banger/internal/smoketest/release_server_test.go
2026-05-01 19:34:44 -03:00

310 lines
8.8 KiB
Go

//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, "..", ".."))
}