Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 7 additions & 6 deletions go/cmd/compass-stack/container_postgres_podman_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -261,12 +261,13 @@ func newContainerFixture(t *testing.T, root string) containerFixture {
RuntimeDir: runtimeDir,
}
deps, err := buildDeps(stack.Config{
StateDir: cfg.StateDir,
SocketPath: cfg.SocketPath,
ListenAddr: cfg.ListenAddr,
DatabaseDSN: cfg.DatabaseDSN,
AgentImage: cfg.AgentImage,
RuntimeDir: cfg.RuntimeDir,
StateDir: cfg.StateDir,
SocketPath: cfg.SocketPath,
ListenAddr: cfg.ListenAddr,
DatabaseDSN: cfg.DatabaseDSN,
AgentImage: cfg.AgentImage,
RuntimeDir: cfg.RuntimeDir,
ExternalNatsURL: "nats://127.0.0.1:4222",
})
if err != nil {
t.Fatalf("buildDeps: %v", err)
Expand Down
9 changes: 6 additions & 3 deletions go/cmd/compass-stack/cross_process_podman_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,9 +75,12 @@ const pgidRecordName = "stack.pgids"
const upBudget = 3 * time.Minute

// downBudget bounds each `compass-stack down` subprocess. DownDetached's absolute
// worst case (every component escalating to SIGKILL) is ~65s; the SIGTERM-succeeds
// path is far quicker. Bounded so a wedged down fails fast.
const downBudget = 90 * time.Second
// worst case (every component escalating to SIGKILL) is ~105s now that the nats
// drain budget joins the collector/postgres teardown; the SIGTERM-succeeds path
// is ~85s. Bounded generously above the escalation worst case so a `down` that
// legitimately escalates is never killed mid-teardown, while a genuinely wedged
// down still fails fast.
const downBudget = 150 * time.Second

// answerPollInterval is the gap between server-socket readiness probes. Small
// enough to make the post-up live assertion prompt, an explicit event-gate
Expand Down
5 changes: 5 additions & 0 deletions go/cmd/compass-stack/integration_podman_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,11 @@ func newFixture(t *testing.T, shortRoot string) (stackFixture, stack.Deps) {
// CollectorImage and ExternalOTLPEndpoint empty -> an empty-image
// `podman run` deep in the adapter.
otelExternal: "127.0.0.1:4317",
// A struct-literal configFlags bypasses newFlagSet's NatsImage default
// exactly as it does the collector's; this headless stack connects to
// no broker, so opt out rather than bundle a NATS these subtests never
// exercise. The bundled-nats path deserves its own podman-gated test.
natsExternal: "nats://127.0.0.1:4222",
})
if err != nil {
t.Fatalf("resolveConfig: %v", err)
Expand Down
47 changes: 44 additions & 3 deletions go/cmd/compass-stack/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,11 @@ type configFlags struct {
// way --database-external+empty-DSN is rejected — a bare string flag cannot
// tell "set to empty" from "unset" on the value alone.
otelExternalSet bool
natsImage string
natsExternal string
// natsExternalSet records whether --nats-external was explicitly passed, for
// the same explicit-empty reject otelExternalSet drives.
natsExternalSet bool
linger bool
}

Expand Down Expand Up @@ -161,6 +166,12 @@ func newFlagSet(name string, lingerable bool) (*flag.FlagSet, *configFlags) {
"Do not start the bundled OTel Collector; point compass surfaces at this "+
"OTLP endpoint instead (the --otel-external opt-out, D3). The managed "+
"plane supplies its own collector.")
fs.StringVar(&f.natsImage, "nats-image", stack.DefaultNatsImage,
"Container image for the bundled NATS message broker. Defaults to the "+
"pinned upstream nats alpine digest. Ignored with --nats-external.")
fs.StringVar(&f.natsExternal, "nats-external", "",
"Do not start the bundled NATS; point compass surfaces at this nats:// URL "+
"instead. The managed plane supplies its own broker.")
if lingerable {
fs.BoolVar(&f.linger, "linger", false,
"Leave the stack running after this process exits (records Config.Linger).")
Expand All @@ -170,14 +181,17 @@ func newFlagSet(name string, lingerable bool) (*flag.FlagSet, *configFlags) {

// markExplicitFlags records which string flags were explicitly passed (as
// opposed to left at their default), for the flags whose "set to empty" must be
// told apart from "unset". Today that is only --otel-external: an explicit empty
// endpoint is a misuse resolveConfig rejects, while an unset flag is the D3
// default (bundle the collector). Called after fs.Parse on every subcommand.
// told apart from "unset": --otel-external and --nats-external. For each, an
// explicit empty value is a misuse resolveConfig rejects, while an unset flag is
// the default (bundle the component). Called after fs.Parse on every subcommand.
func markExplicitFlags(fs *flag.FlagSet, f *configFlags) {
fs.Visit(func(fl *flag.Flag) {
if fl.Name == "otel-external" {
f.otelExternalSet = true
}
if fl.Name == "nats-external" {
f.natsExternalSet = true
}
})
}

Expand Down Expand Up @@ -244,6 +258,13 @@ func resolveConfig(f configFlags) (stack.Config, error) {
return stack.Config{}, errors.New("--otel-external requires an explicit OTLP endpoint: point compass surfaces at your own collector (omit the flag to bundle one)")
}

// The --nats-external opt-out is rejected on an explicit empty value for the
// same reason: an empty ExternalNatsURL reads as "bundle NATS", the opposite
// of the operator's intent.
if f.natsExternalSet && f.natsExternal == "" {
return stack.Config{}, errors.New("--nats-external requires an explicit nats:// URL: point compass surfaces at your own broker (omit the flag to bundle one)")
}

cfg := stack.Config{
StateDir: f.stateDir,
SocketPath: socketPath,
Expand All @@ -255,6 +276,8 @@ func resolveConfig(f configFlags) (stack.Config, error) {
ExternalDatabase: f.databaseExternal,
CollectorImage: f.collectorImage,
ExternalOTLPEndpoint: f.otelExternal,
NatsImage: f.natsImage,
ExternalNatsURL: f.natsExternal,
Linger: f.linger,
}
if err := cfg.Validate(); err != nil {
Expand Down Expand Up @@ -344,6 +367,24 @@ func buildDeps(cfg stack.Config) (stack.Deps, error) {
deps.Containers = cc
}
}
// The bundled nats seams (start + readiness probe) are wired whenever a
// bundled NATS could be in play: not on the --nats-external opt-out. Its
// teardown reuses the same name-agnostic ContainerController contract, so the
// one Containers seam already set above tears down every container component
// by its recorded name; set it from the nats adapter only when neither block
// above did (external DB + external OTLP + bundled nats), so a cross-process
// down that reads a nats container entry always has a teardown seam.
if cfg.ExternalNatsURL == "" {
nc, err := adapters.NewNatsContainer()
if err != nil {
return stack.Deps{}, err
}
deps.NatsContainer = nc
deps.NatsProber = nc
if deps.Containers == nil {
deps.Containers = nc
}
}
return deps, nil
}

Expand Down
67 changes: 67 additions & 0 deletions go/cmd/compass-stack/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,73 @@ func TestResolveConfigCollectorFlags(t *testing.T) {
})
}

// TestResolveConfigNatsFlags covers the bundled NATS / --nats-external flags.
func TestResolveConfigNatsFlags(t *testing.T) {
t.Setenv("XDG_RUNTIME_DIR", "/run/user/1000")
t.Setenv("COMPASS_DATABASE_DSN", "")

t.Run("nats-external threads into config", func(t *testing.T) {
f := baseFlags(t.TempDir())
f.natsExternal = "nats.example.com:4222"
f.natsExternalSet = true
cfg, err := resolveConfig(f)
if err != nil {
t.Fatalf("resolveConfig: %v", err)
}
if cfg.ExternalNatsURL != f.natsExternal {
t.Errorf("ExternalNatsURL = %q, want %q", cfg.ExternalNatsURL, f.natsExternal)
}
})
t.Run("nats-external explicit empty is rejected", func(t *testing.T) {
f := baseFlags(t.TempDir())
f.natsExternalSet = true
_, err := resolveConfig(f)
if err == nil {
t.Fatal("resolveConfig(--nats-external \"\") = nil error, want a rejection")
}
if !strings.Contains(err.Error(), "--nats-external requires an explicit nats:// URL") {
t.Errorf("error = %v, want it to name the missing URL", err)
}
})
t.Run("nats-external unset leaves endpoint empty", func(t *testing.T) {
cfg, err := resolveConfig(baseFlags(t.TempDir()))
if err != nil {
t.Fatalf("resolveConfig: %v", err)
}
if cfg.ExternalNatsURL != "" {
t.Errorf("ExternalNatsURL = %q, want empty (bundle NATS)", cfg.ExternalNatsURL)
}
})
t.Run("nats-image defaults to the pinned digest via newFlagSet", func(t *testing.T) {
_, f := newFlagSet("up", true)
f.stateDir = t.TempDir()
f.image = "example.com/agent:latest"
cfg, err := resolveConfig(*f)
if err != nil {
t.Fatalf("resolveConfig: %v", err)
}
if cfg.NatsImage != stack.DefaultNatsImage {
t.Errorf("NatsImage = %q, want the pinned default %q", cfg.NatsImage, stack.DefaultNatsImage)
}
})
t.Run("nats-external derives natsExternalSet through markExplicitFlags", func(t *testing.T) {
fs, f := newFlagSet("up", true)
f.stateDir = t.TempDir()
f.image = "example.com/agent:latest"
// Drive the real flag set end to end so the fs.Visit derivation runs.
if err := fs.Parse([]string{"--nats-external", "", "--state-dir", f.stateDir, "--image", f.image}); err != nil {
t.Fatalf("Parse: %v", err)
}
markExplicitFlags(fs, f)
if !f.natsExternalSet {
t.Fatalf("natsExternalSet = false after --nats-external \"\" parsed; want true")
}
if _, err := resolveConfig(*f); err == nil {
t.Fatal("resolveConfig(--nats-external \"\") = nil error, want the naming rejection")
}
})
}

func TestRunDispatch(t *testing.T) {
t.Run("unknown subcommand names the three", func(t *testing.T) {
err := run([]string{"bogus"})
Expand Down
Loading
Loading