mirror of
https://github.com/juanfont/headscale.git
synced 2026-09-26 18:24:54 +09:00
c3d771e23e
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.
262 lines
6.5 KiB
Go
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
|
|
}
|