test(app): run broker ensure tests against the production layout
- commit
- 644ac295fb9e4af26b1b2eab25802f88d78071c4
- author
- brice · · 2026-09-23 14:53 UTC
- parent
- eebe943
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
1 file · +84 -332
M internal/app/broker_offline_status_test.go +84 -332
| @@ -1,7 +1,6 @@ | |||
| 1 | 1 | package app | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "bytes" | |
| 5 | 4 | "context" | |
| 6 | 5 | "encoding/json" | |
| 7 | 6 | "errors" | |
| @@ -17,6 +16,7 @@ import ( | |||
| 17 | 16 | ||
| 18 | 17 | "github.com/bnema/vev/internal/adapters/brokerconfig" | |
| 19 | 18 | "github.com/bnema/vev/internal/adapters/brokeripc" | |
| 19 | + | "github.com/bnema/vev/internal/adapters/daemonmux" | |
| 20 | 20 | "github.com/bnema/vev/internal/adapters/ipc" | |
| 21 | 21 | "github.com/bnema/vev/internal/adapters/lifecycle" | |
| 22 | 22 | ) | |
| @@ -31,16 +31,30 @@ func sandboxEmptyDocument(idleGrace string) map[string]any { | |||
| 31 | 31 | return document | |
| 32 | 32 | } | |
| 33 | 33 | ||
| 34 | - | // emptySandboxRoot writes one marker-valid sandbox root and returns its layout. | |
| 35 | - | func emptySandboxRoot(t *testing.T, idleGrace string) brokerconfig.Layout { | |
| 34 | + | // emptyProductionBrokerLayout provisions the real broker config in private XDG | |
| 35 | + | // directories; each test receives an independent runtime, state, and config. | |
| 36 | + | func emptyProductionBrokerLayout(t *testing.T, idleGrace string) brokerconfig.Layout { | |
| 36 | 37 | t.Helper() | |
| 37 | - | isolateSandboxEnv(t) | |
| 38 | - | root := filepath.Join(shortTempDir(t, "vevu"), "s") | |
| 39 | - | require.NoError(t, os.MkdirAll(root, 0o700)) | |
| 40 | - | writeSandboxConfig(t, root, sandboxEmptyDocument(idleGrace)) | |
| 41 | - | layout, err := offlineLayout(root) | |
| 42 | - | require.NoError(t, err) | |
| 43 | - | return layout | |
| 38 | + | t.Setenv("VEV_ENV", "") | |
| 39 | + | t.Setenv("VEV_ENV_ROOT", "") | |
| 40 | + | t.Setenv("XDG_RUNTIME_DIR", shortTempDir(t, "vb")) | |
| 41 | + | t.Setenv("XDG_STATE_HOME", shortTempDir(t, "vb")) | |
| 42 | + | t.Setenv("XDG_CONFIG_HOME", shortTempDir(t, "vb")) | |
| 43 | + | require.NoError(t, ensureProductionBrokerConfig()) | |
| 44 | + | if idleGrace != "" { | |
| 45 | + | raw, err := json.Marshal(sandboxEmptyDocument(idleGrace)) | |
| 46 | + | require.NoError(t, err) | |
| 47 | + | require.NoError(t, os.WriteFile(productionBrokerConfigPath(), raw, 0o600)) | |
| 48 | + | } | |
| 49 | + | return productionBrokerLayout() | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | // productionStatusRequest provides the inputs used by production connect-or-spawn. | |
| 53 | + | func productionStatusRequest(layout brokerconfig.Layout, timeout time.Duration) brokerStatusRequest { | |
| 54 | + | return brokerStatusRequest{ | |
| 55 | + | layout: layout, socketPath: brokeripc.SocketPath(layout.Runtime), | |
| 56 | + | deadline: time.Now().Add(timeout), timeout: timeout, | |
| 57 | + | } | |
| 44 | 58 | } | |
| 45 | 59 | ||
| 46 | 60 | // fakeStatusLock records releases for one held election. | |
| @@ -63,8 +77,7 @@ func readyReport(socketPath string) brokerStatusReport { | |||
| 63 | 77 | func testStatusDeps(t *testing.T) (brokerStatusDeps, *statusRecorder) { | |
| 64 | 78 | t.Helper() | |
| 65 | 79 | rec := &statusRecorder{ | |
| 66 | - | now: time.Now(), | |
| 67 | - | stdout: &bytes.Buffer{}, | |
| 80 | + | now: time.Now(), | |
| 68 | 81 | } | |
| 69 | 82 | deps := brokerStatusDeps{ | |
| 70 | 83 | now: func() time.Time { return rec.now }, | |
| @@ -83,7 +96,6 @@ func testStatusDeps(t *testing.T) (brokerStatusDeps, *statusRecorder) { | |||
| 83 | 96 | rec.spawns.Add(1) | |
| 84 | 97 | return nil | |
| 85 | 98 | }, | |
| 86 | - | stdout: rec.stdout, | |
| 87 | 99 | } | |
| 88 | 100 | rec.setProbe(&deps, func(_ int, socketPath string) (brokerStatusReport, error) { | |
| 89 | 101 | return brokerStatusReport{}, absentError(socketPath) | |
| @@ -98,7 +110,6 @@ type statusRecorder struct { | |||
| 98 | 110 | spawns atomic.Int32 | |
| 99 | 111 | elections atomic.Int32 | |
| 100 | 112 | locks []*fakeStatusLock | |
| 101 | - | stdout *bytes.Buffer | |
| 102 | 113 | } | |
| 103 | 114 | ||
| 104 | 115 | // setProbe installs one scripted probe that sees its 1-based attempt number. | |
| @@ -108,119 +119,23 @@ func (r *statusRecorder) setProbe(deps *brokerStatusDeps, probe func(attempt int | |||
| 108 | 119 | } | |
| 109 | 120 | } | |
| 110 | 121 | ||
| 111 | - | func (r *statusRecorder) report(t *testing.T) brokerStatusReport { | |
| 112 | - | t.Helper() | |
| 113 | - | var report brokerStatusReport | |
| 114 | - | require.NoError(t, json.Unmarshal([]byte(strings.TrimSpace(r.stdout.String())), &report)) | |
| 115 | - | return report | |
| 116 | - | } | |
| 117 | - | ||
| 118 | - | // decodeOneReport asserts stdout holds exactly one JSON document and decodes it. | |
| 122 | + | // decodeOneReport remains shared with the subprocess broker tests until those | |
| 123 | + | // are converted to the production launcher. | |
| 119 | 124 | func decodeOneReport(t *testing.T, raw string) brokerStatusReport { | |
| 120 | 125 | t.Helper() | |
| 121 | 126 | trimmed := strings.TrimSpace(raw) | |
| 122 | - | require.Equal(t, 1, strings.Count(trimmed, "\n")+1, "stdout must hold exactly one report line") | |
| 127 | + | require.Equal(t, 1, strings.Count(trimmed, "\n")+1) | |
| 123 | 128 | var report brokerStatusReport | |
| 124 | 129 | require.NoError(t, json.Unmarshal([]byte(trimmed), &report)) | |
| 125 | 130 | return report | |
| 126 | 131 | } | |
| 127 | 132 | ||
| 128 | - | func TestParseBrokerLauncherArgs(t *testing.T) { | |
| 129 | - | tests := []struct { | |
| 130 | - | name string | |
| 131 | - | args []string | |
| 132 | - | want brokerLauncherOptions | |
| 133 | - | wantErr string | |
| 134 | - | }{ | |
| 135 | - | {name: "root only", args: []string{"--offline-root", "/srv/sandbox"}, want: brokerLauncherOptions{offlineRoot: "/srv/sandbox"}}, | |
| 136 | - | { | |
| 137 | - | name: "root and grace", | |
| 138 | - | args: []string{"--offline-root", "/srv/sandbox", "--idle-grace", "90s"}, | |
| 139 | - | want: brokerLauncherOptions{offlineRoot: "/srv/sandbox", idleGrace: 90 * time.Second}, | |
| 140 | - | }, | |
| 141 | - | {name: "missing root", args: nil, wantErr: "requires --offline-root"}, | |
| 142 | - | {name: "missing root value", args: []string{"--offline-root"}, wantErr: "requires a path"}, | |
| 143 | - | {name: "duplicate root", args: []string{"--offline-root", "/a", "--offline-root", "/b"}, wantErr: "duplicate --offline-root"}, | |
| 144 | - | {name: "duplicate grace", args: []string{"--offline-root", "/a", "--idle-grace", "1s", "--idle-grace", "2s"}, wantErr: "duplicate --idle-grace"}, | |
| 145 | - | {name: "zero grace", args: []string{"--offline-root", "/a", "--idle-grace", "0s"}, wantErr: "must be positive"}, | |
| 146 | - | {name: "invalid grace", args: []string{"--offline-root", "/a", "--idle-grace", "soon"}, wantErr: "not a duration"}, | |
| 147 | - | {name: "ensure is not a launcher flag", args: []string{"--offline-root", "/a", "--ensure"}, wantErr: "unknown flag"}, | |
| 148 | - | {name: "positional", args: []string{"--offline-root", "/a", "extra"}, wantErr: "positional"}, | |
| 149 | - | } | |
| 150 | - | for _, tt := range tests { | |
| 151 | - | t.Run(tt.name, func(t *testing.T) { | |
| 152 | - | command, err := parseBrokerLauncherArgs(tt.args) | |
| 153 | - | if tt.wantErr != "" { | |
| 154 | - | require.Error(t, err) | |
| 155 | - | require.Contains(t, err.Error(), tt.wantErr) | |
| 156 | - | return | |
| 157 | - | } | |
| 158 | - | require.NoError(t, err) | |
| 159 | - | require.Equal(t, kindBrokerLauncher, command.kind) | |
| 160 | - | require.Equal(t, tt.want, command.brokerLauncher) | |
| 161 | - | }) | |
| 162 | - | } | |
| 163 | - | } | |
| 164 | - | ||
| 165 | - | func TestParseBrokerStatusArgs(t *testing.T) { | |
| 166 | - | tests := []struct { | |
| 167 | - | name string | |
| 168 | - | args []string | |
| 169 | - | want brokerStatusOptions | |
| 170 | - | wantErr string | |
| 171 | - | }{ | |
| 172 | - | { | |
| 173 | - | name: "root only defaults the timeout", | |
| 174 | - | args: []string{"--offline-root", "/srv/sandbox"}, | |
| 175 | - | want: brokerStatusOptions{offlineRoot: "/srv/sandbox", timeout: brokerStatusDefaultTimeout}, | |
| 176 | - | }, | |
| 177 | - | { | |
| 178 | - | name: "ensure and timeout", | |
| 179 | - | args: []string{"--offline-root", "/a", "--ensure", "--timeout", "250ms"}, | |
| 180 | - | want: brokerStatusOptions{offlineRoot: "/a", ensure: true, timeout: 250 * time.Millisecond}, | |
| 181 | - | }, | |
| 182 | - | {name: "missing root", args: []string{"--ensure"}, wantErr: "requires --offline-root"}, | |
| 183 | - | {name: "duplicate root", args: []string{"--offline-root", "/a", "--offline-root", "/b"}, wantErr: "duplicate --offline-root"}, | |
| 184 | - | {name: "duplicate ensure", args: []string{"--offline-root", "/a", "--ensure", "--ensure"}, wantErr: "duplicate --ensure"}, | |
| 185 | - | {name: "duplicate timeout", args: []string{"--offline-root", "/a", "--timeout", "1s", "--timeout", "2s"}, wantErr: "duplicate --timeout"}, | |
| 186 | - | {name: "missing timeout value", args: []string{"--offline-root", "/a", "--timeout"}, wantErr: "requires a duration"}, | |
| 187 | - | {name: "zero timeout", args: []string{"--offline-root", "/a", "--timeout", "0s"}, wantErr: "must be positive"}, | |
| 188 | - | {name: "invalid timeout", args: []string{"--offline-root", "/a", "--timeout", "soon"}, wantErr: "not a duration"}, | |
| 189 | - | {name: "idle grace is not a status flag", args: []string{"--offline-root", "/a", "--idle-grace", "1s"}, wantErr: "unknown flag"}, | |
| 190 | - | {name: "positional", args: []string{"--offline-root", "/a", "extra"}, wantErr: "positional"}, | |
| 191 | - | } | |
| 192 | - | for _, tt := range tests { | |
| 193 | - | t.Run(tt.name, func(t *testing.T) { | |
| 194 | - | command, err := parseBrokerStatusArgs(tt.args) | |
| 195 | - | if tt.wantErr != "" { | |
| 196 | - | require.Error(t, err) | |
| 197 | - | require.Contains(t, err.Error(), tt.wantErr) | |
| 198 | - | return | |
| 199 | - | } | |
| 200 | - | require.NoError(t, err) | |
| 201 | - | require.Equal(t, kindBrokerStatus, command.kind) | |
| 202 | - | require.Equal(t, tt.want, command.brokerStatus) | |
| 203 | - | }) | |
| 204 | - | } | |
| 205 | - | } | |
| 206 | - | ||
| 207 | - | func TestBrokerLauncherAndStatusAreHiddenFromPublicHelp(t *testing.T) { | |
| 208 | - | require.NotContains(t, usageText, brokerLauncherCommand) | |
| 209 | - | require.NotContains(t, usageText, brokerStatusCommand) | |
| 210 | - | _, err := parseArgs([]string{brokerLauncherCommand, "--offline-root", "/srv/sandbox"}) | |
| 211 | - | require.NoError(t, err) | |
| 212 | - | _, err = parseArgs([]string{brokerStatusCommand, "--offline-root", "/srv/sandbox", "--ensure"}) | |
| 213 | - | require.NoError(t, err) | |
| 214 | - | _, err = parseArgs([]string{"--help"}) | |
| 215 | - | require.NoError(t, err) | |
| 216 | - | } | |
| 217 | - | ||
| 218 | 133 | func TestEffectiveIdleGrace(t *testing.T) { | |
| 219 | - | layout := emptySandboxRoot(t, "90s") | |
| 220 | - | config, err := brokerconfig.Load(layout) | |
| 134 | + | layout := emptyProductionBrokerLayout(t, "90s") | |
| 135 | + | config, err := brokerconfig.LoadProduction(layout, productionBrokerConfigPath(), "", localDaemonPolicy(), daemonmux.SocketPath(ipc.SocketDir())) | |
| 221 | 136 | require.NoError(t, err) | |
| 222 | - | unprovisioned := emptySandboxRoot(t, "") | |
| 223 | - | plain, err := brokerconfig.Load(unprovisioned) | |
| 137 | + | unprovisioned := emptyProductionBrokerLayout(t, "") | |
| 138 | + | plain, err := brokerconfig.LoadProduction(unprovisioned, productionBrokerConfigPath(), "", localDaemonPolicy(), daemonmux.SocketPath(ipc.SocketDir())) | |
| 224 | 139 | require.NoError(t, err) | |
| 225 | 140 | ||
| 226 | 141 | tests := []struct { | |
| @@ -251,71 +166,27 @@ func TestEffectiveIdleGrace(t *testing.T) { | |||
| 251 | 166 | } | |
| 252 | 167 | ||
| 253 | 168 | func TestBrokerServeRefusesConflictingIdleGrace(t *testing.T) { | |
| 254 | - | layout := emptySandboxRoot(t, "90s") | |
| 169 | + | layout := emptyProductionBrokerLayout(t, "90s") | |
| 255 | 170 | ctx, cancel := context.WithCancel(context.Background()) | |
| 256 | 171 | defer cancel() | |
| 257 | 172 | deps, _ := testBrokerServeDeps(newSandboxClock()) | |
| 258 | - | err := runBrokerServe(ctx, brokerServeOptions{offlineRoot: layout.Root, idleGrace: time.Minute}, deps) | |
| 173 | + | err := runBrokerServe(ctx, brokerServeOptions{production: true, idleGrace: time.Minute}, deps) | |
| 259 | 174 | require.Error(t, err) | |
| 260 | 175 | require.Contains(t, err.Error(), "conflicts with the provisioned idle grace") | |
| 261 | 176 | require.NoFileExists(t, filepath.Join(layout.Runtime, "lifecycle.lock"), "a refused serve must not take the lifetime lock") | |
| 262 | 177 | } | |
| 263 | 178 | ||
| 264 | 179 | func TestBrokerLauncherRefusesConflictingIdleGraceBeforeSpawning(t *testing.T) { | |
| 265 | - | layout := emptySandboxRoot(t, "90s") | |
| 266 | - | err := runBrokerLauncher(brokerLauncherOptions{offlineRoot: layout.Root, idleGrace: time.Minute}) | |
| 180 | + | layout := emptyProductionBrokerLayout(t, "90s") | |
| 181 | + | config, err := brokerconfig.LoadProduction(layout, productionBrokerConfigPath(), "", localDaemonPolicy(), daemonmux.SocketPath(ipc.SocketDir())) | |
| 182 | + | require.NoError(t, err) | |
| 183 | + | _, err = effectiveIdleGrace(config, time.Minute) | |
| 267 | 184 | require.Error(t, err) | |
| 268 | 185 | require.Contains(t, err.Error(), "conflicts with the provisioned idle grace") | |
| 269 | 186 | } | |
| 270 | 187 | ||
| 271 | - | func TestBrokerStatusDialOnlyReportsOfflineWithoutCreatingAnything(t *testing.T) { | |
| 272 | - | layout := emptySandboxRoot(t, "") | |
| 273 | - | deps := defaultBrokerStatusDeps() | |
| 274 | - | var out bytes.Buffer | |
| 275 | - | deps.stdout = &out | |
| 276 | - | ||
| 277 | - | require.NoError(t, runBrokerStatus(context.Background(), brokerStatusOptions{offlineRoot: layout.Root, timeout: time.Second}, deps)) | |
| 278 | - | report := decodeOneReport(t, out.String()) | |
| 279 | - | require.Equal(t, "offline", report.Status) | |
| 280 | - | require.Equal(t, brokeripc.SocketPath(layout.Runtime), report.Endpoint) | |
| 281 | - | require.Zero(t, report.Epoch) | |
| 282 | - | require.Zero(t, report.Revision) | |
| 283 | - | require.Zero(t, report.HostCount) | |
| 284 | - | ||
| 285 | - | require.NoDirExists(t, layout.Runtime, "dial-only status must not create the runtime directory") | |
| 286 | - | require.NoDirExists(t, layout.Spawn, "dial-only status must not create the spawn directory") | |
| 287 | - | } | |
| 288 | - | ||
| 289 | - | func TestBrokerStatusDialOnlyReadyAgainstLiveSandbox(t *testing.T) { | |
| 290 | - | root, prodRuntime, prodState := isolateSandboxEnv(t) | |
| 291 | - | fixture := newBrokerMuxFixture(t, brokerTestPolicy()) | |
| 292 | - | require.NoError(t, os.MkdirAll(root, 0o700)) | |
| 293 | - | writeSandboxConfig(t, root, sandboxRegistrationDocument(fixture.route)) | |
| 294 | - | ||
| 295 | - | clk := newSandboxClock() | |
| 296 | - | serveDeps, ready := testBrokerServeDeps(clk) | |
| 297 | - | ctx, cancel := context.WithCancel(context.Background()) | |
| 298 | - | defer cancel() | |
| 299 | - | done := runSandbox(ctx, brokerServeOptions{offlineRoot: root}, serveDeps) | |
| 300 | - | awaitSandboxReady(t, ready, done) | |
| 301 | - | ||
| 302 | - | deps := defaultBrokerStatusDeps() | |
| 303 | - | var out bytes.Buffer | |
| 304 | - | deps.stdout = &out | |
| 305 | - | require.NoError(t, runBrokerStatus(context.Background(), brokerStatusOptions{offlineRoot: root, timeout: time.Second}, deps)) | |
| 306 | - | report := decodeOneReport(t, out.String()) | |
| 307 | - | require.Equal(t, "ready", report.Status) | |
| 308 | - | require.Positive(t, report.Epoch) | |
| 309 | - | require.Positive(t, report.Revision) | |
| 310 | - | // Provisioned endpoints seed durable broker membership before serving. | |
| 311 | - | require.Equal(t, 1, report.HostCount) | |
| 312 | - | ||
| 313 | - | awaitSandboxCancel(t, cancel, done) | |
| 314 | - | requireProductionUntouched(t, prodRuntime, prodState) | |
| 315 | - | } | |
| 316 | - | ||
| 317 | 188 | func TestBrokerStatusEnsureSpawnsOnceThenReportsReady(t *testing.T) { | |
| 318 | - | layout := emptySandboxRoot(t, "") | |
| 189 | + | layout := emptyProductionBrokerLayout(t, "") | |
| 319 | 190 | deps, rec := testStatusDeps(t) | |
| 320 | 191 | rec.setProbe(&deps, func(_ int, socketPath string) (brokerStatusReport, error) { | |
| 321 | 192 | if rec.spawns.Load() > 0 { | |
| @@ -323,17 +194,18 @@ func TestBrokerStatusEnsureSpawnsOnceThenReportsReady(t *testing.T) { | |||
| 323 | 194 | } | |
| 324 | 195 | return brokerStatusReport{}, absentError(socketPath) | |
| 325 | 196 | }) | |
| 326 | - | require.NoError(t, runBrokerStatus(context.Background(), brokerStatusOptions{offlineRoot: layout.Root, ensure: true, timeout: time.Second}, deps)) | |
| 197 | + | report, err := ensureBrokerReady(context.Background(), productionStatusRequest(layout, time.Second), deps) | |
| 198 | + | require.NoError(t, err) | |
| 327 | 199 | ||
| 328 | 200 | require.Equal(t, int32(1), rec.spawns.Load(), "exactly one spawn is expected") | |
| 329 | 201 | require.Equal(t, int32(1), rec.elections.Load(), "exactly one election is expected") | |
| 330 | 202 | require.Len(t, rec.locks, 1) | |
| 331 | 203 | require.Equal(t, int32(1), rec.locks[0].releases.Load(), "the election must be released after readiness") | |
| 332 | - | require.Equal(t, "ready", rec.report(t).Status) | |
| 204 | + | require.Equal(t, "ready", report.Status) | |
| 333 | 205 | } | |
| 334 | 206 | ||
| 335 | 207 | func TestBrokerStatusEnsureWaitsForAnotherSpawner(t *testing.T) { | |
| 336 | - | layout := emptySandboxRoot(t, "") | |
| 208 | + | layout := emptyProductionBrokerLayout(t, "") | |
| 337 | 209 | var readyAfter int32 = 4 | |
| 338 | 210 | deps, rec := testStatusDeps(t) | |
| 339 | 211 | rec.setProbe(&deps, func(attempt int, socketPath string) (brokerStatusReport, error) { | |
| @@ -346,15 +218,16 @@ func TestBrokerStatusEnsureWaitsForAnotherSpawner(t *testing.T) { | |||
| 346 | 218 | rec.elections.Add(1) | |
| 347 | 219 | return nil, lifecycle.ErrBusy | |
| 348 | 220 | } | |
| 349 | - | require.NoError(t, runBrokerStatus(context.Background(), brokerStatusOptions{offlineRoot: layout.Root, ensure: true, timeout: time.Second}, deps)) | |
| 221 | + | report, err := ensureBrokerReady(context.Background(), productionStatusRequest(layout, time.Second), deps) | |
| 222 | + | require.NoError(t, err) | |
| 350 | 223 | ||
| 351 | 224 | require.Zero(t, rec.spawns.Load(), "a waiter must never spawn while another caller is elected") | |
| 352 | 225 | require.Positive(t, rec.elections.Load()) | |
| 353 | - | require.Equal(t, "ready", rec.report(t).Status) | |
| 226 | + | require.Equal(t, "ready", report.Status) | |
| 354 | 227 | } | |
| 355 | 228 | ||
| 356 | 229 | func TestBrokerStatusEnsureDefersToLifetimeOwnerBeforeSpawning(t *testing.T) { | |
| 357 | - | layout := emptySandboxRoot(t, "") | |
| 230 | + | layout := emptyProductionBrokerLayout(t, "") | |
| 358 | 231 | var ownerCalls int32 | |
| 359 | 232 | deps, rec := testStatusDeps(t) | |
| 360 | 233 | rec.setProbe(&deps, func(_ int, socketPath string) (brokerStatusReport, error) { | |
| @@ -369,13 +242,14 @@ func TestBrokerStatusEnsureDefersToLifetimeOwnerBeforeSpawning(t *testing.T) { | |||
| 369 | 242 | } | |
| 370 | 243 | return false, nil | |
| 371 | 244 | } | |
| 372 | - | require.NoError(t, runBrokerStatus(context.Background(), brokerStatusOptions{offlineRoot: layout.Root, ensure: true, timeout: time.Second}, deps)) | |
| 245 | + | _, err := ensureBrokerReady(context.Background(), productionStatusRequest(layout, time.Second), deps) | |
| 246 | + | require.NoError(t, err) | |
| 373 | 247 | require.Equal(t, int32(1), rec.spawns.Load(), "a live lifetime owner must suppress the spawn until it is gone") | |
| 374 | - | require.Equal(t, int32(1), rec.elections.Load(), "the election is taken only after the live owner releases the sandbox") | |
| 248 | + | require.Equal(t, int32(1), rec.elections.Load(), "the election is taken only after the live owner releases the broker") | |
| 375 | 249 | } | |
| 376 | 250 | ||
| 377 | 251 | func TestBrokerStatusEnsureRecoversAfterSpawnerDeath(t *testing.T) { | |
| 378 | - | layout := emptySandboxRoot(t, "") | |
| 252 | + | layout := emptyProductionBrokerLayout(t, "") | |
| 379 | 253 | var electionAttempts int32 | |
| 380 | 254 | deps, rec := testStatusDeps(t) | |
| 381 | 255 | rec.setProbe(&deps, func(_ int, socketPath string) (brokerStatusReport, error) { | |
| @@ -394,210 +268,96 @@ func TestBrokerStatusEnsureRecoversAfterSpawnerDeath(t *testing.T) { | |||
| 394 | 268 | rec.locks = append(rec.locks, lock) | |
| 395 | 269 | return lock, nil | |
| 396 | 270 | } | |
| 397 | - | require.NoError(t, runBrokerStatus(context.Background(), brokerStatusOptions{offlineRoot: layout.Root, ensure: true, timeout: time.Second}, deps)) | |
| 271 | + | report, err := ensureBrokerReady(context.Background(), productionStatusRequest(layout, time.Second), deps) | |
| 272 | + | require.NoError(t, err) | |
| 398 | 273 | require.Equal(t, int32(1), rec.spawns.Load(), "a waiter must take over once the elected spawner dies") | |
| 399 | - | require.Equal(t, "ready", rec.report(t).Status) | |
| 274 | + | require.Equal(t, "ready", report.Status) | |
| 400 | 275 | } | |
| 401 | 276 | ||
| 402 | 277 | func TestBrokerStatusEnsureIncompatibleEndpointFailsWithoutSpawning(t *testing.T) { | |
| 403 | - | layout := emptySandboxRoot(t, "") | |
| 278 | + | layout := emptyProductionBrokerLayout(t, "") | |
| 404 | 279 | deps, rec := testStatusDeps(t) | |
| 405 | 280 | rec.setProbe(&deps, func(_ int, socketPath string) (brokerStatusReport, error) { | |
| 406 | 281 | return brokerStatusReport{}, incompatibleError(socketPath, errors.New("foreign endpoint")) | |
| 407 | 282 | }) | |
| 408 | - | err := runBrokerStatus(context.Background(), brokerStatusOptions{offlineRoot: layout.Root, ensure: true, timeout: time.Second}, deps) | |
| 283 | + | _, err := ensureBrokerReady(context.Background(), productionStatusRequest(layout, time.Second), deps) | |
| 409 | 284 | require.ErrorIs(t, err, errBrokerIncompatible) | |
| 410 | 285 | require.Zero(t, rec.spawns.Load()) | |
| 411 | 286 | require.Zero(t, rec.elections.Load()) | |
| 412 | - | report := rec.report(t) | |
| 413 | - | require.Equal(t, "offline", report.Status) | |
| 414 | - | require.Equal(t, 3, exitCode(err)) | |
| 415 | 287 | } | |
| 416 | 288 | ||
| 417 | 289 | func TestBrokerStatusEnsureBoundedTimeoutReportsOffline(t *testing.T) { | |
| 418 | - | layout := emptySandboxRoot(t, "") | |
| 290 | + | layout := emptyProductionBrokerLayout(t, "") | |
| 419 | 291 | deps, rec := testStatusDeps(t) | |
| 420 | - | err := runBrokerStatus(context.Background(), brokerStatusOptions{offlineRoot: layout.Root, ensure: true, timeout: 50 * time.Millisecond}, deps) | |
| 292 | + | _, err := ensureBrokerReady(context.Background(), productionStatusRequest(layout, 50*time.Millisecond), deps) | |
| 421 | 293 | require.ErrorIs(t, err, errBrokerNotReady) | |
| 422 | - | require.Equal(t, 3, exitCode(err)) | |
| 423 | 294 | require.Equal(t, int32(1), rec.spawns.Load()) | |
| 424 | - | require.Equal(t, "offline", rec.report(t).Status) | |
| 425 | 295 | } | |
| 426 | 296 | ||
| 427 | 297 | func TestBrokerStatusEnsureSpawnFailureIsReported(t *testing.T) { | |
| 428 | - | layout := emptySandboxRoot(t, "") | |
| 298 | + | layout := emptyProductionBrokerLayout(t, "") | |
| 429 | 299 | spawnErr := errors.New("launcher exec failed") | |
| 430 | - | deps, rec := testStatusDeps(t) | |
| 300 | + | deps, _ := testStatusDeps(t) | |
| 431 | 301 | deps.spawn = func(context.Context, string, time.Duration, bool) error { | |
| 432 | - | rec.spawns.Add(1) | |
| 433 | 302 | return spawnErr | |
| 434 | 303 | } | |
| 435 | - | err := runBrokerStatus(context.Background(), brokerStatusOptions{offlineRoot: layout.Root, ensure: true, timeout: time.Second}, deps) | |
| 304 | + | _, err := ensureBrokerReady(context.Background(), productionStatusRequest(layout, time.Second), deps) | |
| 436 | 305 | require.ErrorIs(t, err, spawnErr) | |
| 437 | - | require.Equal(t, 3, exitCode(err)) | |
| 438 | - | require.Equal(t, "offline", rec.report(t).Status) | |
| 439 | 306 | } | |
| 440 | 307 | ||
| 441 | - | func TestBrokerStatusEnsureRejectsUnsafeRoot(t *testing.T) { | |
| 442 | - | isolateSandboxEnv(t) | |
| 443 | - | tests := []struct { | |
| 444 | - | name string | |
| 445 | - | root string | |
| 446 | - | }{ | |
| 447 | - | {name: "relative root", root: "relative"}, | |
| 448 | - | {name: "production overlap", root: ipc.SocketDir()}, | |
| 449 | - | } | |
| 450 | - | for _, tt := range tests { | |
| 451 | - | t.Run(tt.name, func(t *testing.T) { | |
| 452 | - | deps, rec := testStatusDeps(t) | |
| 453 | - | err := runBrokerStatus(context.Background(), brokerStatusOptions{offlineRoot: tt.root, ensure: true, timeout: time.Second}, deps) | |
| 454 | - | require.Error(t, err) | |
| 455 | - | require.Equal(t, 3, exitCode(err), "a layout failure must still exit 3") | |
| 456 | - | require.Equal(t, "offline", rec.report(t).Status, "a layout failure must still emit one offline report") | |
| 457 | - | require.Zero(t, rec.spawns.Load()) | |
| 458 | - | }) | |
| 459 | - | } | |
| 460 | - | } | |
| 461 | - | ||
| 462 | - | // TestBrokerStatusEnsureConfigFailureReportsOffline proves a malformed or | |
| 463 | - | // missing configuration still leaves the caller with one offline JSON document | |
| 464 | - | // and a code-3 failure instead of a bare error. | |
| 465 | - | func TestBrokerStatusEnsureConfigFailureReportsOffline(t *testing.T) { | |
| 466 | - | isolateSandboxEnv(t) | |
| 467 | - | root := filepath.Join(shortTempDir(t, "vevm"), "s") | |
| 468 | - | require.NoError(t, os.MkdirAll(root, 0o700)) | |
| 469 | - | deps, rec := testStatusDeps(t) | |
| 470 | - | err := runBrokerStatus(context.Background(), brokerStatusOptions{offlineRoot: root, ensure: true, timeout: time.Second}, deps) | |
| 308 | + | // A corrupt production broker.json is rejected before broker ownership. | |
| 309 | + | func TestBrokerEnsureProductionConfigFailure(t *testing.T) { | |
| 310 | + | layout := emptyProductionBrokerLayout(t, "") | |
| 311 | + | require.NoError(t, os.WriteFile(productionBrokerConfigPath(), []byte("not json"), 0o600)) | |
| 312 | + | deps, _ := testBrokerServeDeps(newSandboxClock()) | |
| 313 | + | err := runBrokerServe(context.Background(), brokerServeOptions{production: true}, deps) | |
| 471 | 314 | require.Error(t, err) | |
| 472 | - | require.Contains(t, err.Error(), brokerconfig.ConfigFileName) | |
| 473 | - | require.Equal(t, 3, exitCode(err)) | |
| 474 | - | require.Equal(t, "offline", rec.report(t).Status) | |
| 475 | - | require.Zero(t, rec.spawns.Load()) | |
| 315 | + | require.NoFileExists(t, filepath.Join(layout.Runtime, "lifecycle.lock")) | |
| 476 | 316 | } | |
| 477 | 317 | ||
| 478 | - | // TestBrokerStatusEnsureSecuresRootBeforeSpawning proves the ensure path | |
| 479 | - | // validates and secures the sandbox root exactly as the launcher does, before | |
| 480 | - | // the spawn directory is created or any election lock is taken. | |
| 318 | + | // TestBrokerStatusEnsureSecuresRootBeforeSpawning rejects an insecure | |
| 319 | + | // production spawn directory before electing a launcher. | |
| 481 | 320 | func TestBrokerStatusEnsureSecuresRootBeforeSpawning(t *testing.T) { | |
| 482 | - | layout := emptySandboxRoot(t, "") | |
| 483 | - | require.NoError(t, os.Chmod(layout.Root, 0o755)) | |
| 321 | + | layout := emptyProductionBrokerLayout(t, "") | |
| 322 | + | require.NoError(t, os.MkdirAll(layout.Runtime, 0o700)) | |
| 323 | + | require.NoError(t, os.MkdirAll(layout.Spawn, 0o700)) | |
| 324 | + | require.NoError(t, os.Chmod(layout.Spawn, 0o755)) | |
| 484 | 325 | deps, rec := testStatusDeps(t) | |
| 485 | - | err := runBrokerStatus(context.Background(), brokerStatusOptions{offlineRoot: layout.Root, ensure: true, timeout: time.Second}, deps) | |
| 326 | + | _, err := ensureBrokerReady(context.Background(), productionStatusRequest(layout, time.Second), deps) | |
| 486 | 327 | require.Error(t, err) | |
| 487 | 328 | require.Contains(t, err.Error(), "0755") | |
| 488 | - | require.Equal(t, 3, exitCode(err)) | |
| 489 | - | require.Equal(t, "offline", rec.report(t).Status) | |
| 490 | 329 | require.Zero(t, rec.spawns.Load()) | |
| 491 | 330 | require.Zero(t, rec.elections.Load()) | |
| 492 | - | require.NoFileExists(t, filepath.Join(layout.Runtime, brokerconfig.SpawnDirName), "an insecure root must be refused before the spawn directory is created") | |
| 331 | + | require.NoFileExists(t, filepath.Join(layout.Spawn, "lifecycle.lock"), "an insecure spawn directory must be refused before election") | |
| 493 | 332 | } | |
| 494 | 333 | ||
| 495 | 334 | // TestBrokerStatusEnsureDeadlineBoundsSpawnWait proves the overall ensure | |
| 496 | 335 | // deadline bounds a launcher spawn that never returns, so a blocking launcher | |
| 497 | - | // cannot wedge the status command. | |
| 336 | + | // cannot wedge connect-or-spawn. | |
| 498 | 337 | func TestBrokerStatusEnsureDeadlineBoundsSpawnWait(t *testing.T) { | |
| 499 | - | layout := emptySandboxRoot(t, "") | |
| 500 | - | deps, rec := testStatusDeps(t) | |
| 338 | + | layout := emptyProductionBrokerLayout(t, "") | |
| 339 | + | deps, _ := testStatusDeps(t) | |
| 501 | 340 | deps.spawn = func(ctx context.Context, _ string, _ time.Duration, _ bool) error { | |
| 502 | 341 | <-ctx.Done() | |
| 503 | 342 | return ctx.Err() | |
| 504 | 343 | } | |
| 505 | 344 | start := time.Now() | |
| 506 | - | err := runBrokerStatus(context.Background(), brokerStatusOptions{offlineRoot: layout.Root, ensure: true, timeout: 200 * time.Millisecond}, deps) | |
| 345 | + | _, err := ensureBrokerReady(context.Background(), productionStatusRequest(layout, 200*time.Millisecond), deps) | |
| 507 | 346 | require.ErrorIs(t, err, context.DeadlineExceeded) | |
| 508 | - | require.Equal(t, 3, exitCode(err)) | |
| 509 | 347 | require.Less(t, time.Since(start), 5*time.Second, "the overall deadline must bound the launcher spawn wait") | |
| 510 | - | require.Equal(t, "offline", rec.report(t).Status) | |
| 511 | - | } | |
| 512 | - | ||
| 513 | - | // TestBrokerStatusDialOnlyAppliesTimeout proves the dial-only probe is bounded | |
| 514 | - | // by --timeout as one absolute bound and is never silently replaced by the | |
| 515 | - | // ensure probe budget. | |
| 516 | - | func TestBrokerStatusDialOnlyAppliesTimeout(t *testing.T) { | |
| 517 | - | tests := []struct { | |
| 518 | - | name string | |
| 519 | - | timeout time.Duration | |
| 520 | - | }{ | |
| 521 | - | {name: "short timeout is the probe bound", timeout: 250 * time.Millisecond}, | |
| 522 | - | {name: "default timeout is the probe bound", timeout: brokerStatusDefaultTimeout}, | |
| 523 | - | } | |
| 524 | - | for _, tt := range tests { | |
| 525 | - | t.Run(tt.name, func(t *testing.T) { | |
| 526 | - | layout := emptySandboxRoot(t, "") | |
| 527 | - | deps, _ := testStatusDeps(t) | |
| 528 | - | var observed time.Duration | |
| 529 | - | sawDeadline := false | |
| 530 | - | deps.probe = func(ctx context.Context, socketPath string) (brokerStatusReport, error) { | |
| 531 | - | deadline, ok := ctx.Deadline() | |
| 532 | - | sawDeadline = ok | |
| 533 | - | if ok { | |
| 534 | - | observed = time.Until(deadline) | |
| 535 | - | } | |
| 536 | - | return brokerStatusReport{}, absentError(socketPath) | |
| 537 | - | } | |
| 538 | - | require.NoError(t, runBrokerStatus(context.Background(), brokerStatusOptions{offlineRoot: layout.Root, timeout: tt.timeout}, deps)) | |
| 539 | - | require.True(t, sawDeadline, "dial-only must bound the probe with the timeout context") | |
| 540 | - | require.InDelta(t, tt.timeout.Seconds(), observed.Seconds(), 0.5) | |
| 541 | - | }) | |
| 542 | - | } | |
| 543 | - | } | |
| 544 | - | ||
| 545 | - | // TestBrokerStatusDialOnlyBoundedByTimeout proves a live-but-silent endpoint is | |
| 546 | - | // bounded by --timeout rather than the ensure probe budget. | |
| 547 | - | func TestBrokerStatusDialOnlyBoundedByTimeout(t *testing.T) { | |
| 548 | - | layout := emptySandboxRoot(t, "") | |
| 549 | - | require.NoError(t, os.MkdirAll(layout.Runtime, 0o700)) | |
| 550 | - | _ = bindUnixSocket(t, brokeripc.SocketPath(layout.Runtime)) | |
| 551 | - | deps := defaultBrokerStatusDeps() | |
| 552 | - | var out bytes.Buffer | |
| 553 | - | deps.stdout = &out | |
| 554 | - | start := time.Now() | |
| 555 | - | require.NoError(t, runBrokerStatus(context.Background(), brokerStatusOptions{offlineRoot: layout.Root, timeout: 300 * time.Millisecond}, deps)) | |
| 556 | - | require.Less(t, time.Since(start), time.Second, "the dial-only probe must be bounded by --timeout, not the ensure budget") | |
| 557 | - | require.Equal(t, "offline", decodeOneReport(t, out.String()).Status) | |
| 558 | 348 | } | |
| 559 | 349 | ||
| 560 | 350 | func TestBrokerStatusEnsureHonorsCancellation(t *testing.T) { | |
| 561 | - | layout := emptySandboxRoot(t, "") | |
| 351 | + | layout := emptyProductionBrokerLayout(t, "") | |
| 562 | 352 | deps, _ := testStatusDeps(t) | |
| 563 | 353 | ctx, cancel := context.WithCancel(context.Background()) | |
| 564 | 354 | cancel() | |
| 565 | - | err := runBrokerStatus(ctx, brokerStatusOptions{offlineRoot: layout.Root, ensure: true, timeout: time.Second}, deps) | |
| 355 | + | _, err := ensureBrokerReady(ctx, productionStatusRequest(layout, time.Second), deps) | |
| 566 | 356 | require.ErrorIs(t, err, context.Canceled) | |
| 567 | 357 | } | |
| 568 | 358 | ||
| 569 | - | func TestBrokerStatusReportIsBoundedJSON(t *testing.T) { | |
| 570 | - | var out bytes.Buffer | |
| 571 | - | require.NoError(t, writeBrokerStatusReport(&out, brokerStatusReport{ | |
| 572 | - | Status: "ready", Endpoint: brokeripc.SocketPath("/tmp/sandbox/runtime"), Epoch: 3, Revision: 9, HostCount: 4, | |
| 573 | - | })) | |
| 574 | - | var fields map[string]json.RawMessage | |
| 575 | - | require.NoError(t, json.Unmarshal([]byte(strings.TrimSpace(out.String())), &fields)) | |
| 576 | - | require.Equal(t, []string{"endpoint", "epoch", "host_count", "revision", "status"}, sortedKeys(fields)) | |
| 577 | - | require.LessOrEqual(t, out.Len(), brokerStatusMaxReportBytes) | |
| 578 | - | } | |
| 579 | - | ||
| 580 | - | // sortedKeys returns the sorted keys of one JSON object. | |
| 581 | - | func sortedKeys(fields map[string]json.RawMessage) []string { | |
| 582 | - | keys := make([]string, 0, len(fields)) | |
| 583 | - | for key := range fields { | |
| 584 | - | keys = append(keys, key) | |
| 585 | - | } | |
| 586 | - | slicesSort(keys) | |
| 587 | - | return keys | |
| 588 | - | } | |
| 589 | - | ||
| 590 | - | // slicesSort is a tiny insertion sort so the test file needs no extra import. | |
| 591 | - | func slicesSort(values []string) { | |
| 592 | - | for i := 1; i < len(values); i++ { | |
| 593 | - | for j := i; j > 0 && values[j] < values[j-1]; j-- { | |
| 594 | - | values[j], values[j-1] = values[j-1], values[j] | |
| 595 | - | } | |
| 596 | - | } | |
| 597 | - | } | |
| 598 | - | ||
| 599 | 359 | func TestProbeBrokerStatusClassifiesAbsenceAndIncompatibility(t *testing.T) { | |
| 600 | - | layout := emptySandboxRoot(t, "") | |
| 360 | + | layout := emptyProductionBrokerLayout(t, "") | |
| 601 | 361 | socketPath := brokeripc.SocketPath(layout.Runtime) | |
| 602 | 362 | ||
| 603 | 363 | t.Run("missing endpoint is absence", func(t *testing.T) { | |
| @@ -637,7 +397,7 @@ func TestProbeBrokerStatusClassifiesAbsenceAndIncompatibility(t *testing.T) { | |||
| 637 | 397 | } | |
| 638 | 398 | ||
| 639 | 399 | func TestProbeBrokerStatusSilentRegistrationIsBounded(t *testing.T) { | |
| 640 | - | layout := emptySandboxRoot(t, "") | |
| 400 | + | layout := emptyProductionBrokerLayout(t, "") | |
| 641 | 401 | socketPath := brokeripc.SocketPath(layout.Runtime) | |
| 642 | 402 | require.NoError(t, os.MkdirAll(layout.Runtime, 0o700)) | |
| 643 | 403 | // A listener that never reads or answers: the dial succeeds and the broker | |
| @@ -674,11 +434,3 @@ func bindStaleUnixSocket(t *testing.T, path string) { | |||
| 674 | 434 | require.NoError(t, listener.Close()) | |
| 675 | 435 | require.FileExists(t, path) | |
| 676 | 436 | } | |
| 677 | - | ||
| 678 | - | // Ensure the helper fixture contract used elsewhere stays coherent. | |
| 679 | - | func TestStatusFixtureDocument(t *testing.T) { | |
| 680 | - | document := sandboxEmptyDocument("1s") | |
| 681 | - | require.Equal(t, brokerconfig.Marker, document["marker"]) | |
| 682 | - | require.Equal(t, "1s", document["idleGrace"]) | |
| 683 | - | require.Empty(t, sandboxEmptyDocument("")["idleGrace"]) | |
| 684 | - | } | |
| @@ -1,7 +1,6 @@ | |||||
| 1 | package app | 1 | package app | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | - | "bytes" | |||
| 5 | "context" | 4 | "context" | ||
| 6 | "encoding/json" | 5 | "encoding/json" | ||
| 7 | "errors" | 6 | "errors" | ||
| @@ -17,6 +16,7 @@ import ( | |||||
| 17 | 16 | ||||
| 18 | "github.com/bnema/vev/internal/adapters/brokerconfig" | 17 | "github.com/bnema/vev/internal/adapters/brokerconfig" | ||
| 19 | "github.com/bnema/vev/internal/adapters/brokeripc" | 18 | "github.com/bnema/vev/internal/adapters/brokeripc" | ||
| 19 | + | "github.com/bnema/vev/internal/adapters/daemonmux" | |||
| 20 | "github.com/bnema/vev/internal/adapters/ipc" | 20 | "github.com/bnema/vev/internal/adapters/ipc" | ||
| 21 | "github.com/bnema/vev/internal/adapters/lifecycle" | 21 | "github.com/bnema/vev/internal/adapters/lifecycle" | ||
| 22 | ) | 22 | ) | ||
| @@ -31,16 +31,30 @@ func sandboxEmptyDocument(idleGrace string) map[string]any { | |||||
| 31 | return document | 31 | return document | ||
| 32 | } | 32 | } | ||
| 33 | 33 | ||||
| 34 | - | // emptySandboxRoot writes one marker-valid sandbox root and returns its layout. | 34 | + | // emptyProductionBrokerLayout provisions the real broker config in private XDG |
| 35 | - | func emptySandboxRoot(t *testing.T, idleGrace string) brokerconfig.Layout { | 35 | + | // directories; each test receives an independent runtime, state, and config. |
| 36 | + | func emptyProductionBrokerLayout(t *testing.T, idleGrace string) brokerconfig.Layout { | |||
| 36 | t.Helper() | 37 | t.Helper() | ||
| 37 | - | isolateSandboxEnv(t) | 38 | + | t.Setenv("VEV_ENV", "") |
| 38 | - | root := filepath.Join(shortTempDir(t, "vevu"), "s") | 39 | + | t.Setenv("VEV_ENV_ROOT", "") |
| 39 | - | require.NoError(t, os.MkdirAll(root, 0o700)) | 40 | + | t.Setenv("XDG_RUNTIME_DIR", shortTempDir(t, "vb")) |
| 40 | - | writeSandboxConfig(t, root, sandboxEmptyDocument(idleGrace)) | 41 | + | t.Setenv("XDG_STATE_HOME", shortTempDir(t, "vb")) |
| 41 | - | layout, err := offlineLayout(root) | 42 | + | t.Setenv("XDG_CONFIG_HOME", shortTempDir(t, "vb")) |
| 42 | - | require.NoError(t, err) | 43 | + | require.NoError(t, ensureProductionBrokerConfig()) |
| 43 | - | return layout | 44 | + | if idleGrace != "" { |
| 45 | + | raw, err := json.Marshal(sandboxEmptyDocument(idleGrace)) | |||
| 46 | + | require.NoError(t, err) | |||
| 47 | + | require.NoError(t, os.WriteFile(productionBrokerConfigPath(), raw, 0o600)) | |||
| 48 | + | } | |||
| 49 | + | return productionBrokerLayout() | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | // productionStatusRequest provides the inputs used by production connect-or-spawn. | |||
| 53 | + | func productionStatusRequest(layout brokerconfig.Layout, timeout time.Duration) brokerStatusRequest { | |||
| 54 | + | return brokerStatusRequest{ | |||
| 55 | + | layout: layout, socketPath: brokeripc.SocketPath(layout.Runtime), | |||
| 56 | + | deadline: time.Now().Add(timeout), timeout: timeout, | |||
| 57 | + | } | |||
| 44 | } | 58 | } | ||
| 45 | 59 | ||||
| 46 | // fakeStatusLock records releases for one held election. | 60 | // fakeStatusLock records releases for one held election. | ||
| @@ -63,8 +77,7 @@ func readyReport(socketPath string) brokerStatusReport { | |||||
| 63 | func testStatusDeps(t *testing.T) (brokerStatusDeps, *statusRecorder) { | 77 | func testStatusDeps(t *testing.T) (brokerStatusDeps, *statusRecorder) { | ||
| 64 | t.Helper() | 78 | t.Helper() | ||
| 65 | rec := &statusRecorder{ | 79 | rec := &statusRecorder{ | ||
| 66 | - | now: time.Now(), | 80 | + | now: time.Now(), |
| 67 | - | stdout: &bytes.Buffer{}, | |||
| 68 | } | 81 | } | ||
| 69 | deps := brokerStatusDeps{ | 82 | deps := brokerStatusDeps{ | ||
| 70 | now: func() time.Time { return rec.now }, | 83 | now: func() time.Time { return rec.now }, | ||
| @@ -83,7 +96,6 @@ func testStatusDeps(t *testing.T) (brokerStatusDeps, *statusRecorder) { | |||||
| 83 | rec.spawns.Add(1) | 96 | rec.spawns.Add(1) | ||
| 84 | return nil | 97 | return nil | ||
| 85 | }, | 98 | }, | ||
| 86 | - | stdout: rec.stdout, | |||
| 87 | } | 99 | } | ||
| 88 | rec.setProbe(&deps, func(_ int, socketPath string) (brokerStatusReport, error) { | 100 | rec.setProbe(&deps, func(_ int, socketPath string) (brokerStatusReport, error) { | ||
| 89 | return brokerStatusReport{}, absentError(socketPath) | 101 | return brokerStatusReport{}, absentError(socketPath) | ||
| @@ -98,7 +110,6 @@ type statusRecorder struct { | |||||
| 98 | spawns atomic.Int32 | 110 | spawns atomic.Int32 | ||
| 99 | elections atomic.Int32 | 111 | elections atomic.Int32 | ||
| 100 | locks []*fakeStatusLock | 112 | locks []*fakeStatusLock | ||
| 101 | - | stdout *bytes.Buffer | |||
| 102 | } | 113 | } | ||
| 103 | 114 | ||||
| 104 | // setProbe installs one scripted probe that sees its 1-based attempt number. | 115 | // setProbe installs one scripted probe that sees its 1-based attempt number. | ||
| @@ -108,119 +119,23 @@ func (r *statusRecorder) setProbe(deps *brokerStatusDeps, probe func(attempt int | |||||
| 108 | } | 119 | } | ||
| 109 | } | 120 | } | ||
| 110 | 121 | ||||
| 111 | - | func (r *statusRecorder) report(t *testing.T) brokerStatusReport { | 122 | + | // decodeOneReport remains shared with the subprocess broker tests until those |
| 112 | - | t.Helper() | 123 | + | // are converted to the production launcher. |
| 113 | - | var report brokerStatusReport | |||
| 114 | - | require.NoError(t, json.Unmarshal([]byte(strings.TrimSpace(r.stdout.String())), &report)) | |||
| 115 | - | return report | |||
| 116 | - | } | |||
| 117 | - | ||||
| 118 | - | // decodeOneReport asserts stdout holds exactly one JSON document and decodes it. | |||
| 119 | func decodeOneReport(t *testing.T, raw string) brokerStatusReport { | 124 | func decodeOneReport(t *testing.T, raw string) brokerStatusReport { | ||
| 120 | t.Helper() | 125 | t.Helper() | ||
| 121 | trimmed := strings.TrimSpace(raw) | 126 | trimmed := strings.TrimSpace(raw) | ||
| 122 | - | require.Equal(t, 1, strings.Count(trimmed, "\n")+1, "stdout must hold exactly one report line") | 127 | + | require.Equal(t, 1, strings.Count(trimmed, "\n")+1) |
| 123 | var report brokerStatusReport | 128 | var report brokerStatusReport | ||
| 124 | require.NoError(t, json.Unmarshal([]byte(trimmed), &report)) | 129 | require.NoError(t, json.Unmarshal([]byte(trimmed), &report)) | ||
| 125 | return report | 130 | return report | ||
| 126 | } | 131 | } | ||
| 127 | 132 | ||||
| 128 | - | func TestParseBrokerLauncherArgs(t *testing.T) { | |||
| 129 | - | tests := []struct { | |||
| 130 | - | name string | |||
| 131 | - | args []string | |||
| 132 | - | want brokerLauncherOptions | |||
| 133 | - | wantErr string | |||
| 134 | - | }{ | |||
| 135 | - | {name: "root only", args: []string{"--offline-root", "/srv/sandbox"}, want: brokerLauncherOptions{offlineRoot: "/srv/sandbox"}}, | |||
| 136 | - | { | |||
| 137 | - | name: "root and grace", | |||
| 138 | - | args: []string{"--offline-root", "/srv/sandbox", "--idle-grace", "90s"}, | |||
| 139 | - | want: brokerLauncherOptions{offlineRoot: "/srv/sandbox", idleGrace: 90 * time.Second}, | |||
| 140 | - | }, | |||
| 141 | - | {name: "missing root", args: nil, wantErr: "requires --offline-root"}, | |||
| 142 | - | {name: "missing root value", args: []string{"--offline-root"}, wantErr: "requires a path"}, | |||
| 143 | - | {name: "duplicate root", args: []string{"--offline-root", "/a", "--offline-root", "/b"}, wantErr: "duplicate --offline-root"}, | |||
| 144 | - | {name: "duplicate grace", args: []string{"--offline-root", "/a", "--idle-grace", "1s", "--idle-grace", "2s"}, wantErr: "duplicate --idle-grace"}, | |||
| 145 | - | {name: "zero grace", args: []string{"--offline-root", "/a", "--idle-grace", "0s"}, wantErr: "must be positive"}, | |||
| 146 | - | {name: "invalid grace", args: []string{"--offline-root", "/a", "--idle-grace", "soon"}, wantErr: "not a duration"}, | |||
| 147 | - | {name: "ensure is not a launcher flag", args: []string{"--offline-root", "/a", "--ensure"}, wantErr: "unknown flag"}, | |||
| 148 | - | {name: "positional", args: []string{"--offline-root", "/a", "extra"}, wantErr: "positional"}, | |||
| 149 | - | } | |||
| 150 | - | for _, tt := range tests { | |||
| 151 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 152 | - | command, err := parseBrokerLauncherArgs(tt.args) | |||
| 153 | - | if tt.wantErr != "" { | |||
| 154 | - | require.Error(t, err) | |||
| 155 | - | require.Contains(t, err.Error(), tt.wantErr) | |||
| 156 | - | return | |||
| 157 | - | } | |||
| 158 | - | require.NoError(t, err) | |||
| 159 | - | require.Equal(t, kindBrokerLauncher, command.kind) | |||
| 160 | - | require.Equal(t, tt.want, command.brokerLauncher) | |||
| 161 | - | }) | |||
| 162 | - | } | |||
| 163 | - | } | |||
| 164 | - | ||||
| 165 | - | func TestParseBrokerStatusArgs(t *testing.T) { | |||
| 166 | - | tests := []struct { | |||
| 167 | - | name string | |||
| 168 | - | args []string | |||
| 169 | - | want brokerStatusOptions | |||
| 170 | - | wantErr string | |||
| 171 | - | }{ | |||
| 172 | - | { | |||
| 173 | - | name: "root only defaults the timeout", | |||
| 174 | - | args: []string{"--offline-root", "/srv/sandbox"}, | |||
| 175 | - | want: brokerStatusOptions{offlineRoot: "/srv/sandbox", timeout: brokerStatusDefaultTimeout}, | |||
| 176 | - | }, | |||
| 177 | - | { | |||
| 178 | - | name: "ensure and timeout", | |||
| 179 | - | args: []string{"--offline-root", "/a", "--ensure", "--timeout", "250ms"}, | |||
| 180 | - | want: brokerStatusOptions{offlineRoot: "/a", ensure: true, timeout: 250 * time.Millisecond}, | |||
| 181 | - | }, | |||
| 182 | - | {name: "missing root", args: []string{"--ensure"}, wantErr: "requires --offline-root"}, | |||
| 183 | - | {name: "duplicate root", args: []string{"--offline-root", "/a", "--offline-root", "/b"}, wantErr: "duplicate --offline-root"}, | |||
| 184 | - | {name: "duplicate ensure", args: []string{"--offline-root", "/a", "--ensure", "--ensure"}, wantErr: "duplicate --ensure"}, | |||
| 185 | - | {name: "duplicate timeout", args: []string{"--offline-root", "/a", "--timeout", "1s", "--timeout", "2s"}, wantErr: "duplicate --timeout"}, | |||
| 186 | - | {name: "missing timeout value", args: []string{"--offline-root", "/a", "--timeout"}, wantErr: "requires a duration"}, | |||
| 187 | - | {name: "zero timeout", args: []string{"--offline-root", "/a", "--timeout", "0s"}, wantErr: "must be positive"}, | |||
| 188 | - | {name: "invalid timeout", args: []string{"--offline-root", "/a", "--timeout", "soon"}, wantErr: "not a duration"}, | |||
| 189 | - | {name: "idle grace is not a status flag", args: []string{"--offline-root", "/a", "--idle-grace", "1s"}, wantErr: "unknown flag"}, | |||
| 190 | - | {name: "positional", args: []string{"--offline-root", "/a", "extra"}, wantErr: "positional"}, | |||
| 191 | - | } | |||
| 192 | - | for _, tt := range tests { | |||
| 193 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 194 | - | command, err := parseBrokerStatusArgs(tt.args) | |||
| 195 | - | if tt.wantErr != "" { | |||
| 196 | - | require.Error(t, err) | |||
| 197 | - | require.Contains(t, err.Error(), tt.wantErr) | |||
| 198 | - | return | |||
| 199 | - | } | |||
| 200 | - | require.NoError(t, err) | |||
| 201 | - | require.Equal(t, kindBrokerStatus, command.kind) | |||
| 202 | - | require.Equal(t, tt.want, command.brokerStatus) | |||
| 203 | - | }) | |||
| 204 | - | } | |||
| 205 | - | } | |||
| 206 | - | ||||
| 207 | - | func TestBrokerLauncherAndStatusAreHiddenFromPublicHelp(t *testing.T) { | |||
| 208 | - | require.NotContains(t, usageText, brokerLauncherCommand) | |||
| 209 | - | require.NotContains(t, usageText, brokerStatusCommand) | |||
| 210 | - | _, err := parseArgs([]string{brokerLauncherCommand, "--offline-root", "/srv/sandbox"}) | |||
| 211 | - | require.NoError(t, err) | |||
| 212 | - | _, err = parseArgs([]string{brokerStatusCommand, "--offline-root", "/srv/sandbox", "--ensure"}) | |||
| 213 | - | require.NoError(t, err) | |||
| 214 | - | _, err = parseArgs([]string{"--help"}) | |||
| 215 | - | require.NoError(t, err) | |||
| 216 | - | } | |||
| 217 | - | ||||
| 218 | func TestEffectiveIdleGrace(t *testing.T) { | 133 | func TestEffectiveIdleGrace(t *testing.T) { | ||
| 219 | - | layout := emptySandboxRoot(t, "90s") | 134 | + | layout := emptyProductionBrokerLayout(t, "90s") |
| 220 | - | config, err := brokerconfig.Load(layout) | 135 | + | config, err := brokerconfig.LoadProduction(layout, productionBrokerConfigPath(), "", localDaemonPolicy(), daemonmux.SocketPath(ipc.SocketDir())) |
| 221 | require.NoError(t, err) | 136 | require.NoError(t, err) | ||
| 222 | - | unprovisioned := emptySandboxRoot(t, "") | 137 | + | unprovisioned := emptyProductionBrokerLayout(t, "") |
| 223 | - | plain, err := brokerconfig.Load(unprovisioned) | 138 | + | plain, err := brokerconfig.LoadProduction(unprovisioned, productionBrokerConfigPath(), "", localDaemonPolicy(), daemonmux.SocketPath(ipc.SocketDir())) |
| 224 | require.NoError(t, err) | 139 | require.NoError(t, err) | ||
| 225 | 140 | ||||
| 226 | tests := []struct { | 141 | tests := []struct { | ||
| @@ -251,71 +166,27 @@ func TestEffectiveIdleGrace(t *testing.T) { | |||||
| 251 | } | 166 | } | ||
| 252 | 167 | ||||
| 253 | func TestBrokerServeRefusesConflictingIdleGrace(t *testing.T) { | 168 | func TestBrokerServeRefusesConflictingIdleGrace(t *testing.T) { | ||
| 254 | - | layout := emptySandboxRoot(t, "90s") | 169 | + | layout := emptyProductionBrokerLayout(t, "90s") |
| 255 | ctx, cancel := context.WithCancel(context.Background()) | 170 | ctx, cancel := context.WithCancel(context.Background()) | ||
| 256 | defer cancel() | 171 | defer cancel() | ||
| 257 | deps, _ := testBrokerServeDeps(newSandboxClock()) | 172 | deps, _ := testBrokerServeDeps(newSandboxClock()) | ||
| 258 | - | err := runBrokerServe(ctx, brokerServeOptions{offlineRoot: layout.Root, idleGrace: time.Minute}, deps) | 173 | + | err := runBrokerServe(ctx, brokerServeOptions{production: true, idleGrace: time.Minute}, deps) |
| 259 | require.Error(t, err) | 174 | require.Error(t, err) | ||
| 260 | require.Contains(t, err.Error(), "conflicts with the provisioned idle grace") | 175 | require.Contains(t, err.Error(), "conflicts with the provisioned idle grace") | ||
| 261 | require.NoFileExists(t, filepath.Join(layout.Runtime, "lifecycle.lock"), "a refused serve must not take the lifetime lock") | 176 | require.NoFileExists(t, filepath.Join(layout.Runtime, "lifecycle.lock"), "a refused serve must not take the lifetime lock") | ||
| 262 | } | 177 | } | ||
| 263 | 178 | ||||
| 264 | func TestBrokerLauncherRefusesConflictingIdleGraceBeforeSpawning(t *testing.T) { | 179 | func TestBrokerLauncherRefusesConflictingIdleGraceBeforeSpawning(t *testing.T) { | ||
| 265 | - | layout := emptySandboxRoot(t, "90s") | 180 | + | layout := emptyProductionBrokerLayout(t, "90s") |
| 266 | - | err := runBrokerLauncher(brokerLauncherOptions{offlineRoot: layout.Root, idleGrace: time.Minute}) | 181 | + | config, err := brokerconfig.LoadProduction(layout, productionBrokerConfigPath(), "", localDaemonPolicy(), daemonmux.SocketPath(ipc.SocketDir())) |
| 182 | + | require.NoError(t, err) | |||
| 183 | + | _, err = effectiveIdleGrace(config, time.Minute) | |||
| 267 | require.Error(t, err) | 184 | require.Error(t, err) | ||
| 268 | require.Contains(t, err.Error(), "conflicts with the provisioned idle grace") | 185 | require.Contains(t, err.Error(), "conflicts with the provisioned idle grace") | ||
| 269 | } | 186 | } | ||
| 270 | 187 | ||||
| 271 | - | func TestBrokerStatusDialOnlyReportsOfflineWithoutCreatingAnything(t *testing.T) { | |||
| 272 | - | layout := emptySandboxRoot(t, "") | |||
| 273 | - | deps := defaultBrokerStatusDeps() | |||
| 274 | - | var out bytes.Buffer | |||
| 275 | - | deps.stdout = &out | |||
| 276 | - | ||||
| 277 | - | require.NoError(t, runBrokerStatus(context.Background(), brokerStatusOptions{offlineRoot: layout.Root, timeout: time.Second}, deps)) | |||
| 278 | - | report := decodeOneReport(t, out.String()) | |||
| 279 | - | require.Equal(t, "offline", report.Status) | |||
| 280 | - | require.Equal(t, brokeripc.SocketPath(layout.Runtime), report.Endpoint) | |||
| 281 | - | require.Zero(t, report.Epoch) | |||
| 282 | - | require.Zero(t, report.Revision) | |||
| 283 | - | require.Zero(t, report.HostCount) | |||
| 284 | - | ||||
| 285 | - | require.NoDirExists(t, layout.Runtime, "dial-only status must not create the runtime directory") | |||
| 286 | - | require.NoDirExists(t, layout.Spawn, "dial-only status must not create the spawn directory") | |||
| 287 | - | } | |||
| 288 | - | ||||
| 289 | - | func TestBrokerStatusDialOnlyReadyAgainstLiveSandbox(t *testing.T) { | |||
| 290 | - | root, prodRuntime, prodState := isolateSandboxEnv(t) | |||
| 291 | - | fixture := newBrokerMuxFixture(t, brokerTestPolicy()) | |||
| 292 | - | require.NoError(t, os.MkdirAll(root, 0o700)) | |||
| 293 | - | writeSandboxConfig(t, root, sandboxRegistrationDocument(fixture.route)) | |||
| 294 | - | ||||
| 295 | - | clk := newSandboxClock() | |||
| 296 | - | serveDeps, ready := testBrokerServeDeps(clk) | |||
| 297 | - | ctx, cancel := context.WithCancel(context.Background()) | |||
| 298 | - | defer cancel() | |||
| 299 | - | done := runSandbox(ctx, brokerServeOptions{offlineRoot: root}, serveDeps) | |||
| 300 | - | awaitSandboxReady(t, ready, done) | |||
| 301 | - | ||||
| 302 | - | deps := defaultBrokerStatusDeps() | |||
| 303 | - | var out bytes.Buffer | |||
| 304 | - | deps.stdout = &out | |||
| 305 | - | require.NoError(t, runBrokerStatus(context.Background(), brokerStatusOptions{offlineRoot: root, timeout: time.Second}, deps)) | |||
| 306 | - | report := decodeOneReport(t, out.String()) | |||
| 307 | - | require.Equal(t, "ready", report.Status) | |||
| 308 | - | require.Positive(t, report.Epoch) | |||
| 309 | - | require.Positive(t, report.Revision) | |||
| 310 | - | // Provisioned endpoints seed durable broker membership before serving. | |||
| 311 | - | require.Equal(t, 1, report.HostCount) | |||
| 312 | - | ||||
| 313 | - | awaitSandboxCancel(t, cancel, done) | |||
| 314 | - | requireProductionUntouched(t, prodRuntime, prodState) | |||
| 315 | - | } | |||
| 316 | - | ||||
| 317 | func TestBrokerStatusEnsureSpawnsOnceThenReportsReady(t *testing.T) { | 188 | func TestBrokerStatusEnsureSpawnsOnceThenReportsReady(t *testing.T) { | ||
| 318 | - | layout := emptySandboxRoot(t, "") | 189 | + | layout := emptyProductionBrokerLayout(t, "") |
| 319 | deps, rec := testStatusDeps(t) | 190 | deps, rec := testStatusDeps(t) | ||
| 320 | rec.setProbe(&deps, func(_ int, socketPath string) (brokerStatusReport, error) { | 191 | rec.setProbe(&deps, func(_ int, socketPath string) (brokerStatusReport, error) { | ||
| 321 | if rec.spawns.Load() > 0 { | 192 | if rec.spawns.Load() > 0 { | ||
| @@ -323,17 +194,18 @@ func TestBrokerStatusEnsureSpawnsOnceThenReportsReady(t *testing.T) { | |||||
| 323 | } | 194 | } | ||
| 324 | return brokerStatusReport{}, absentError(socketPath) | 195 | return brokerStatusReport{}, absentError(socketPath) | ||
| 325 | }) | 196 | }) | ||
| 326 | - | require.NoError(t, runBrokerStatus(context.Background(), brokerStatusOptions{offlineRoot: layout.Root, ensure: true, timeout: time.Second}, deps)) | 197 | + | report, err := ensureBrokerReady(context.Background(), productionStatusRequest(layout, time.Second), deps) |
| 198 | + | require.NoError(t, err) | |||
| 327 | 199 | ||||
| 328 | require.Equal(t, int32(1), rec.spawns.Load(), "exactly one spawn is expected") | 200 | require.Equal(t, int32(1), rec.spawns.Load(), "exactly one spawn is expected") | ||
| 329 | require.Equal(t, int32(1), rec.elections.Load(), "exactly one election is expected") | 201 | require.Equal(t, int32(1), rec.elections.Load(), "exactly one election is expected") | ||
| 330 | require.Len(t, rec.locks, 1) | 202 | require.Len(t, rec.locks, 1) | ||
| 331 | require.Equal(t, int32(1), rec.locks[0].releases.Load(), "the election must be released after readiness") | 203 | require.Equal(t, int32(1), rec.locks[0].releases.Load(), "the election must be released after readiness") | ||
| 332 | - | require.Equal(t, "ready", rec.report(t).Status) | 204 | + | require.Equal(t, "ready", report.Status) |
| 333 | } | 205 | } | ||
| 334 | 206 | ||||
| 335 | func TestBrokerStatusEnsureWaitsForAnotherSpawner(t *testing.T) { | 207 | func TestBrokerStatusEnsureWaitsForAnotherSpawner(t *testing.T) { | ||
| 336 | - | layout := emptySandboxRoot(t, "") | 208 | + | layout := emptyProductionBrokerLayout(t, "") |
| 337 | var readyAfter int32 = 4 | 209 | var readyAfter int32 = 4 | ||
| 338 | deps, rec := testStatusDeps(t) | 210 | deps, rec := testStatusDeps(t) | ||
| 339 | rec.setProbe(&deps, func(attempt int, socketPath string) (brokerStatusReport, error) { | 211 | rec.setProbe(&deps, func(attempt int, socketPath string) (brokerStatusReport, error) { | ||
| @@ -346,15 +218,16 @@ func TestBrokerStatusEnsureWaitsForAnotherSpawner(t *testing.T) { | |||||
| 346 | rec.elections.Add(1) | 218 | rec.elections.Add(1) | ||
| 347 | return nil, lifecycle.ErrBusy | 219 | return nil, lifecycle.ErrBusy | ||
| 348 | } | 220 | } | ||
| 349 | - | require.NoError(t, runBrokerStatus(context.Background(), brokerStatusOptions{offlineRoot: layout.Root, ensure: true, timeout: time.Second}, deps)) | 221 | + | report, err := ensureBrokerReady(context.Background(), productionStatusRequest(layout, time.Second), deps) |
| 222 | + | require.NoError(t, err) | |||
| 350 | 223 | ||||
| 351 | require.Zero(t, rec.spawns.Load(), "a waiter must never spawn while another caller is elected") | 224 | require.Zero(t, rec.spawns.Load(), "a waiter must never spawn while another caller is elected") | ||
| 352 | require.Positive(t, rec.elections.Load()) | 225 | require.Positive(t, rec.elections.Load()) | ||
| 353 | - | require.Equal(t, "ready", rec.report(t).Status) | 226 | + | require.Equal(t, "ready", report.Status) |
| 354 | } | 227 | } | ||
| 355 | 228 | ||||
| 356 | func TestBrokerStatusEnsureDefersToLifetimeOwnerBeforeSpawning(t *testing.T) { | 229 | func TestBrokerStatusEnsureDefersToLifetimeOwnerBeforeSpawning(t *testing.T) { | ||
| 357 | - | layout := emptySandboxRoot(t, "") | 230 | + | layout := emptyProductionBrokerLayout(t, "") |
| 358 | var ownerCalls int32 | 231 | var ownerCalls int32 | ||
| 359 | deps, rec := testStatusDeps(t) | 232 | deps, rec := testStatusDeps(t) | ||
| 360 | rec.setProbe(&deps, func(_ int, socketPath string) (brokerStatusReport, error) { | 233 | rec.setProbe(&deps, func(_ int, socketPath string) (brokerStatusReport, error) { | ||
| @@ -369,13 +242,14 @@ func TestBrokerStatusEnsureDefersToLifetimeOwnerBeforeSpawning(t *testing.T) { | |||||
| 369 | } | 242 | } | ||
| 370 | return false, nil | 243 | return false, nil | ||
| 371 | } | 244 | } | ||
| 372 | - | require.NoError(t, runBrokerStatus(context.Background(), brokerStatusOptions{offlineRoot: layout.Root, ensure: true, timeout: time.Second}, deps)) | 245 | + | _, err := ensureBrokerReady(context.Background(), productionStatusRequest(layout, time.Second), deps) |
| 246 | + | require.NoError(t, err) | |||
| 373 | require.Equal(t, int32(1), rec.spawns.Load(), "a live lifetime owner must suppress the spawn until it is gone") | 247 | require.Equal(t, int32(1), rec.spawns.Load(), "a live lifetime owner must suppress the spawn until it is gone") | ||
| 374 | - | require.Equal(t, int32(1), rec.elections.Load(), "the election is taken only after the live owner releases the sandbox") | 248 | + | require.Equal(t, int32(1), rec.elections.Load(), "the election is taken only after the live owner releases the broker") |
| 375 | } | 249 | } | ||
| 376 | 250 | ||||
| 377 | func TestBrokerStatusEnsureRecoversAfterSpawnerDeath(t *testing.T) { | 251 | func TestBrokerStatusEnsureRecoversAfterSpawnerDeath(t *testing.T) { | ||
| 378 | - | layout := emptySandboxRoot(t, "") | 252 | + | layout := emptyProductionBrokerLayout(t, "") |
| 379 | var electionAttempts int32 | 253 | var electionAttempts int32 | ||
| 380 | deps, rec := testStatusDeps(t) | 254 | deps, rec := testStatusDeps(t) | ||
| 381 | rec.setProbe(&deps, func(_ int, socketPath string) (brokerStatusReport, error) { | 255 | rec.setProbe(&deps, func(_ int, socketPath string) (brokerStatusReport, error) { | ||
| @@ -394,210 +268,96 @@ func TestBrokerStatusEnsureRecoversAfterSpawnerDeath(t *testing.T) { | |||||
| 394 | rec.locks = append(rec.locks, lock) | 268 | rec.locks = append(rec.locks, lock) | ||
| 395 | return lock, nil | 269 | return lock, nil | ||
| 396 | } | 270 | } | ||
| 397 | - | require.NoError(t, runBrokerStatus(context.Background(), brokerStatusOptions{offlineRoot: layout.Root, ensure: true, timeout: time.Second}, deps)) | 271 | + | report, err := ensureBrokerReady(context.Background(), productionStatusRequest(layout, time.Second), deps) |
| 272 | + | require.NoError(t, err) | |||
| 398 | require.Equal(t, int32(1), rec.spawns.Load(), "a waiter must take over once the elected spawner dies") | 273 | require.Equal(t, int32(1), rec.spawns.Load(), "a waiter must take over once the elected spawner dies") | ||
| 399 | - | require.Equal(t, "ready", rec.report(t).Status) | 274 | + | require.Equal(t, "ready", report.Status) |
| 400 | } | 275 | } | ||
| 401 | 276 | ||||
| 402 | func TestBrokerStatusEnsureIncompatibleEndpointFailsWithoutSpawning(t *testing.T) { | 277 | func TestBrokerStatusEnsureIncompatibleEndpointFailsWithoutSpawning(t *testing.T) { | ||
| 403 | - | layout := emptySandboxRoot(t, "") | 278 | + | layout := emptyProductionBrokerLayout(t, "") |
| 404 | deps, rec := testStatusDeps(t) | 279 | deps, rec := testStatusDeps(t) | ||
| 405 | rec.setProbe(&deps, func(_ int, socketPath string) (brokerStatusReport, error) { | 280 | rec.setProbe(&deps, func(_ int, socketPath string) (brokerStatusReport, error) { | ||
| 406 | return brokerStatusReport{}, incompatibleError(socketPath, errors.New("foreign endpoint")) | 281 | return brokerStatusReport{}, incompatibleError(socketPath, errors.New("foreign endpoint")) | ||
| 407 | }) | 282 | }) | ||
| 408 | - | err := runBrokerStatus(context.Background(), brokerStatusOptions{offlineRoot: layout.Root, ensure: true, timeout: time.Second}, deps) | 283 | + | _, err := ensureBrokerReady(context.Background(), productionStatusRequest(layout, time.Second), deps) |
| 409 | require.ErrorIs(t, err, errBrokerIncompatible) | 284 | require.ErrorIs(t, err, errBrokerIncompatible) | ||
| 410 | require.Zero(t, rec.spawns.Load()) | 285 | require.Zero(t, rec.spawns.Load()) | ||
| 411 | require.Zero(t, rec.elections.Load()) | 286 | require.Zero(t, rec.elections.Load()) | ||
| 412 | - | report := rec.report(t) | |||
| 413 | - | require.Equal(t, "offline", report.Status) | |||
| 414 | - | require.Equal(t, 3, exitCode(err)) | |||
| 415 | } | 287 | } | ||
| 416 | 288 | ||||
| 417 | func TestBrokerStatusEnsureBoundedTimeoutReportsOffline(t *testing.T) { | 289 | func TestBrokerStatusEnsureBoundedTimeoutReportsOffline(t *testing.T) { | ||
| 418 | - | layout := emptySandboxRoot(t, "") | 290 | + | layout := emptyProductionBrokerLayout(t, "") |
| 419 | deps, rec := testStatusDeps(t) | 291 | deps, rec := testStatusDeps(t) | ||
| 420 | - | err := runBrokerStatus(context.Background(), brokerStatusOptions{offlineRoot: layout.Root, ensure: true, timeout: 50 * time.Millisecond}, deps) | 292 | + | _, err := ensureBrokerReady(context.Background(), productionStatusRequest(layout, 50*time.Millisecond), deps) |
| 421 | require.ErrorIs(t, err, errBrokerNotReady) | 293 | require.ErrorIs(t, err, errBrokerNotReady) | ||
| 422 | - | require.Equal(t, 3, exitCode(err)) | |||
| 423 | require.Equal(t, int32(1), rec.spawns.Load()) | 294 | require.Equal(t, int32(1), rec.spawns.Load()) | ||
| 424 | - | require.Equal(t, "offline", rec.report(t).Status) | |||
| 425 | } | 295 | } | ||
| 426 | 296 | ||||
| 427 | func TestBrokerStatusEnsureSpawnFailureIsReported(t *testing.T) { | 297 | func TestBrokerStatusEnsureSpawnFailureIsReported(t *testing.T) { | ||
| 428 | - | layout := emptySandboxRoot(t, "") | 298 | + | layout := emptyProductionBrokerLayout(t, "") |
| 429 | spawnErr := errors.New("launcher exec failed") | 299 | spawnErr := errors.New("launcher exec failed") | ||
| 430 | - | deps, rec := testStatusDeps(t) | 300 | + | deps, _ := testStatusDeps(t) |
| 431 | deps.spawn = func(context.Context, string, time.Duration, bool) error { | 301 | deps.spawn = func(context.Context, string, time.Duration, bool) error { | ||
| 432 | - | rec.spawns.Add(1) | |||
| 433 | return spawnErr | 302 | return spawnErr | ||
| 434 | } | 303 | } | ||
| 435 | - | err := runBrokerStatus(context.Background(), brokerStatusOptions{offlineRoot: layout.Root, ensure: true, timeout: time.Second}, deps) | 304 | + | _, err := ensureBrokerReady(context.Background(), productionStatusRequest(layout, time.Second), deps) |
| 436 | require.ErrorIs(t, err, spawnErr) | 305 | require.ErrorIs(t, err, spawnErr) | ||
| 437 | - | require.Equal(t, 3, exitCode(err)) | |||
| 438 | - | require.Equal(t, "offline", rec.report(t).Status) | |||
| 439 | } | 306 | } | ||
| 440 | 307 | ||||
| 441 | - | func TestBrokerStatusEnsureRejectsUnsafeRoot(t *testing.T) { | 308 | + | // A corrupt production broker.json is rejected before broker ownership. |
| 442 | - | isolateSandboxEnv(t) | 309 | + | func TestBrokerEnsureProductionConfigFailure(t *testing.T) { |
| 443 | - | tests := []struct { | 310 | + | layout := emptyProductionBrokerLayout(t, "") |
| 444 | - | name string | 311 | + | require.NoError(t, os.WriteFile(productionBrokerConfigPath(), []byte("not json"), 0o600)) |
| 445 | - | root string | 312 | + | deps, _ := testBrokerServeDeps(newSandboxClock()) |
| 446 | - | }{ | 313 | + | err := runBrokerServe(context.Background(), brokerServeOptions{production: true}, deps) |
| 447 | - | {name: "relative root", root: "relative"}, | |||
| 448 | - | {name: "production overlap", root: ipc.SocketDir()}, | |||
| 449 | - | } | |||
| 450 | - | for _, tt := range tests { | |||
| 451 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 452 | - | deps, rec := testStatusDeps(t) | |||
| 453 | - | err := runBrokerStatus(context.Background(), brokerStatusOptions{offlineRoot: tt.root, ensure: true, timeout: time.Second}, deps) | |||
| 454 | - | require.Error(t, err) | |||
| 455 | - | require.Equal(t, 3, exitCode(err), "a layout failure must still exit 3") | |||
| 456 | - | require.Equal(t, "offline", rec.report(t).Status, "a layout failure must still emit one offline report") | |||
| 457 | - | require.Zero(t, rec.spawns.Load()) | |||
| 458 | - | }) | |||
| 459 | - | } | |||
| 460 | - | } | |||
| 461 | - | ||||
| 462 | - | // TestBrokerStatusEnsureConfigFailureReportsOffline proves a malformed or | |||
| 463 | - | // missing configuration still leaves the caller with one offline JSON document | |||
| 464 | - | // and a code-3 failure instead of a bare error. | |||
| 465 | - | func TestBrokerStatusEnsureConfigFailureReportsOffline(t *testing.T) { | |||
| 466 | - | isolateSandboxEnv(t) | |||
| 467 | - | root := filepath.Join(shortTempDir(t, "vevm"), "s") | |||
| 468 | - | require.NoError(t, os.MkdirAll(root, 0o700)) | |||
| 469 | - | deps, rec := testStatusDeps(t) | |||
| 470 | - | err := runBrokerStatus(context.Background(), brokerStatusOptions{offlineRoot: root, ensure: true, timeout: time.Second}, deps) | |||
| 471 | require.Error(t, err) | 314 | require.Error(t, err) | ||
| 472 | - | require.Contains(t, err.Error(), brokerconfig.ConfigFileName) | 315 | + | require.NoFileExists(t, filepath.Join(layout.Runtime, "lifecycle.lock")) |
| 473 | - | require.Equal(t, 3, exitCode(err)) | |||
| 474 | - | require.Equal(t, "offline", rec.report(t).Status) | |||
| 475 | - | require.Zero(t, rec.spawns.Load()) | |||
| 476 | } | 316 | } | ||
| 477 | 317 | ||||
| 478 | - | // TestBrokerStatusEnsureSecuresRootBeforeSpawning proves the ensure path | 318 | + | // TestBrokerStatusEnsureSecuresRootBeforeSpawning rejects an insecure |
| 479 | - | // validates and secures the sandbox root exactly as the launcher does, before | 319 | + | // production spawn directory before electing a launcher. |
| 480 | - | // the spawn directory is created or any election lock is taken. | |||
| 481 | func TestBrokerStatusEnsureSecuresRootBeforeSpawning(t *testing.T) { | 320 | func TestBrokerStatusEnsureSecuresRootBeforeSpawning(t *testing.T) { | ||
| 482 | - | layout := emptySandboxRoot(t, "") | 321 | + | layout := emptyProductionBrokerLayout(t, "") |
| 483 | - | require.NoError(t, os.Chmod(layout.Root, 0o755)) | 322 | + | require.NoError(t, os.MkdirAll(layout.Runtime, 0o700)) |
| 323 | + | require.NoError(t, os.MkdirAll(layout.Spawn, 0o700)) | |||
| 324 | + | require.NoError(t, os.Chmod(layout.Spawn, 0o755)) | |||
| 484 | deps, rec := testStatusDeps(t) | 325 | deps, rec := testStatusDeps(t) | ||
| 485 | - | err := runBrokerStatus(context.Background(), brokerStatusOptions{offlineRoot: layout.Root, ensure: true, timeout: time.Second}, deps) | 326 | + | _, err := ensureBrokerReady(context.Background(), productionStatusRequest(layout, time.Second), deps) |
| 486 | require.Error(t, err) | 327 | require.Error(t, err) | ||
| 487 | require.Contains(t, err.Error(), "0755") | 328 | require.Contains(t, err.Error(), "0755") | ||
| 488 | - | require.Equal(t, 3, exitCode(err)) | |||
| 489 | - | require.Equal(t, "offline", rec.report(t).Status) | |||
| 490 | require.Zero(t, rec.spawns.Load()) | 329 | require.Zero(t, rec.spawns.Load()) | ||
| 491 | require.Zero(t, rec.elections.Load()) | 330 | require.Zero(t, rec.elections.Load()) | ||
| 492 | - | require.NoFileExists(t, filepath.Join(layout.Runtime, brokerconfig.SpawnDirName), "an insecure root must be refused before the spawn directory is created") | 331 | + | require.NoFileExists(t, filepath.Join(layout.Spawn, "lifecycle.lock"), "an insecure spawn directory must be refused before election") |
| 493 | } | 332 | } | ||
| 494 | 333 | ||||
| 495 | // TestBrokerStatusEnsureDeadlineBoundsSpawnWait proves the overall ensure | 334 | // TestBrokerStatusEnsureDeadlineBoundsSpawnWait proves the overall ensure | ||
| 496 | // deadline bounds a launcher spawn that never returns, so a blocking launcher | 335 | // deadline bounds a launcher spawn that never returns, so a blocking launcher | ||
| 497 | - | // cannot wedge the status command. | 336 | + | // cannot wedge connect-or-spawn. |
| 498 | func TestBrokerStatusEnsureDeadlineBoundsSpawnWait(t *testing.T) { | 337 | func TestBrokerStatusEnsureDeadlineBoundsSpawnWait(t *testing.T) { | ||
| 499 | - | layout := emptySandboxRoot(t, "") | 338 | + | layout := emptyProductionBrokerLayout(t, "") |
| 500 | - | deps, rec := testStatusDeps(t) | 339 | + | deps, _ := testStatusDeps(t) |
| 501 | deps.spawn = func(ctx context.Context, _ string, _ time.Duration, _ bool) error { | 340 | deps.spawn = func(ctx context.Context, _ string, _ time.Duration, _ bool) error { | ||
| 502 | <-ctx.Done() | 341 | <-ctx.Done() | ||
| 503 | return ctx.Err() | 342 | return ctx.Err() | ||
| 504 | } | 343 | } | ||
| 505 | start := time.Now() | 344 | start := time.Now() | ||
| 506 | - | err := runBrokerStatus(context.Background(), brokerStatusOptions{offlineRoot: layout.Root, ensure: true, timeout: 200 * time.Millisecond}, deps) | 345 | + | _, err := ensureBrokerReady(context.Background(), productionStatusRequest(layout, 200*time.Millisecond), deps) |
| 507 | require.ErrorIs(t, err, context.DeadlineExceeded) | 346 | require.ErrorIs(t, err, context.DeadlineExceeded) | ||
| 508 | - | require.Equal(t, 3, exitCode(err)) | |||
| 509 | require.Less(t, time.Since(start), 5*time.Second, "the overall deadline must bound the launcher spawn wait") | 347 | require.Less(t, time.Since(start), 5*time.Second, "the overall deadline must bound the launcher spawn wait") | ||
| 510 | - | require.Equal(t, "offline", rec.report(t).Status) | |||
| 511 | - | } | |||
| 512 | - | ||||
| 513 | - | // TestBrokerStatusDialOnlyAppliesTimeout proves the dial-only probe is bounded | |||
| 514 | - | // by --timeout as one absolute bound and is never silently replaced by the | |||
| 515 | - | // ensure probe budget. | |||
| 516 | - | func TestBrokerStatusDialOnlyAppliesTimeout(t *testing.T) { | |||
| 517 | - | tests := []struct { | |||
| 518 | - | name string | |||
| 519 | - | timeout time.Duration | |||
| 520 | - | }{ | |||
| 521 | - | {name: "short timeout is the probe bound", timeout: 250 * time.Millisecond}, | |||
| 522 | - | {name: "default timeout is the probe bound", timeout: brokerStatusDefaultTimeout}, | |||
| 523 | - | } | |||
| 524 | - | for _, tt := range tests { | |||
| 525 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 526 | - | layout := emptySandboxRoot(t, "") | |||
| 527 | - | deps, _ := testStatusDeps(t) | |||
| 528 | - | var observed time.Duration | |||
| 529 | - | sawDeadline := false | |||
| 530 | - | deps.probe = func(ctx context.Context, socketPath string) (brokerStatusReport, error) { | |||
| 531 | - | deadline, ok := ctx.Deadline() | |||
| 532 | - | sawDeadline = ok | |||
| 533 | - | if ok { | |||
| 534 | - | observed = time.Until(deadline) | |||
| 535 | - | } | |||
| 536 | - | return brokerStatusReport{}, absentError(socketPath) | |||
| 537 | - | } | |||
| 538 | - | require.NoError(t, runBrokerStatus(context.Background(), brokerStatusOptions{offlineRoot: layout.Root, timeout: tt.timeout}, deps)) | |||
| 539 | - | require.True(t, sawDeadline, "dial-only must bound the probe with the timeout context") | |||
| 540 | - | require.InDelta(t, tt.timeout.Seconds(), observed.Seconds(), 0.5) | |||
| 541 | - | }) | |||
| 542 | - | } | |||
| 543 | - | } | |||
| 544 | - | ||||
| 545 | - | // TestBrokerStatusDialOnlyBoundedByTimeout proves a live-but-silent endpoint is | |||
| 546 | - | // bounded by --timeout rather than the ensure probe budget. | |||
| 547 | - | func TestBrokerStatusDialOnlyBoundedByTimeout(t *testing.T) { | |||
| 548 | - | layout := emptySandboxRoot(t, "") | |||
| 549 | - | require.NoError(t, os.MkdirAll(layout.Runtime, 0o700)) | |||
| 550 | - | _ = bindUnixSocket(t, brokeripc.SocketPath(layout.Runtime)) | |||
| 551 | - | deps := defaultBrokerStatusDeps() | |||
| 552 | - | var out bytes.Buffer | |||
| 553 | - | deps.stdout = &out | |||
| 554 | - | start := time.Now() | |||
| 555 | - | require.NoError(t, runBrokerStatus(context.Background(), brokerStatusOptions{offlineRoot: layout.Root, timeout: 300 * time.Millisecond}, deps)) | |||
| 556 | - | require.Less(t, time.Since(start), time.Second, "the dial-only probe must be bounded by --timeout, not the ensure budget") | |||
| 557 | - | require.Equal(t, "offline", decodeOneReport(t, out.String()).Status) | |||
| 558 | } | 348 | } | ||
| 559 | 349 | ||||
| 560 | func TestBrokerStatusEnsureHonorsCancellation(t *testing.T) { | 350 | func TestBrokerStatusEnsureHonorsCancellation(t *testing.T) { | ||
| 561 | - | layout := emptySandboxRoot(t, "") | 351 | + | layout := emptyProductionBrokerLayout(t, "") |
| 562 | deps, _ := testStatusDeps(t) | 352 | deps, _ := testStatusDeps(t) | ||
| 563 | ctx, cancel := context.WithCancel(context.Background()) | 353 | ctx, cancel := context.WithCancel(context.Background()) | ||
| 564 | cancel() | 354 | cancel() | ||
| 565 | - | err := runBrokerStatus(ctx, brokerStatusOptions{offlineRoot: layout.Root, ensure: true, timeout: time.Second}, deps) | 355 | + | _, err := ensureBrokerReady(ctx, productionStatusRequest(layout, time.Second), deps) |
| 566 | require.ErrorIs(t, err, context.Canceled) | 356 | require.ErrorIs(t, err, context.Canceled) | ||
| 567 | } | 357 | } | ||
| 568 | 358 | ||||
| 569 | - | func TestBrokerStatusReportIsBoundedJSON(t *testing.T) { | |||
| 570 | - | var out bytes.Buffer | |||
| 571 | - | require.NoError(t, writeBrokerStatusReport(&out, brokerStatusReport{ | |||
| 572 | - | Status: "ready", Endpoint: brokeripc.SocketPath("/tmp/sandbox/runtime"), Epoch: 3, Revision: 9, HostCount: 4, | |||
| 573 | - | })) | |||
| 574 | - | var fields map[string]json.RawMessage | |||
| 575 | - | require.NoError(t, json.Unmarshal([]byte(strings.TrimSpace(out.String())), &fields)) | |||
| 576 | - | require.Equal(t, []string{"endpoint", "epoch", "host_count", "revision", "status"}, sortedKeys(fields)) | |||
| 577 | - | require.LessOrEqual(t, out.Len(), brokerStatusMaxReportBytes) | |||
| 578 | - | } | |||
| 579 | - | ||||
| 580 | - | // sortedKeys returns the sorted keys of one JSON object. | |||
| 581 | - | func sortedKeys(fields map[string]json.RawMessage) []string { | |||
| 582 | - | keys := make([]string, 0, len(fields)) | |||
| 583 | - | for key := range fields { | |||
| 584 | - | keys = append(keys, key) | |||
| 585 | - | } | |||
| 586 | - | slicesSort(keys) | |||
| 587 | - | return keys | |||
| 588 | - | } | |||
| 589 | - | ||||
| 590 | - | // slicesSort is a tiny insertion sort so the test file needs no extra import. | |||
| 591 | - | func slicesSort(values []string) { | |||
| 592 | - | for i := 1; i < len(values); i++ { | |||
| 593 | - | for j := i; j > 0 && values[j] < values[j-1]; j-- { | |||
| 594 | - | values[j], values[j-1] = values[j-1], values[j] | |||
| 595 | - | } | |||
| 596 | - | } | |||
| 597 | - | } | |||
| 598 | - | ||||
| 599 | func TestProbeBrokerStatusClassifiesAbsenceAndIncompatibility(t *testing.T) { | 359 | func TestProbeBrokerStatusClassifiesAbsenceAndIncompatibility(t *testing.T) { | ||
| 600 | - | layout := emptySandboxRoot(t, "") | 360 | + | layout := emptyProductionBrokerLayout(t, "") |
| 601 | socketPath := brokeripc.SocketPath(layout.Runtime) | 361 | socketPath := brokeripc.SocketPath(layout.Runtime) | ||
| 602 | 362 | ||||
| 603 | t.Run("missing endpoint is absence", func(t *testing.T) { | 363 | t.Run("missing endpoint is absence", func(t *testing.T) { | ||
| @@ -637,7 +397,7 @@ func TestProbeBrokerStatusClassifiesAbsenceAndIncompatibility(t *testing.T) { | |||||
| 637 | } | 397 | } | ||
| 638 | 398 | ||||
| 639 | func TestProbeBrokerStatusSilentRegistrationIsBounded(t *testing.T) { | 399 | func TestProbeBrokerStatusSilentRegistrationIsBounded(t *testing.T) { | ||
| 640 | - | layout := emptySandboxRoot(t, "") | 400 | + | layout := emptyProductionBrokerLayout(t, "") |
| 641 | socketPath := brokeripc.SocketPath(layout.Runtime) | 401 | socketPath := brokeripc.SocketPath(layout.Runtime) | ||
| 642 | require.NoError(t, os.MkdirAll(layout.Runtime, 0o700)) | 402 | require.NoError(t, os.MkdirAll(layout.Runtime, 0o700)) | ||
| 643 | // A listener that never reads or answers: the dial succeeds and the broker | 403 | // A listener that never reads or answers: the dial succeeds and the broker | ||
| @@ -674,11 +434,3 @@ func bindStaleUnixSocket(t *testing.T, path string) { | |||||
| 674 | require.NoError(t, listener.Close()) | 434 | require.NoError(t, listener.Close()) | ||
| 675 | require.FileExists(t, path) | 435 | require.FileExists(t, path) | ||
| 676 | } | 436 | } | ||
| 677 | - | ||||
| 678 | - | // Ensure the helper fixture contract used elsewhere stays coherent. | |||
| 679 | - | func TestStatusFixtureDocument(t *testing.T) { | |||
| 680 | - | document := sandboxEmptyDocument("1s") | |||
| 681 | - | require.Equal(t, brokerconfig.Marker, document["marker"]) | |||
| 682 | - | require.Equal(t, "1s", document["idleGrace"]) | |||
| 683 | - | require.Empty(t, sandboxEmptyDocument("")["idleGrace"]) | |||
| 684 | - | } | |||