Files
headscale/tools/bump/docker.go
T
Kristoffer Dalby c3d771e23e tools/bump: add the version bump tool
Keeps the interlocked pins current and reports what it could not move.
Areas apply, gate and commit one at a time, so a dependency that breaks
the build costs one commit rather than the whole pull request.
2026-09-25 15:52:26 +02:00

262 lines
6.5 KiB
Go

package main
import (
"context"
"errors"
"fmt"
"net/http"
"regexp"
"strings"
"golang.org/x/mod/semver"
)
// tailscaleGoModURL is the go directive that the two builder stages cloning
// tailscale must be able to satisfy.
const tailscaleGoModURL = "https://raw.githubusercontent.com/tailscale/tailscale/main/go.mod"
const dockerHubTagURL = "https://hub.docker.com/v2/repositories/library/%s/tags/%s"
var (
errNoGoDirective = errors.New("no go directive found")
errTagMissing = errors.New("image tag does not exist")
)
// golangFrom matches the version inside a golang builder image reference,
// leaving any registry prefix and OS suffix untouched.
var golangFrom = regexp.MustCompile(`(?m)^(FROM\s+\S*golang:)(\d[\w.]*)(-[\w.]+)?`)
// goPin is a Dockerfile whose golang builder must satisfy some minimum.
type goPin struct {
File string
// Why names the thing that sets the floor, for the report.
Why string
}
// tailscaleBuilders clone tailscale from an unpinned branch, so their floor is
// upstream's go directive rather than ours.
var tailscaleBuilders = []goPin{
{File: "Dockerfile.tailscale-HEAD", Why: "tailscale main"},
{File: "Dockerfile.derper", Why: "tailscale main"},
}
// localBuilders compile this repository, so they track the toolchain the nix
// build uses and must not fall below go.mod's own directive.
var localBuilders = []goPin{
{File: "Dockerfile.integration", Why: "devShell Go"},
{File: "Dockerfile.wasmclient", Why: "devShell Go"},
}
// currentGolangTag reports the version pinned in a Dockerfile's builder stage.
func currentGolangTag(content string) (string, bool) {
m := golangFrom.FindStringSubmatch(content)
if m == nil {
return "", false
}
return m[2], true
}
// golangTagSuffix reports the OS variant of a builder image, such as "-alpine".
func golangTagSuffix(content string) string {
m := golangFrom.FindStringSubmatch(content)
if m == nil {
return ""
}
return m[3]
}
// setGolangTag rewrites every golang builder reference to want.
func setGolangTag(content, want string) string {
return golangFrom.ReplaceAllString(content, "${1}"+want+"${3}")
}
// goDirective extracts the version from a go.mod's go line.
func goDirective(goMod string) (string, error) {
for line := range strings.Lines(goMod) {
if rest, ok := strings.CutPrefix(line, "go "); ok {
fields := strings.Fields(rest)
if len(fields) > 0 {
return fields[0], nil
}
}
}
return "", errNoGoDirective
}
// tailscaleGo reads the go directive at the tip of tailscale's default branch.
func tailscaleGo(ctx context.Context) (string, error) {
body, err := fetch(ctx, tailscaleGoModURL)
if err != nil {
return "", err
}
return goDirective(string(body))
}
// tagExists asks the registry whether a tag is published. Rewriting a
// Dockerfile to a tag that has not been pushed yet turns a bump into an outage.
func tagExists(ctx context.Context, image, tag string) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
fmt.Sprintf(dockerHubTagURL, image, tag), nil)
if err != nil {
return fmt.Errorf("building tag request: %w", err)
}
client := &http.Client{Timeout: proxyTimeout}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("checking %s:%s: %w", image, tag, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("%w: %s:%s (%s)", errTagMissing, image, tag, resp.Status)
}
return nil
}
// higher returns whichever Go version sorts later.
func higher(a, b string) string {
if semver.Compare("v"+a, "v"+b) >= 0 {
return a
}
return b
}
// bumpBuilders raises each pin to want, and reports what moved.
func bumpBuilders(ctx context.Context, r *repo, pins []goPin, want string) ([]string, error) {
var moved []string
for _, pin := range pins {
content, err := r.readFile(pin.File)
if err != nil {
return nil, err
}
have, ok := currentGolangTag(content)
if !ok {
return nil, fmt.Errorf("%s: %w", pin.File, errNoGoDirective)
}
if semver.Compare("v"+have, "v"+want) >= 0 {
continue
}
// Probe only a tag that is about to be written. nixpkgs can ship a Go
// release before the image is published, and a pin that needs no change
// must not be held up by a tag nobody is going to reference.
err = tagExists(ctx, "golang", want+golangTagSuffix(content))
if err != nil {
return nil, err
}
err = r.writeFile(pin.File, setGolangTag(content, want))
if err != nil {
return nil, err
}
moved = append(moved, fmt.Sprintf("%s golang %s -> %s (%s)", pin.File, have, want, pin.Why))
}
return moved, nil
}
// applyDockerGo brings every golang builder image up to the version its
// consumer requires. The relation is a floor, not equality: a newer toolchain
// compiles an older module, and only the reverse fails.
func applyDockerGo(ctx context.Context, r *repo) (change, error) {
tsGo, err := tailscaleGo(ctx)
if err != nil {
return change{}, err
}
nixGo, err := goVersion(ctx, r)
if err != nil {
return change{}, err
}
ourMod, err := r.readFile("go.mod")
if err != nil {
return change{}, err
}
ourGo, err := goDirective(ourMod)
if err != nil {
return change{}, err
}
localWant := higher(nixGo, ourGo)
moved, err := bumpBuilders(ctx, r, tailscaleBuilders, tsGo)
if err != nil {
return change{}, err
}
movedLocal, err := bumpBuilders(ctx, r, localBuilders, localWant)
if err != nil {
return change{}, err
}
moved = append(moved, movedLocal...)
if len(moved) == 0 {
return change{Empty: true}, nil
}
return change{
Summary: fmt.Sprintf("golang builders to %s / %s", tsGo, localWant),
Detail: moved,
}, nil
}
// gateDockerGo re-reads what was written and re-checks the floors, so a bad
// rewrite is caught before anything is committed.
func gateDockerGo(ctx context.Context, r *repo) error {
tsGo, err := tailscaleGo(ctx)
if err != nil {
return err
}
ourMod, err := r.readFile("go.mod")
if err != nil {
return err
}
ourGo, err := goDirective(ourMod)
if err != nil {
return err
}
for _, check := range []struct {
pins []goPin
floor string
}{
{tailscaleBuilders, tsGo},
{localBuilders, ourGo},
} {
for _, pin := range check.pins {
content, err := r.readFile(pin.File)
if err != nil {
return err
}
have, ok := currentGolangTag(content)
if !ok {
return fmt.Errorf("%s: %w", pin.File, errNoGoDirective)
}
if semver.Compare("v"+have, "v"+check.floor) < 0 {
return fmt.Errorf("%s: golang %s is below the required %s: %w",
pin.File, have, check.floor, errTagMissing)
}
}
}
return nil
}