//go:build smoke package smoketest import ( "archive/tar" "compress/gzip" "crypto/ecdsa" "crypto/elliptic" "crypto/rand" "crypto/sha256" "crypto/x509" "encoding/base64" "encoding/pem" "fmt" "io" "net/http" "net/http/httptest" "os" "os/exec" "path/filepath" "strings" "sync" ) // Release-server state set up lazily by prepareSmokeReleases. The HTTP // server stays up for the duration of TestMain (shut down in teardown). // smokeRelOnce serializes concurrent first-callers; smokeRelErr is the // stored result for replay so subsequent callers see the same outcome. var ( smokeRelOnce sync.Once smokeRelErr error manifestURL string pubkeyFile string releaseHTTPServer *httptest.Server releaseRelDir string smokeRelKey *ecdsa.PrivateKey ) const ( smokeReleaseGood = "v0.smoke.0" smokeReleaseBroken = "v0.smoke.broken-bangerd" ) // prepareSmokeReleases is the Go port of scripts/smoke.sh's // prepare_smoke_releases. It generates an ECDSA P-256 keypair (matching // cosign blob signatures, which are ASN.1 DER ECDSA over SHA256(body), // base64-encoded), builds two coverage-instrumented release tarballs // signed with that key, writes a manifest, and stands up an httptest // file server. The hidden --manifest-url / --pubkey-file flags on // `banger update` redirect the updater at this fake bucket. // // Idempotent. The first caller pays the build/server cost; later // callers replay the cached result. func prepareSmokeReleases() error { smokeRelOnce.Do(func() { smokeRelErr = doPrepareSmokeReleases() }) return smokeRelErr } func doPrepareSmokeReleases() error { releaseRelDir = filepath.Join(scratchRoot, "release") if err := os.RemoveAll(releaseRelDir); err != nil { return fmt.Errorf("clean release dir: %w", err) } if err := os.MkdirAll(releaseRelDir, 0o755); err != nil { return fmt.Errorf("mkdir release dir: %w", err) } priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) if err != nil { return fmt.Errorf("generate ECDSA key: %w", err) } smokeRelKey = priv pubDER, err := x509.MarshalPKIXPublicKey(&priv.PublicKey) if err != nil { return fmt.Errorf("marshal pub key: %w", err) } pubPEM := pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubDER}) pubkeyFile = filepath.Join(releaseRelDir, "cosign.pub") if err := os.WriteFile(pubkeyFile, pubPEM, 0o644); err != nil { return fmt.Errorf("write pub key: %w", err) } if err := buildSmokeReleaseTarball(smokeReleaseGood); err != nil { return err } if err := buildSmokeReleaseTarball(smokeReleaseBroken); err != nil { return err } releaseHTTPServer = httptest.NewServer(http.FileServer(http.Dir(releaseRelDir))) manifestPath := filepath.Join(releaseRelDir, "manifest.json") if err := writeSmokeManifest(manifestPath, releaseHTTPServer.URL); err != nil { return err } manifestURL = releaseHTTPServer.URL + "/manifest.json" return nil } func shutdownReleaseServer() { if releaseHTTPServer != nil { releaseHTTPServer.Close() } } // buildSmokeReleaseTarball is the Go port of build_smoke_release_tarball // from scripts/smoke.sh. It compiles banger / bangerd / banger-vsock-agent // with the requested Version baked in, packages them as a gzip tarball, // and writes SHA256SUMS + SHA256SUMS.sig alongside. // // The v0.smoke.broken-* family ships a shell-script bangerd that passes // `--check-migrations` (so the swap proceeds) but exits non-zero in // service mode (so the post-swap restart fails and rollbackAndWrap // fires). Same trick the bash version uses. func buildSmokeReleaseTarball(version string) error { outDir := filepath.Join(releaseRelDir, version) stage := filepath.Join(outDir, ".stage") if err := os.MkdirAll(stage, 0o755); err != nil { return fmt.Errorf("mkdir stage: %w", err) } ldflags := "-X banger/internal/buildinfo.Version=" + version + " -X banger/internal/buildinfo.Commit=smoke" + " -X banger/internal/buildinfo.BuiltAt=2026-04-30T00:00:00Z" root, err := repoRoot() if err != nil { return err } build := func(target, output string, extraEnv ...string) error { cmd := exec.Command("go", "build", "-ldflags", ldflags, "-o", output, target) cmd.Dir = root if len(extraEnv) > 0 { cmd.Env = append(os.Environ(), extraEnv...) } if out, err := cmd.CombinedOutput(); err != nil { return fmt.Errorf("build %s@%s: %w\n%s", target, version, err, out) } return nil } if err := build("./cmd/banger", filepath.Join(stage, "banger")); err != nil { return err } if strings.HasPrefix(version, "v0.smoke.broken-") { const brokenScript = `#!/bin/sh case "$*" in *--check-migrations*) printf 'compatible: smoke broken-bangerd pretends to be ready\n' exit 0 ;; *) printf 'smoke broken-bangerd: refusing to run as daemon\n' >&2 exit 1 ;; esac ` if err := os.WriteFile(filepath.Join(stage, "bangerd"), []byte(brokenScript), 0o755); err != nil { return fmt.Errorf("write broken bangerd: %w", err) } } else { if err := build("./cmd/bangerd", filepath.Join(stage, "bangerd")); err != nil { return err } } if err := build("./cmd/banger-vsock-agent", filepath.Join(stage, "banger-vsock-agent"), "CGO_ENABLED=0", "GOOS=linux", "GOARCH=amd64"); err != nil { return err } tarballName := fmt.Sprintf("banger-%s-linux-amd64.tar.gz", version) tarballPath := filepath.Join(outDir, tarballName) if err := writeTarGz(stage, tarballPath); err != nil { return fmt.Errorf("tar %s: %w", version, err) } body, err := os.ReadFile(tarballPath) if err != nil { return fmt.Errorf("read tarball: %w", err) } hash := sha256.Sum256(body) sumsBody := fmt.Sprintf("%x %s\n", hash, tarballName) if err := os.WriteFile(filepath.Join(outDir, "SHA256SUMS"), []byte(sumsBody), 0o644); err != nil { return fmt.Errorf("write SHA256SUMS: %w", err) } sig, err := signCosignBlob(smokeRelKey, []byte(sumsBody)) if err != nil { return fmt.Errorf("sign SHA256SUMS for %s: %w", version, err) } if err := os.WriteFile(filepath.Join(outDir, "SHA256SUMS.sig"), []byte(sig), 0o644); err != nil { return fmt.Errorf("write sig: %w", err) } return os.RemoveAll(stage) } // signCosignBlob produces a cosign-compatible blob signature: ASN.1 DER // ECDSA over SHA256(body), base64 encoded with no newline. This is the // exact wire format cosign produces and the Go updater verifies, and // matches the bash chain `openssl dgst -sha256 -sign | base64 -w0`. func signCosignBlob(priv *ecdsa.PrivateKey, body []byte) (string, error) { hash := sha256.Sum256(body) sig, err := ecdsa.SignASN1(rand.Reader, priv, hash[:]) if err != nil { return "", err } return base64.StdEncoding.EncodeToString(sig), nil } // writeTarGz packages every regular file in srcDir at the root of a // gzip tarball at dst. Mirrors the bash `tar czf` of the staged binary // trio (banger, bangerd, banger-vsock-agent). func writeTarGz(srcDir, dst string) error { out, err := os.Create(dst) if err != nil { return err } defer out.Close() gw := gzip.NewWriter(out) defer gw.Close() tw := tar.NewWriter(gw) defer tw.Close() entries, err := os.ReadDir(srcDir) if err != nil { return err } for _, e := range entries { if !e.Type().IsRegular() { continue } path := filepath.Join(srcDir, e.Name()) st, err := os.Stat(path) if err != nil { return err } hdr := &tar.Header{ Name: e.Name(), Mode: int64(st.Mode().Perm()), Size: st.Size(), ModTime: st.ModTime(), } if err := tw.WriteHeader(hdr); err != nil { return err } f, err := os.Open(path) if err != nil { return err } if _, err := io.Copy(tw, f); err != nil { f.Close() return err } f.Close() } return nil } func writeSmokeManifest(path, base string) error { body := fmt.Sprintf(`{ "schema_version": 1, "latest_stable": %q, "releases": [ { "version": %q, "tarball_url": "%s/%s/banger-%s-linux-amd64.tar.gz", "sha256sums_url": "%s/%s/SHA256SUMS", "sha256sums_sig_url": "%s/%s/SHA256SUMS.sig", "released_at": "2026-04-29T00:00:00Z" }, { "version": %q, "tarball_url": "%s/%s/banger-%s-linux-amd64.tar.gz", "sha256sums_url": "%s/%s/SHA256SUMS", "sha256sums_sig_url": "%s/%s/SHA256SUMS.sig", "released_at": "2026-04-30T00:00:00Z" } ] } `, smokeReleaseGood, smokeReleaseGood, base, smokeReleaseGood, smokeReleaseGood, base, smokeReleaseGood, base, smokeReleaseGood, smokeReleaseBroken, base, smokeReleaseBroken, smokeReleaseBroken, base, smokeReleaseBroken, base, smokeReleaseBroken, ) return os.WriteFile(path, []byte(body), 0o644) } // repoRoot resolves the repo root (where go.mod lives) from the test // binary's cwd. `go test` runs each package's tests from that package's // source dir, so internal/smoketest -> ../.. lands at the root. func repoRoot() (string, error) { cwd, err := os.Getwd() if err != nil { return "", err } return filepath.Abs(filepath.Join(cwd, "..", "..")) }