mirror of
https://github.com/tailscale/tailscale.git
synced 2026-05-06 20:56:24 +02:00
Consolidate go.mod.sri and go.toolchain.rev.sri into a single flakehashes.json file at the repo root, owned by a new Go program at tool/updateflakes. The JSON is consumed by flake.nix via builtins.fromJSON and by any future Go code via the FlakeHashes struct that defines its schema. Each block records its input fingerprint alongside the SRI it produced: the goModSum (a sha256 over go.mod and go.sum) for the vendor block, and the literal rev string from go.toolchain.rev for the toolchain block. updateflakes regenerates a block only when its recorded fingerprint disagrees with the current input. Doing the gating by content rather than file mtimes avoids the usual mtime hazards across git checkouts, clones, and merges. It also means re-runs with no input changes are essentially free, and a re-run that touches only one input pays only for that one block. The two blocks have no shared state -- vendor invokes go mod vendor into one tempdir, toolchain fetches and extracts a tarball into another -- so they run concurrently via errgroup. Cold time is bounded by the slower of the two rather than their sum. Also takes the opportunity to fold the toolchain fetch into a single curl|tar pipeline (no intermediate .tar.gz on disk). Split cmd/nardump into a thin package main and a new package nardump library at cmd/nardump/nardump that holds the NAR encoder and SRI helper. tool/updateflakes imports the library directly rather than building and exec'ing the nardump binary at runtime. The library uses fs.ReadLink (Go 1.25+) instead of os.Readlink, so it no longer requires the caller to chdir into the FS root for symlink targets to resolve. WriteNAR now wraps its writer in a bufio.Writer internally (unless the caller already passed one) and flushes on return, so callers don't pay for tiny writes against slow underlying writers. The cache-busting line in flake.nix and shell.nix is known to live at end of file, so updateCacheBust walks the lines in reverse. make tidy timings on this machine, before: ~14s every run. After: warm (no input changes): 0.05s vendor block stale only: 1.4s toolchain block stale only: 5.0s cold (no flakehashes.json): 5.0s Updates #6845 Change-Id: I0340608798f1614abf147a491bf7c68a198a0db4 Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
265 lines
6.3 KiB
Go
265 lines
6.3 KiB
Go
// Copyright (c) Tailscale Inc & contributors
|
|
// SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
// updateflakes regenerates flakehashes.json, the file that records
|
|
// the Nix SRI hashes for the Go module vendor tree and the Tailscale
|
|
// Go toolchain tarball.
|
|
//
|
|
// The file is content-addressed: each block records the input
|
|
// fingerprint that produced its SRI, and updateflakes only
|
|
// regenerates a block when the current input differs from the
|
|
// recorded fingerprint. As a result, repeat runs with no input
|
|
// changes are no-ops.
|
|
//
|
|
// Run from the repo root:
|
|
//
|
|
// ./tool/go run ./tool/updateflakes
|
|
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"io/fs"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"golang.org/x/sync/errgroup"
|
|
"tailscale.com/cmd/nardump/nardump"
|
|
)
|
|
|
|
const (
|
|
hashesFile = "flakehashes.json"
|
|
goModFile = "go.mod"
|
|
goSumFile = "go.sum"
|
|
toolchainRevFile = "go.toolchain.rev"
|
|
flakeNixFile = "flake.nix"
|
|
shellNixFile = "shell.nix"
|
|
cacheBustPrefix = "# nix-direnv cache busting line:"
|
|
)
|
|
|
|
// FlakeHashes is the on-disk schema of flakehashes.json. It is also
|
|
// consumed directly by flake.nix via builtins.fromJSON, so changes
|
|
// to the JSON shape must be coordinated with flake.nix.
|
|
type FlakeHashes struct {
|
|
Toolchain ToolchainHash `json:"toolchain"`
|
|
Vendor VendorHash `json:"vendor"`
|
|
}
|
|
|
|
// ToolchainHash records the SRI of the Tailscale Go toolchain
|
|
// tarball. Rev is the value in go.toolchain.rev that produced SRI.
|
|
type ToolchainHash struct {
|
|
Rev string `json:"rev"`
|
|
SRI string `json:"sri"`
|
|
}
|
|
|
|
// VendorHash records the SRI of `go mod vendor` output. GoModSum is a
|
|
// fingerprint of go.mod and go.sum that produced SRI.
|
|
type VendorHash struct {
|
|
GoModSum string `json:"goModSum"`
|
|
SRI string `json:"sri"`
|
|
}
|
|
|
|
func main() {
|
|
flag.Parse()
|
|
if err := run(); err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func run() error {
|
|
have, err := loadHashes()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
want := have
|
|
|
|
rev, err := readTrim(toolchainRevFile)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
wantToolchain := have.Toolchain.Rev != rev || have.Toolchain.SRI == ""
|
|
|
|
goModSum, err := goModFingerprint()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
wantVendor := have.Vendor.GoModSum != goModSum || have.Vendor.SRI == ""
|
|
|
|
var (
|
|
newToolchain ToolchainHash
|
|
newVendor VendorHash
|
|
)
|
|
var g errgroup.Group
|
|
if wantToolchain {
|
|
g.Go(func() error {
|
|
sri, err := hashToolchain(rev)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
newToolchain = ToolchainHash{Rev: rev, SRI: sri}
|
|
return nil
|
|
})
|
|
}
|
|
if wantVendor {
|
|
g.Go(func() error {
|
|
sri, err := hashVendor()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
newVendor = VendorHash{GoModSum: goModSum, SRI: sri}
|
|
return nil
|
|
})
|
|
}
|
|
if err := g.Wait(); err != nil {
|
|
return err
|
|
}
|
|
if wantToolchain {
|
|
want.Toolchain = newToolchain
|
|
}
|
|
if wantVendor {
|
|
want.Vendor = newVendor
|
|
}
|
|
|
|
if want != have {
|
|
if err := writeHashes(want); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// nix-direnv only watches the top-level nix files for changes,
|
|
// so when a referenced hash changes we must also tickle
|
|
// flake.nix and shell.nix to force re-evaluation.
|
|
for _, f := range []string{flakeNixFile, shellNixFile} {
|
|
if err := updateCacheBust(f, want.Vendor.SRI); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func loadHashes() (FlakeHashes, error) {
|
|
var h FlakeHashes
|
|
data, err := os.ReadFile(hashesFile)
|
|
if errors.Is(err, fs.ErrNotExist) {
|
|
return h, nil
|
|
}
|
|
if err != nil {
|
|
return h, err
|
|
}
|
|
if err := json.Unmarshal(data, &h); err != nil {
|
|
return h, fmt.Errorf("parse %s: %w", hashesFile, err)
|
|
}
|
|
return h, nil
|
|
}
|
|
|
|
func writeHashes(h FlakeHashes) error {
|
|
b, err := json.MarshalIndent(h, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
b = append(b, '\n')
|
|
return os.WriteFile(hashesFile, b, 0644)
|
|
}
|
|
|
|
func readTrim(path string) (string, error) {
|
|
b, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return strings.TrimSpace(string(b)), nil
|
|
}
|
|
|
|
// goModFingerprint returns a content fingerprint of go.mod and go.sum
|
|
// that changes whenever either file changes.
|
|
func goModFingerprint() (string, error) {
|
|
h := sha256.New()
|
|
for _, f := range []string{goModFile, goSumFile} {
|
|
b, err := os.ReadFile(f)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
fmt.Fprintf(h, "%s %d\n", f, len(b))
|
|
h.Write(b)
|
|
}
|
|
return "sha256-" + base64.StdEncoding.EncodeToString(h.Sum(nil)), nil
|
|
}
|
|
|
|
func hashVendor() (string, error) {
|
|
out, err := os.MkdirTemp("", "nar-vendor-")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
// `go mod vendor -o` requires the destination to not already exist.
|
|
if err := os.Remove(out); err != nil {
|
|
return "", err
|
|
}
|
|
defer os.RemoveAll(out)
|
|
|
|
cmd := exec.Command("./tool/go", "mod", "vendor", "-o", out)
|
|
cmd.Env = append(os.Environ(), "GOWORK=off")
|
|
cmd.Stderr = os.Stderr
|
|
if err := cmd.Run(); err != nil {
|
|
return "", fmt.Errorf("go mod vendor: %w", err)
|
|
}
|
|
return nardump.SRI(os.DirFS(out))
|
|
}
|
|
|
|
func hashToolchain(rev string) (string, error) {
|
|
out, err := os.MkdirTemp("", "nar-toolchain-")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer os.RemoveAll(out)
|
|
|
|
url := fmt.Sprintf("https://github.com/tailscale/go/archive/%s.tar.gz", rev)
|
|
resp, err := http.Get(url)
|
|
if err != nil {
|
|
return "", fmt.Errorf("fetching %s: %w", url, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return "", fmt.Errorf("fetching %s: %s", url, resp.Status)
|
|
}
|
|
|
|
tar := exec.Command("tar", "-xz", "-C", out)
|
|
tar.Stdin = resp.Body
|
|
tar.Stderr = os.Stderr
|
|
if err := tar.Run(); err != nil {
|
|
return "", fmt.Errorf("extracting toolchain tarball: %w", err)
|
|
}
|
|
return nardump.SRI(os.DirFS(filepath.Join(out, "go-"+rev)))
|
|
}
|
|
|
|
// updateCacheBust rewrites the "# nix-direnv cache busting line"
|
|
// in path to embed sri so nix-direnv re-evaluates when the SRI
|
|
// changes. The line lives at end of file, so walk in reverse.
|
|
func updateCacheBust(path, sri string) error {
|
|
b, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
want := []byte(cacheBustPrefix + " " + sri)
|
|
lines := bytes.Split(b, []byte("\n"))
|
|
for i := len(lines) - 1; i >= 0; i-- {
|
|
line := lines[i]
|
|
if !bytes.HasPrefix(line, []byte(cacheBustPrefix)) {
|
|
continue
|
|
}
|
|
if bytes.Equal(line, want) {
|
|
return nil
|
|
}
|
|
lines[i] = want
|
|
return os.WriteFile(path, bytes.Join(lines, []byte("\n")), 0644)
|
|
}
|
|
return fmt.Errorf("%s: missing %q line", path, cacheBustPrefix)
|
|
}
|