port smoke to go
This commit is contained in:
parent
b0a9d64f4a
commit
9ed44bfd75
20 changed files with 2118 additions and 1573 deletions
310
internal/smoketest/release_server_test.go
Normal file
310
internal/smoketest/release_server_test.go
Normal file
|
|
@ -0,0 +1,310 @@
|
|||
//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, "..", ".."))
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue