From f798e1db33074e2ffa5612b8daeaa88a57291bcc Mon Sep 17 00:00:00 2001 From: Thales Maciel Date: Sun, 22 Mar 2026 17:14:06 -0300 Subject: [PATCH] Stamp shared build metadata into banger binaries Treat `banger`, `bangerd`, and `banger-vsock-agent` as one release by stamping the same version, commit SHA, and build timestamp into every binary through a shared ldflag-backed `internal/buildinfo` package. Add `banger version`, extend daemon ping/status to report the running daemon's build tuple, and keep the guest helper linked to the same build metadata without adding a new public version surface for it. Validate with `GOCACHE=/tmp/banger-gocache go test ./...`, `make build`, `./build/bin/banger version`, and `./build/bin/banger daemon status` after the daemon restarts onto the new binary. --- Makefile | 10 ++-- cmd/banger-vsock-agent/main.go | 3 ++ internal/api/types.go | 9 ++-- internal/buildinfo/buildinfo.go | 34 +++++++++++++ internal/buildinfo/buildinfo_test.go | 33 ++++++++++++ internal/cli/banger.go | 31 ++++++++++-- internal/cli/cli_test.go | 76 +++++++++++++++++++++++++++- internal/daemon/daemon.go | 11 +++- internal/daemon/daemon_test.go | 25 +++++++++ 9 files changed, 219 insertions(+), 13 deletions(-) create mode 100644 internal/buildinfo/buildinfo.go create mode 100644 internal/buildinfo/buildinfo_test.go diff --git a/Makefile b/Makefile index 4dd0db6..5c15b23 100644 --- a/Makefile +++ b/Makefile @@ -20,6 +20,10 @@ VOID_VM_NAME ?= void-dev ALPINE_RELEASE ?= 3.23.3 ALPINE_IMAGE_NAME ?= alpine ALPINE_VM_NAME ?= alpine-dev +VERSION ?= $(shell git describe --tags --exact-match 2>/dev/null || echo dev) +COMMIT ?= $(shell git rev-parse --verify HEAD 2>/dev/null || echo unknown) +BUILT_AT ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ) +GO_LDFLAGS := -X banger/internal/buildinfo.Version=$(VERSION) -X banger/internal/buildinfo.Commit=$(COMMIT) -X banger/internal/buildinfo.BuiltAt=$(BUILT_AT) .DEFAULT_GOAL := help @@ -51,15 +55,15 @@ build: $(BINARIES) $(BANGER_BIN): $(GO_SOURCES) go.mod go.sum mkdir -p "$(BUILD_BIN_DIR)" - $(GO) build -o "$(BANGER_BIN)" ./cmd/banger + $(GO) build -ldflags '$(GO_LDFLAGS)' -o "$(BANGER_BIN)" ./cmd/banger $(BANGERD_BIN): $(GO_SOURCES) go.mod go.sum mkdir -p "$(BUILD_BIN_DIR)" - $(GO) build -o "$(BANGERD_BIN)" ./cmd/bangerd + $(GO) build -ldflags '$(GO_LDFLAGS)' -o "$(BANGERD_BIN)" ./cmd/bangerd $(VSOCK_AGENT_BIN): $(GO_SOURCES) go.mod go.sum mkdir -p "$(BUILD_BIN_DIR)" - CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GO) build -o "$(VSOCK_AGENT_BIN)" ./cmd/banger-vsock-agent + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GO) build -ldflags '$(GO_LDFLAGS)' -o "$(VSOCK_AGENT_BIN)" ./cmd/banger-vsock-agent test: $(GO) test ./... diff --git a/cmd/banger-vsock-agent/main.go b/cmd/banger-vsock-agent/main.go index 54cf31a..a45a8c0 100644 --- a/cmd/banger-vsock-agent/main.go +++ b/cmd/banger-vsock-agent/main.go @@ -11,12 +11,15 @@ import ( "syscall" "time" + "banger/internal/buildinfo" sdkvsock "github.com/firecracker-microvm/firecracker-go-sdk/vsock" "github.com/sirupsen/logrus" "banger/internal/vsockagent" ) +var _, _, _ = buildinfo.Version, buildinfo.Commit, buildinfo.BuiltAt + func main() { ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer cancel() diff --git a/internal/api/types.go b/internal/api/types.go index fcd6961..5c5b334 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -9,9 +9,12 @@ import ( type Empty struct{} type PingResult struct { - Status string `json:"status"` - PID int `json:"pid"` - WebURL string `json:"web_url,omitempty"` + Status string `json:"status"` + PID int `json:"pid"` + WebURL string `json:"web_url,omitempty"` + Version string `json:"version,omitempty"` + Commit string `json:"commit,omitempty"` + BuiltAt string `json:"built_at,omitempty"` } type ShutdownResult struct { diff --git a/internal/buildinfo/buildinfo.go b/internal/buildinfo/buildinfo.go new file mode 100644 index 0000000..61bc6c2 --- /dev/null +++ b/internal/buildinfo/buildinfo.go @@ -0,0 +1,34 @@ +package buildinfo + +import "strings" + +var ( + Version = "dev" + Commit = "unknown" + BuiltAt = "unknown" +) + +type Info struct { + Version string + Commit string + BuiltAt string +} + +func Current() Info { + return Normalize(Version, Commit, BuiltAt) +} + +func Normalize(version, commit, builtAt string) Info { + return Info{ + Version: normalizedValue(version, "dev"), + Commit: normalizedValue(commit, "unknown"), + BuiltAt: normalizedValue(builtAt, "unknown"), + } +} + +func normalizedValue(value, fallback string) string { + if trimmed := strings.TrimSpace(value); trimmed != "" { + return trimmed + } + return fallback +} diff --git a/internal/buildinfo/buildinfo_test.go b/internal/buildinfo/buildinfo_test.go new file mode 100644 index 0000000..51b1ce2 --- /dev/null +++ b/internal/buildinfo/buildinfo_test.go @@ -0,0 +1,33 @@ +package buildinfo + +import "testing" + +func TestNormalizeUsesFallbacks(t *testing.T) { + t.Parallel() + + info := Normalize("", " ", "\t") + if info.Version != "dev" { + t.Fatalf("Version = %q, want dev", info.Version) + } + if info.Commit != "unknown" { + t.Fatalf("Commit = %q, want unknown", info.Commit) + } + if info.BuiltAt != "unknown" { + t.Fatalf("BuiltAt = %q, want unknown", info.BuiltAt) + } +} + +func TestNormalizeTrimsValues(t *testing.T) { + t.Parallel() + + info := Normalize(" v1.2.3 ", " abc123 ", " 2026-03-22T12:00:00Z ") + if info.Version != "v1.2.3" { + t.Fatalf("Version = %q, want v1.2.3", info.Version) + } + if info.Commit != "abc123" { + t.Fatalf("Commit = %q, want abc123", info.Commit) + } + if info.BuiltAt != "2026-03-22T12:00:00Z" { + t.Fatalf("BuiltAt = %q, want 2026-03-22T12:00:00Z", info.BuiltAt) + } +} diff --git a/internal/cli/banger.go b/internal/cli/banger.go index 56e850d..2fda7c4 100644 --- a/internal/cli/banger.go +++ b/internal/cli/banger.go @@ -19,6 +19,7 @@ import ( "time" "banger/internal/api" + "banger/internal/buildinfo" "banger/internal/config" "banger/internal/daemon" "banger/internal/guest" @@ -70,6 +71,9 @@ var ( vmHealthFunc = func(ctx context.Context, socketPath, idOrName string) (api.VMHealthResult, error) { return rpc.Call[api.VMHealthResult](ctx, socketPath, "vm.health", api.VMRefParams{IDOrName: idOrName}) } + daemonPingFunc = func(ctx context.Context, socketPath string) (api.PingResult, error) { + return rpc.Call[api.PingResult](ctx, socketPath, "ping", api.Empty{}) + } vmCreateBeginFunc = func(ctx context.Context, socketPath string, params api.VMCreateParams) (api.VMCreateBeginResult, error) { return rpc.Call[api.VMCreateBeginResult](ctx, socketPath, "vm.create.begin", params) } @@ -121,7 +125,7 @@ func NewBangerCommand() *cobra.Command { RunE: helpNoArgs, } root.CompletionOptions.DisableDefaultCmd = true - root.AddCommand(newDaemonCommand(), newDoctorCommand(), newVMCommand(), newImageCommand(), newInternalCommand()) + root.AddCommand(newDaemonCommand(), newDoctorCommand(), newImageCommand(), newInternalCommand(), newVersionCommand(), newVMCommand()) return root } @@ -146,6 +150,18 @@ func newDoctorCommand() *cobra.Command { } } +func newVersionCommand() *cobra.Command { + return &cobra.Command{ + Use: "version", + Short: "Show banger build information", + Args: noArgsUsage("usage: banger version"), + RunE: func(cmd *cobra.Command, args []string) error { + _, err := fmt.Fprint(cmd.OutOrStdout(), formatBuildInfoBlock(buildinfo.Current())) + return err + }, + } +} + func newInternalCommand() *cobra.Command { cmd := &cobra.Command{ Use: "internal", @@ -342,7 +358,7 @@ func newDaemonCommand() *cobra.Command { if err != nil { return err } - ping, pingErr := rpc.Call[api.PingResult](cmd.Context(), layout.SocketPath, "ping", api.Empty{}) + ping, pingErr := daemonPingFunc(cmd.Context(), layout.SocketPath) if pingErr != nil { if strings.TrimSpace(cfg.WebListenAddr) != "" { _, err = fmt.Fprintf(cmd.OutOrStdout(), "stopped\nsocket: %s\nlog: %s\ndns: %s\nweb: http://%s\n", layout.SocketPath, layout.DaemonLog, vmdns.DefaultListenAddr, cfg.WebListenAddr) @@ -351,11 +367,12 @@ func newDaemonCommand() *cobra.Command { _, err = fmt.Fprintf(cmd.OutOrStdout(), "stopped\nsocket: %s\nlog: %s\ndns: %s\n", layout.SocketPath, layout.DaemonLog, vmdns.DefaultListenAddr) return err } + info := buildinfo.Normalize(ping.Version, ping.Commit, ping.BuiltAt) if strings.TrimSpace(ping.WebURL) != "" { - _, err = fmt.Fprintf(cmd.OutOrStdout(), "running\npid: %d\nsocket: %s\nlog: %s\ndns: %s\nweb: %s\n", ping.PID, layout.SocketPath, layout.DaemonLog, vmdns.DefaultListenAddr, ping.WebURL) + _, err = fmt.Fprintf(cmd.OutOrStdout(), "running\npid: %d\n%ssocket: %s\nlog: %s\ndns: %s\nweb: %s\n", ping.PID, formatBuildInfoBlock(info), layout.SocketPath, layout.DaemonLog, vmdns.DefaultListenAddr, ping.WebURL) return err } - _, err = fmt.Fprintf(cmd.OutOrStdout(), "running\npid: %d\nsocket: %s\nlog: %s\ndns: %s\n", ping.PID, layout.SocketPath, layout.DaemonLog, vmdns.DefaultListenAddr) + _, err = fmt.Fprintf(cmd.OutOrStdout(), "running\npid: %d\n%ssocket: %s\nlog: %s\ndns: %s\n", ping.PID, formatBuildInfoBlock(info), layout.SocketPath, layout.DaemonLog, vmdns.DefaultListenAddr) return err }, }, @@ -1141,7 +1158,7 @@ func ensureDaemon(ctx context.Context) (paths.Layout, model.DaemonConfig, error) if err != nil { return paths.Layout{}, model.DaemonConfig{}, err } - if ping, err := rpc.Call[api.PingResult](ctx, layout.SocketPath, "ping", api.Empty{}); err == nil { + if ping, err := daemonPingFunc(ctx, layout.SocketPath); err == nil { if daemonOutdated(ping.PID) { if err := restartDaemon(ctx, layout, ping.PID); err != nil { return paths.Layout{}, model.DaemonConfig{}, err @@ -2067,3 +2084,7 @@ func relativeTime(t time.Time) string { return fmt.Sprintf("%d weeks ago", int(delta.Hours()/(24*7))) } } + +func formatBuildInfoBlock(info buildinfo.Info) string { + return fmt.Sprintf("version: %s\ncommit: %s\nbuilt_at: %s\n", info.Version, info.Commit, info.BuiltAt) +} diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 95e77ad..0664607 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -15,6 +15,7 @@ import ( "time" "banger/internal/api" + "banger/internal/buildinfo" "banger/internal/model" "banger/internal/system" ) @@ -25,12 +26,36 @@ func TestNewBangerCommandHasExpectedSubcommands(t *testing.T) { for _, sub := range cmd.Commands() { names = append(names, sub.Name()) } - want := []string{"daemon", "doctor", "image", "internal", "vm"} + want := []string{"daemon", "doctor", "image", "internal", "version", "vm"} if !reflect.DeepEqual(names, want) { t.Fatalf("subcommands = %v, want %v", names, want) } } +func TestVersionCommandPrintsBuildInfo(t *testing.T) { + cmd := NewBangerCommand() + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stdout) + cmd.SetArgs([]string{"version"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute: %v", err) + } + + info := buildinfo.Current() + output := stdout.String() + for _, want := range []string{ + "version: " + info.Version, + "commit: " + info.Commit, + "built_at: " + info.BuiltAt, + } { + if !strings.Contains(output, want) { + t.Fatalf("output = %q, want %q", output, want) + } + } +} + func TestLegacyRemovedCommandIsRejected(t *testing.T) { cmd := NewBangerCommand() cmd.SetArgs([]string{"tui"}) @@ -1222,6 +1247,55 @@ func TestDaemonStatusIncludesLogPathWhenStopped(t *testing.T) { } } +func TestDaemonStatusIncludesDaemonBuildInfoWhenRunning(t *testing.T) { + origDaemonPing := daemonPingFunc + t.Cleanup(func() { + daemonPingFunc = origDaemonPing + }) + + configHome := filepath.Join(t.TempDir(), "config") + stateHome := filepath.Join(t.TempDir(), "state") + runtimeHome := filepath.Join(t.TempDir(), "runtime") + t.Setenv("XDG_CONFIG_HOME", configHome) + t.Setenv("XDG_STATE_HOME", stateHome) + t.Setenv("XDG_RUNTIME_DIR", runtimeHome) + + daemonPingFunc = func(context.Context, string) (api.PingResult, error) { + return api.PingResult{ + Status: "ok", + PID: 42, + WebURL: "http://127.0.0.1:7777", + Version: "v1.2.3", + Commit: "abc123", + BuiltAt: "2026-03-22T12:00:00Z", + }, nil + } + + cmd := NewBangerCommand() + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stdout) + cmd.SetArgs([]string{"daemon", "status"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute: %v", err) + } + + output := stdout.String() + for _, want := range []string{ + "running\n", + "pid: 42", + "version: v1.2.3", + "commit: abc123", + "built_at: 2026-03-22T12:00:00Z", + "log: " + filepath.Join(stateHome, "banger", "bangerd.log"), + "web: http://127.0.0.1:7777", + } { + if !strings.Contains(output, want) { + t.Fatalf("output = %q, want %q", output, want) + } + } +} + func TestBuildDaemonCommandIsDetachedFromCallerContext(t *testing.T) { cmd := buildDaemonCommand("/tmp/bangerd") diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index de1176e..4a29f24 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -16,6 +16,7 @@ import ( "time" "banger/internal/api" + "banger/internal/buildinfo" "banger/internal/config" "banger/internal/model" "banger/internal/paths" @@ -250,7 +251,15 @@ func (d *Daemon) dispatch(ctx context.Context, req rpc.Request) rpc.Response { } switch req.Method { case "ping": - result, _ := rpc.NewResult(api.PingResult{Status: "ok", PID: d.pid, WebURL: d.webURL}) + info := buildinfo.Current() + result, _ := rpc.NewResult(api.PingResult{ + Status: "ok", + PID: d.pid, + WebURL: d.webURL, + Version: info.Version, + Commit: info.Commit, + BuiltAt: info.BuiltAt, + }) return result case "shutdown": go d.Close() diff --git a/internal/daemon/daemon_test.go b/internal/daemon/daemon_test.go index dc43c59..1f5780e 100644 --- a/internal/daemon/daemon_test.go +++ b/internal/daemon/daemon_test.go @@ -2,14 +2,17 @@ package daemon import ( "context" + "encoding/json" "os" "path/filepath" "strings" "testing" "banger/internal/api" + "banger/internal/buildinfo" "banger/internal/model" "banger/internal/paths" + "banger/internal/rpc" "banger/internal/system" ) @@ -42,6 +45,28 @@ func TestRegisterImageRequiresKernel(t *testing.T) { } } +func TestDispatchPingIncludesBuildInfo(t *testing.T) { + d := &Daemon{pid: 42, webURL: "http://127.0.0.1:7777"} + + resp := d.dispatch(context.Background(), rpc.Request{Version: rpc.Version, Method: "ping"}) + if !resp.OK { + t.Fatalf("dispatch(ping) = %+v, want ok", resp) + } + + var got api.PingResult + if err := json.Unmarshal(resp.Result, &got); err != nil { + t.Fatalf("Unmarshal(PingResult): %v", err) + } + + info := buildinfo.Current() + if got.Status != "ok" || got.PID != 42 || got.WebURL != "http://127.0.0.1:7777" { + t.Fatalf("PingResult = %+v, want status/pid/weburl populated", got) + } + if got.Version != info.Version || got.Commit != info.Commit || got.BuiltAt != info.BuiltAt { + t.Fatalf("PingResult build info = %+v, want %+v", got, info) + } +} + func TestPromoteImageCopiesBootArtifactsIntoArtifactDir(t *testing.T) { dir := t.TempDir() rootfs := filepath.Join(dir, "rootfs.ext4")