310 lines
8.8 KiB
Go
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, "..", ".."))
|
|
}
|