feat(broker): route clients through autonomous broker
- commit
- ca376f7628379c809d4ce7099c8fd3a22d1c92f0
- author
- brice · · 2026-09-22 07:20 UTC
- committer
- brice · · 2026-09-22 07:20 UTC
- parent
- b56b682
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
326 files · +20457 -39680
M boundary_test.go +84 -20
| @@ -2,6 +2,7 @@ package main_test | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "fmt" | |
| 5 | + | "go/ast" | |
| 5 | 6 | "go/parser" | |
| 6 | 7 | "go/token" | |
| 7 | 8 | "io/fs" | |
| @@ -87,12 +88,6 @@ func TestImportBoundaryNegativeFixtures(t *testing.T) { | |||
| 87 | 88 | }{ | |
| 88 | 89 | {"usecase accepts semantic protocol", modulePath + "/internal/usecase/client", modulePath + "/internal/protocol", false, true}, | |
| 89 | 90 | {"usecase rejects wire", modulePath + "/internal/usecase/daemon", modulePath + "/internal/protocol/wire", false, false}, | |
| 90 | - | {"remotes accepts ports", modulePath + "/internal/usecase/remotes", modulePath + "/internal/ports", false, true}, | |
| 91 | - | {"remotes accepts domain", modulePath + "/internal/usecase/remotes", modulePath + "/internal/domain", false, true}, | |
| 92 | - | {"remotes rejects wire", modulePath + "/internal/usecase/remotes", modulePath + "/internal/protocol/wire", false, false}, | |
| 93 | - | {"remotes rejects adapter", modulePath + "/internal/usecase/remotes", modulePath + "/internal/adapters/ipc", false, false}, | |
| 94 | - | {"remotes rejects app", modulePath + "/internal/usecase/remotes", modulePath + "/internal/app", false, false}, | |
| 95 | - | {"remotes rejects persist", modulePath + "/internal/usecase/remotes", modulePath + "/internal/persist", false, false}, | |
| 96 | 91 | {"usecase rejects adapter", modulePath + "/internal/usecase/client", modulePath + "/internal/adapters/ipc", false, false}, | |
| 97 | 92 | {"domain rejects ports", modulePath + "/internal/domain", modulePath + "/internal/ports", false, false}, | |
| 98 | 93 | {"pkg rejects internal", modulePath + "/pkg/rawterm", modulePath + "/internal/domain", false, false}, | |
| @@ -110,26 +105,17 @@ func TestImportBoundaryNegativeFixtures(t *testing.T) { | |||
| 110 | 105 | {"broker subpackage accepts broker root", modulePath + "/internal/usecase/broker/streams", modulePath + "/internal/usecase/broker", false, true}, | |
| 111 | 106 | {"broker rejects client", modulePath + "/internal/usecase/broker", modulePath + "/internal/usecase/client", false, false}, | |
| 112 | 107 | {"broker rejects daemon", modulePath + "/internal/usecase/broker", modulePath + "/internal/usecase/daemon", false, false}, | |
| 113 | - | {"broker rejects remotes", modulePath + "/internal/usecase/broker", modulePath + "/internal/usecase/remotes", false, false}, | |
| 114 | 108 | {"broker rejects sibling usecase", modulePath + "/internal/usecase/broker", modulePath + "/internal/usecase/picker", false, false}, | |
| 115 | 109 | {"broker rejects adapter", modulePath + "/internal/usecase/broker", modulePath + "/internal/adapters/ipc", false, false}, | |
| 116 | - | {"broker rejects broker adapter", modulePath + "/internal/usecase/broker", modulePath + "/internal/adapters/remote", false, false}, | |
| 117 | 110 | {"broker rejects wire", modulePath + "/internal/usecase/broker", modulePath + "/internal/protocol/wire", false, false}, | |
| 118 | 111 | {"broker rejects app", modulePath + "/internal/usecase/broker", modulePath + "/internal/app", false, false}, | |
| 119 | 112 | {"broker rejects persist", modulePath + "/internal/usecase/broker", modulePath + "/internal/persist", false, false}, | |
| 120 | 113 | // P1.1 client/daemon must not own broker, each other, or remote discovery. | |
| 121 | 114 | {"client rejects daemon", modulePath + "/internal/usecase/client", modulePath + "/internal/usecase/daemon", false, false}, | |
| 122 | 115 | {"client rejects broker", modulePath + "/internal/usecase/client", modulePath + "/internal/usecase/broker", false, false}, | |
| 123 | - | {"client rejects remotes", modulePath + "/internal/usecase/client", modulePath + "/internal/usecase/remotes", false, false}, | |
| 124 | - | {"client rejects broker adapter", modulePath + "/internal/usecase/client", modulePath + "/internal/adapters/remote", false, false}, | |
| 125 | 116 | {"daemon rejects client", modulePath + "/internal/usecase/daemon", modulePath + "/internal/usecase/client", false, false}, | |
| 126 | 117 | {"daemon rejects broker", modulePath + "/internal/usecase/daemon", modulePath + "/internal/usecase/broker", false, false}, | |
| 127 | - | {"daemon rejects remotes registry", modulePath + "/internal/usecase/daemon", modulePath + "/internal/usecase/remotes", false, false}, | |
| 128 | - | {"daemon rejects remote adapter", modulePath + "/internal/usecase/daemon", modulePath + "/internal/adapters/remote", false, false}, | |
| 129 | 118 | {"daemon rejects app", modulePath + "/internal/usecase/daemon", modulePath + "/internal/app", false, false}, | |
| 130 | - | {"remotes rejects client", modulePath + "/internal/usecase/remotes", modulePath + "/internal/usecase/client", false, false}, | |
| 131 | - | {"remotes rejects daemon", modulePath + "/internal/usecase/remotes", modulePath + "/internal/usecase/daemon", false, false}, | |
| 132 | - | {"remotes rejects broker", modulePath + "/internal/usecase/remotes", modulePath + "/internal/usecase/broker", false, false}, | |
| 133 | 119 | } | |
| 134 | 120 | for _, tt := range tests { | |
| 135 | 121 | t.Run(tt.name, func(t *testing.T) { | |
| @@ -223,14 +209,12 @@ func dependencyAllowed(source, target string, testFile bool) (bool, error) { | |||
| 223 | 209 | ||
| 224 | 210 | // packageImportDenied encodes ADR 001 ownership at package granularity for | |
| 225 | 211 | // production files. Layer rules alone permit any usecase-to-usecase import; | |
| 226 | - | // these denies keep broker, client, daemon, and remotes from owning each | |
| 227 | 212 | // other, and keep the broker free of every sibling use case. Test files stay | |
| 228 | 213 | // exempt so composition tests can wire adapters and orchestrators together. | |
| 229 | 214 | func packageImportDenied(source, target string) bool { | |
| 230 | 215 | brokerPkg := modulePath + "/internal/usecase/broker" | |
| 231 | 216 | clientPkg := modulePath + "/internal/usecase/client" | |
| 232 | 217 | daemonPkg := modulePath + "/internal/usecase/daemon" | |
| 233 | - | remotesPkg := modulePath + "/internal/usecase/remotes" | |
| 234 | 218 | usecasePrefix := modulePath + "/internal/usecase/" | |
| 235 | 219 | under := func(path, root string) bool { | |
| 236 | 220 | return path == root || strings.HasPrefix(path, root+"/") | |
| @@ -239,10 +223,8 @@ func packageImportDenied(source, target string) bool { | |||
| 239 | 223 | case under(source, brokerPkg): | |
| 240 | 224 | return !under(target, brokerPkg) && strings.HasPrefix(target, usecasePrefix) | |
| 241 | 225 | case under(source, clientPkg): | |
| 242 | - | return under(target, daemonPkg) || under(target, brokerPkg) || under(target, remotesPkg) | |
| 226 | + | return under(target, daemonPkg) || under(target, brokerPkg) | |
| 243 | 227 | case under(source, daemonPkg): | |
| 244 | - | return under(target, clientPkg) || under(target, brokerPkg) || under(target, remotesPkg) | |
| 245 | - | case under(source, remotesPkg): | |
| 246 | 228 | return under(target, clientPkg) || under(target, daemonPkg) || under(target, brokerPkg) | |
| 247 | 229 | default: | |
| 248 | 230 | return false | |
| @@ -371,3 +353,85 @@ func inspectExternalDependencies(t *testing.T) []string { | |||
| 371 | 353 | sort.Strings(violations) | |
| 372 | 354 | return violations | |
| 373 | 355 | } | |
| 356 | + | ||
| 357 | + | // TestProductionDaemonBindingComposition pins the daemon authority composition | |
| 358 | + | // at the source level: the daemon's process binding may only be built by the one | |
| 359 | + | // production site that provisions the daemon's closed policy set, and the | |
| 360 | + | // single-policy shorthand must not reappear outside test fixtures. | |
| 361 | + | // | |
| 362 | + | // The multi-policy constructor exists because a daemon serves one exact policy | |
| 363 | + | // per accepted carriage shape (its own local Unix daemonmux policy, plus each | |
| 364 | + | // provisioned remote transport policy). A production composition that used the | |
| 365 | + | // single-policy shorthand would publish an authority that refuses every remote | |
| 366 | + | // carriage, so the composition is pinned to the multi-policy call, and the | |
| 367 | + | // shorthand stays a fixtures-only convenience. | |
| 368 | + | func TestProductionDaemonBindingComposition(t *testing.T) { | |
| 369 | + | const runFile = "internal/app/run.go" | |
| 370 | + | ||
| 371 | + | var ( | |
| 372 | + | bindingSites []string | |
| 373 | + | runBindings int | |
| 374 | + | ) | |
| 375 | + | fset := token.NewFileSet() | |
| 376 | + | err := filepath.WalkDir(".", func(path string, entry fs.DirEntry, walkErr error) error { | |
| 377 | + | if walkErr != nil { | |
| 378 | + | return walkErr | |
| 379 | + | } | |
| 380 | + | name := entry.Name() | |
| 381 | + | if entry.IsDir() { | |
| 382 | + | if name == ".git" || name == ".worktrees" || name == "vendor" { | |
| 383 | + | return filepath.SkipDir | |
| 384 | + | } | |
| 385 | + | return nil | |
| 386 | + | } | |
| 387 | + | if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { | |
| 388 | + | return nil | |
| 389 | + | } | |
| 390 | + | file, err := parser.ParseFile(fset, path, nil, 0) | |
| 391 | + | if err != nil { | |
| 392 | + | return err | |
| 393 | + | } | |
| 394 | + | slashed := filepath.ToSlash(path) | |
| 395 | + | for _, decl := range file.Decls { | |
| 396 | + | fn, ok := decl.(*ast.FuncDecl) | |
| 397 | + | if !ok || fn.Body == nil { | |
| 398 | + | continue | |
| 399 | + | } | |
| 400 | + | ast.Inspect(fn.Body, func(node ast.Node) bool { | |
| 401 | + | call, ok := node.(*ast.CallExpr) | |
| 402 | + | if !ok { | |
| 403 | + | return true | |
| 404 | + | } | |
| 405 | + | selector, ok := call.Fun.(*ast.SelectorExpr) | |
| 406 | + | if !ok { | |
| 407 | + | return true | |
| 408 | + | } | |
| 409 | + | // Only the adapter's own constructors count: a method spelled | |
| 410 | + | // the same way elsewhere is unrelated. | |
| 411 | + | ident, ok := selector.X.(*ast.Ident) | |
| 412 | + | if !ok || ident.Name != "daemonmux" { | |
| 413 | + | return true | |
| 414 | + | } | |
| 415 | + | switch selector.Sel.Name { | |
| 416 | + | case "NewServerBinding": | |
| 417 | + | bindingSites = append(bindingSites, fmt.Sprintf("%s:%d: %s must build the closed policy set with NewServerBindings", slashed, fset.Position(call.Pos()).Line, selector.Sel.Name)) | |
| 418 | + | case "NewServerBindings": | |
| 419 | + | if slashed == runFile { | |
| 420 | + | runBindings++ | |
| 421 | + | } | |
| 422 | + | } | |
| 423 | + | return true | |
| 424 | + | }) | |
| 425 | + | } | |
| 426 | + | return nil | |
| 427 | + | }) | |
| 428 | + | if err != nil { | |
| 429 | + | t.Fatalf("walking repository: %v", err) | |
| 430 | + | } | |
| 431 | + | if len(bindingSites) != 0 { | |
| 432 | + | t.Fatalf("production code uses the single-policy daemon binding shorthand:\n%s", strings.Join(bindingSites, "\n")) | |
| 433 | + | } | |
| 434 | + | if runBindings == 0 { | |
| 435 | + | t.Fatalf("%s must compose the daemon authority with daemonmux.NewServerBindings", runFile) | |
| 436 | + | } | |
| 437 | + | } | |
M cmd/vev-perf-harness/evidence_test.go +1 -1
| @@ -34,7 +34,7 @@ func TestCLITransportSeamOwnsExclusivePeerTraceAndCleanup(t *testing.T) { | |||
| 34 | 34 | t.Fatal(err) | |
| 35 | 35 | } | |
| 36 | 36 | text := string(shim) | |
| 37 | - | for _, want := range []string{"_stdio", m.TracePath, m.ProcessID} { | |
| 37 | + | for _, want := range []string{"_broker-mux-stdio", m.TracePath, m.ProcessID} { | |
| 38 | 38 | if !strings.Contains(text, want) { | |
| 39 | 39 | t.Errorf("seam does not retain %q:\n%s", want, text) | |
| 40 | 40 | } | |
M cmd/vev-perf-harness/hangup_test.go +8 -8
| @@ -110,11 +110,11 @@ func TestClientTracePairsAfterPTYHangup(t *testing.T) { | |||
| 110 | 110 | } | |
| 111 | 111 | }() | |
| 112 | 112 | ||
| 113 | - | // Wait until the client is attached with a receive pump blocked mid-frame: | |
| 114 | - | // its trace then holds one more adapter_receive_start than end. That in-flight | |
| 115 | - | // span is exactly what an ungraceful death truncates. | |
| 116 | - | if err := waitForInflightReceive(clientMapping.TracePath, 10*time.Second); err != nil { | |
| 117 | - | t.Fatalf("client never reached a blocked receive: %v", err) | |
| 113 | + | // Wait until the broker-backed client reaches a real attached shell before | |
| 114 | + | // severing its terminal. Session carriage tracing now belongs to the broker, | |
| 115 | + | // not the thin client process. | |
| 116 | + | if err := client.waitForTerminalReady(); err != nil { | |
| 117 | + | t.Fatalf("client never reached an attached shell: %v", err) | |
| 118 | 118 | } | |
| 119 | 119 | ||
| 120 | 120 | // Sever the controlling terminal with no graceful exit. This is the harness | |
| @@ -183,7 +183,7 @@ func TestClientGracefulShutdownDrainsTracedStdioDescendant(t *testing.T) { | |||
| 183 | 183 | var command roleCommand | |
| 184 | 184 | switch pm.Role { | |
| 185 | 185 | case "daemon": | |
| 186 | - | command = roleCommand{Args: []string{"--daemon"}} | |
| 186 | + | command = routeRoleArgs(s, pm, transport{}) | |
| 187 | 187 | case "ssh_stdio_peer": | |
| 188 | 188 | // The transport-neutral _stdio mode requests an ephemeral session. | |
| 189 | 189 | command = roleCommand{Args: []string{"_stdio"}, Transport: transport{ID: "ssh_stdio", Kind: "ssh_stdio"}} | |
| @@ -231,8 +231,8 @@ func TestClientGracefulShutdownDrainsTracedStdioDescendant(t *testing.T) { | |||
| 231 | 231 | } | |
| 232 | 232 | }() | |
| 233 | 233 | ||
| 234 | - | if err := waitForInflightReceive(peerMapping.TracePath, 10*time.Second); err != nil { | |
| 235 | - | t.Fatalf("stdio descendant never reached blocked receive: %v", err) | |
| 234 | + | if err := client.waitForTerminalReady(); err != nil { | |
| 235 | + | t.Fatalf("stdio client never reached an attached shell: %v", err) | |
| 236 | 236 | } | |
| 237 | 237 | processGroup := client.cmd.Process.Pid | |
| 238 | 238 | if err := client.Close(); err != nil { | |
M cmd/vev-perf-harness/launcher.go +200 -50
| @@ -5,6 +5,7 @@ package main | |||
| 5 | 5 | import ( | |
| 6 | 6 | "bytes" | |
| 7 | 7 | "context" | |
| 8 | + | "encoding/json" | |
| 8 | 9 | "errors" | |
| 9 | 10 | "fmt" | |
| 10 | 11 | "io" | |
| @@ -66,8 +67,10 @@ type cliLauncher struct { | |||
| 66 | 67 | } | |
| 67 | 68 | ||
| 68 | 69 | type peerRoute struct { | |
| 69 | - | mapping processMapping | |
| 70 | - | command roleCommand | |
| 70 | + | mapping processMapping | |
| 71 | + | command roleCommand | |
| 72 | + | runtimeDir string | |
| 73 | + | stateDir string | |
| 71 | 74 | } | |
| 72 | 75 | ||
| 73 | 76 | type preparedPeer struct { | |
| @@ -91,6 +94,46 @@ type terminalOutput interface { | |||
| 91 | 94 | Close() error | |
| 92 | 95 | } | |
| 93 | 96 | ||
| 97 | + | type capturedTerminalOutput struct { | |
| 98 | + | file *os.File | |
| 99 | + | mu sync.Mutex | |
| 100 | + | data bytes.Buffer | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | type synchronizedBuffer struct { | |
| 104 | + | mu sync.Mutex | |
| 105 | + | data bytes.Buffer | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | func (b *synchronizedBuffer) Write(p []byte) (int, error) { | |
| 109 | + | b.mu.Lock() | |
| 110 | + | defer b.mu.Unlock() | |
| 111 | + | return b.data.Write(p) | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | func (b *synchronizedBuffer) WriteString(value string) { | |
| 115 | + | _, _ = b.Write([]byte(value)) | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | func (b *synchronizedBuffer) String() string { | |
| 119 | + | b.mu.Lock() | |
| 120 | + | defer b.mu.Unlock() | |
| 121 | + | return b.data.String() | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | func (o *capturedTerminalOutput) Write(p []byte) (int, error) { | |
| 125 | + | o.mu.Lock() | |
| 126 | + | _, _ = o.data.Write(p) | |
| 127 | + | o.mu.Unlock() | |
| 128 | + | return o.file.Write(p) | |
| 129 | + | } | |
| 130 | + | func (o *capturedTerminalOutput) Close() error { return o.file.Close() } | |
| 131 | + | func (o *capturedTerminalOutput) String() string { | |
| 132 | + | o.mu.Lock() | |
| 133 | + | defer o.mu.Unlock() | |
| 134 | + | return o.data.String() | |
| 135 | + | } | |
| 136 | + | ||
| 94 | 137 | var setPTYWinsize = rawterm.SetWinsize | |
| 95 | 138 | ||
| 96 | 139 | type cliProcess struct { | |
| @@ -108,7 +151,12 @@ type cliProcess struct { | |||
| 108 | 151 | shutdown func() error | |
| 109 | 152 | cleanupRuntime func() error | |
| 110 | 153 | terminalReady bool | |
| 111 | - | readyPath string | |
| 154 | + | readyCommand *exec.Cmd | |
| 155 | + | startCommand *exec.Cmd | |
| 156 | + | configureCommand *exec.Cmd | |
| 157 | + | serverOutput *synchronizedBuffer | |
| 158 | + | peerCommand *exec.Cmd | |
| 159 | + | peerDone chan error | |
| 112 | 160 | closeResult error | |
| 113 | 161 | } | |
| 114 | 162 | ||
| @@ -146,25 +194,80 @@ func (l *cliLauncher) Launch(m processMapping, role roleCommand) (launchedProces | |||
| 146 | 194 | // a role working directory: daemon-owned subprocess cleanup may otherwise | |
| 147 | 195 | // treat the evidence directory as its working tree and remove a preallocated | |
| 148 | 196 | // trace while a later repetition is being merged. | |
| 149 | - | cmd.Env = append(withoutEnv(os.Environ(), "VEV", "VEV_PERF_TRACE", "VEV_PERF_PROCESS_ID", "VEV_PERF_SCENARIO", "VEV_PERF_RUN", "VEV_PERF_BIN", "VEV_REMOTE_TRANSPORT", "XDG_RUNTIME_DIR", "XDG_STATE_HOME"), traceEnvironment(m)...) | |
| 197 | + | cmd.Env = append(withoutEnv(os.Environ(), "VEV", "VEV_PERF_TRACE", "VEV_PERF_PROCESS_ID", "VEV_PERF_SCENARIO", "VEV_PERF_RUN", "VEV_PERF_BIN", "VEV_REMOTE_TRANSPORT", "XDG_RUNTIME_DIR", "XDG_STATE_HOME", "XDG_CONFIG_HOME"), traceEnvironment(m)...) | |
| 150 | 198 | cmd.Env = append(cmd.Env, "XDG_RUNTIME_DIR="+runtimeDir, | |
| 151 | - | "XDG_STATE_HOME="+filepath.Join(runDir, "state"), "TERM=xterm-256color", "VEV_PERF_BIN="+bin) | |
| 152 | - | if m.Role == "client" { | |
| 199 | + | "XDG_STATE_HOME="+filepath.Join(runDir, "state"), "XDG_CONFIG_HOME="+filepath.Join(runDir, "config"), "TERM=xterm-256color", "SHELL=/bin/sh", "VEV_PERF_BIN="+bin) | |
| 200 | + | l.mu.Lock() | |
| 201 | + | peer, routed := l.peers[runDir] | |
| 202 | + | l.mu.Unlock() | |
| 203 | + | if routed && (m.Role == "client" || m.Role == "daemon") { | |
| 204 | + | mode, err := remoteMode(peer.command.Transport) | |
| 205 | + | if err != nil { | |
| 206 | + | return nil, err | |
| 207 | + | } | |
| 208 | + | cmd.Env = append(withoutEnv(cmd.Env, "PATH", "VEV_REMOTE_TRANSPORT"), "PATH="+runDir+":"+os.Getenv("PATH"), "VEV_REMOTE_TRANSPORT="+mode) | |
| 209 | + | } | |
| 210 | + | if err := safedir.EnsurePrivate(filepath.Join(runDir, "state")); err != nil { | |
| 211 | + | return nil, err | |
| 212 | + | } | |
| 213 | + | if m.Role == "daemon" { | |
| 214 | + | configDir := filepath.Join(runDir, "config", "vev") | |
| 215 | + | if err := safedir.EnsurePrivate(configDir); err != nil { | |
| 216 | + | return nil, err | |
| 217 | + | } | |
| 218 | + | if err := os.WriteFile(filepath.Join(configDir, "broker.json"), []byte(`{"marker":"vev.broker.offline/v1","registrations":[]}`), 0o600); err != nil { | |
| 219 | + | return nil, err | |
| 220 | + | } | |
| 221 | + | } | |
| 222 | + | p := &cliProcess{cmd: cmd, waitErr: make(chan error, 1), serverOutput: &synchronizedBuffer{}} | |
| 223 | + | if m.Role == "daemon" { | |
| 224 | + | p.startCommand = exec.Command(bin, "ls") | |
| 225 | + | p.startCommand.Env = cmd.Env | |
| 226 | + | p.readyCommand = exec.Command(bin, "_broker-ready", "--require", "local-catalogue", "--timeout", "10s") | |
| 227 | + | p.readyCommand.Env = cmd.Env | |
| 153 | 228 | l.mu.Lock() | |
| 154 | 229 | peer, routed := l.peers[runDir] | |
| 155 | 230 | l.mu.Unlock() | |
| 156 | 231 | if routed { | |
| 157 | - | mode, err := remoteMode(peer.command.Transport) | |
| 158 | - | if err != nil { | |
| 159 | - | return nil, err | |
| 232 | + | mode, modeErr := remoteMode(peer.command.Transport) | |
| 233 | + | if modeErr != nil { | |
| 234 | + | return nil, modeErr | |
| 160 | 235 | } | |
| 161 | - | cmd.Env = append(cmd.Env, "PATH="+runDir+":"+os.Getenv("PATH"), "VEV_REMOTE_TRANSPORT="+mode) | |
| 236 | + | p.configureCommand = exec.Command(bin, "host", "add", "harness@127.0.0.1") | |
| 237 | + | p.configureCommand.Env = append(withoutEnv(cmd.Env, "PATH", "VEV_REMOTE_TRANSPORT"), "PATH="+runDir+":"+os.Getenv("PATH"), "VEV_REMOTE_TRANSPORT="+mode) | |
| 238 | + | peerStart := exec.Command(bin, "--daemon") | |
| 239 | + | peerStart.Env = append(withoutEnv(os.Environ(), "VEV", "VEV_PERF_TRACE", "VEV_PERF_PROCESS_ID", "VEV_PERF_SCENARIO", "VEV_PERF_RUN", "XDG_RUNTIME_DIR", "XDG_STATE_HOME", "PATH"), | |
| 240 | + | "PATH="+runDir+":"+os.Getenv("PATH"), | |
| 241 | + | "XDG_RUNTIME_DIR="+peer.runtimeDir, | |
| 242 | + | "XDG_STATE_HOME="+peer.stateDir, | |
| 243 | + | "TERM=xterm-256color") | |
| 244 | + | peerStartOutput := &bytes.Buffer{} | |
| 245 | + | peerStart.Stdin, peerStart.Stdout, peerStart.Stderr = nil, peerStartOutput, peerStartOutput | |
| 246 | + | if err := peerStart.Start(); err != nil { | |
| 247 | + | return nil, fmt.Errorf("start remote peer daemon: %w", err) | |
| 248 | + | } | |
| 249 | + | peerSocket := filepath.Join(peer.runtimeDir, "vev", "daemonmux.sock") | |
| 250 | + | deadline := time.Now().Add(10 * time.Second) | |
| 251 | + | for { | |
| 252 | + | if info, err := os.Stat(peerSocket); err == nil && info.Mode()&os.ModeSocket != 0 { | |
| 253 | + | break | |
| 254 | + | } | |
| 255 | + | if time.Now().After(deadline) { | |
| 256 | + | return nil, fmt.Errorf("remote peer daemon did not publish %s (output=%q)", peerSocket, peerStartOutput.String()) | |
| 257 | + | } | |
| 258 | + | time.Sleep(10 * time.Millisecond) | |
| 259 | + | } | |
| 260 | + | p.peerCommand = peerStart | |
| 261 | + | p.peerDone = make(chan error, 1) | |
| 262 | + | go func() { | |
| 263 | + | err := peerStart.Wait() | |
| 264 | + | if err != nil { | |
| 265 | + | p.serverOutput.WriteString(" remote-peer: " + err.Error() + " " + peerStartOutput.String()) | |
| 266 | + | } | |
| 267 | + | p.peerDone <- err | |
| 268 | + | }() | |
| 162 | 269 | } | |
| 163 | 270 | } | |
| 164 | - | if err := safedir.EnsurePrivate(filepath.Join(runDir, "state")); err != nil { | |
| 165 | - | return nil, err | |
| 166 | - | } | |
| 167 | - | p := &cliProcess{cmd: cmd, waitErr: make(chan error, 1), readyPath: filepath.Join(runtimeDir, "vev", "daemon.sock")} | |
| 168 | 271 | if m.Role == "daemon" { | |
| 169 | 272 | p.cleanupRuntime = func() error { return l.releaseRuntime(runDir) } | |
| 170 | 273 | } | |
| @@ -197,7 +300,7 @@ func (l *cliLauncher) Launch(m processMapping, role roleCommand) (launchedProces | |||
| 197 | 300 | return nil, err | |
| 198 | 301 | } | |
| 199 | 302 | _ = slave.Close() | |
| 200 | - | p.pty, p.output, p.chunks, p.done = master, output, make(chan []byte, 32), make(chan struct{}) | |
| 303 | + | p.pty, p.output, p.chunks, p.done = master, &capturedTerminalOutput{file: output}, make(chan []byte, 32), make(chan struct{}) | |
| 201 | 304 | // Stop workload generation through the attached shell before any signal. | |
| 202 | 305 | // A normal shell exit makes the daemon detach the client; the client then | |
| 203 | 306 | // closes and waits for its ssh transport, allowing a traced _stdio | |
| @@ -213,7 +316,7 @@ func (l *cliLauncher) Launch(m processMapping, role roleCommand) (launchedProces | |||
| 213 | 316 | if err != nil { | |
| 214 | 317 | return nil, err | |
| 215 | 318 | } | |
| 216 | - | cmd.Stdin, cmd.Stdout, cmd.Stderr = devNull, devNull, devNull | |
| 319 | + | cmd.Stdin, cmd.Stdout, cmd.Stderr = devNull, p.serverOutput, p.serverOutput | |
| 217 | 320 | if err := cmd.Start(); err != nil { | |
| 218 | 321 | _ = devNull.Close() | |
| 219 | 322 | if p.cleanupRuntime != nil { | |
| @@ -224,9 +327,9 @@ func (l *cliLauncher) Launch(m processMapping, role roleCommand) (launchedProces | |||
| 224 | 327 | _ = devNull.Close() | |
| 225 | 328 | } | |
| 226 | 329 | if m.Role == "daemon" { | |
| 227 | - | // Stop the foreground daemon through its public CLI before its bounded | |
| 228 | - | // process-group fallback. This lets blocked carriage operations close and | |
| 229 | - | // serialize their failed end marks instead of being cut off by SIGKILL. | |
| 330 | + | // Ask the daemon owned by the foreground broker to stop before the | |
| 331 | + | // broker's bounded process-group fallback. This lets blocked carriage | |
| 332 | + | // operations serialize their failed end marks before either process exits. | |
| 230 | 333 | p.shutdown = func() error { | |
| 231 | 334 | ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) | |
| 232 | 335 | defer cancel() | |
| @@ -310,7 +413,13 @@ func (l *cliLauncher) preparePeer(m processMapping, role roleCommand) (launchedP | |||
| 310 | 413 | if len(role.Args) != 1 || role.Args[0] != "_stdio" { | |
| 311 | 414 | return nil, fail(fmt.Errorf("unsupported public peer command %q", role.Args)) | |
| 312 | 415 | } | |
| 313 | - | route := peerRoute{mapping: m, command: role} | |
| 416 | + | route := peerRoute{mapping: m, command: role, runtimeDir: filepath.Join(runDir, "peer-runtime"), stateDir: filepath.Join(runDir, "peer-state")} | |
| 417 | + | if err := safedir.EnsurePrivate(route.runtimeDir); err != nil { | |
| 418 | + | return nil, fail(err) | |
| 419 | + | } | |
| 420 | + | if err := safedir.EnsurePrivate(route.stateDir); err != nil { | |
| 421 | + | return nil, fail(err) | |
| 422 | + | } | |
| 314 | 423 | shim := filepath.Join(runDir, "ssh") | |
| 315 | 424 | // The SSH command seam forwards the public _stdio entrypoint with the | |
| 316 | 425 | // peer's unique trace identity, so the peer carrying client traffic is the | |
| @@ -318,8 +427,8 @@ func (l *cliLauncher) preparePeer(m processMapping, role roleCommand) (launchedP | |||
| 318 | 427 | body := fmt.Sprintf(`#!/bin/sh | |
| 319 | 428 | set -eu | |
| 320 | 429 | case "$*" in | |
| 321 | - | *"_stdio"*) | |
| 322 | - | exec env VEV_PERF_TRACE=%[1]q VEV_PERF_PROCESS_ID=%[2]q VEV_PERF_SCENARIO=%[6]q VEV_PERF_RUN=%[7]d XDG_RUNTIME_DIR=%[3]q XDG_STATE_HOME=%[4]q TERM=xterm-256color %[5]q _stdio | |
| 430 | + | *"_broker-mux-stdio"*) | |
| 431 | + | exec env VEV_PERF_TRACE=%[1]q VEV_PERF_PROCESS_ID=%[2]q VEV_PERF_SCENARIO=%[6]q VEV_PERF_RUN=%[7]d XDG_RUNTIME_DIR=%[3]q XDG_STATE_HOME=%[4]q TERM=xterm-256color %[5]q _broker-mux-stdio --production --daemon-start existing-only | |
| 323 | 432 | ;; | |
| 324 | 433 | *) echo 'vev harness ssh seam rejected non-vev command' >&2; exit 64 ;; | |
| 325 | 434 | esac | |
| @@ -402,35 +511,48 @@ func (p *cliProcess) copyTerminal() { | |||
| 402 | 511 | } | |
| 403 | 512 | } | |
| 404 | 513 | ||
| 405 | - | // WaitReady confirms the explicitly launched public daemon owns its unique | |
| 406 | - | // socket before a client is launched. Without this barrier, the normal client | |
| 407 | - | // auto-spawn behavior can create an unmanifested daemon process during startup. | |
| 514 | + | // WaitReady executes the production dial-only readiness contract under the | |
| 515 | + | // exact XDG environment shared by the servers and clients. Its bounded JSON | |
| 516 | + | // stdout, stderr and owner-process death are retained in any failure. | |
| 408 | 517 | func (p *cliProcess) WaitReady() error { | |
| 409 | - | if p.readyPath == "" { | |
| 410 | - | return errors.New("daemon readiness path is empty") | |
| 411 | - | } | |
| 412 | - | ticker := time.NewTicker(10 * time.Millisecond) | |
| 413 | - | defer ticker.Stop() | |
| 414 | - | timer := time.NewTimer(10 * time.Second) | |
| 415 | - | defer timer.Stop() | |
| 416 | - | for { | |
| 417 | - | if info, err := os.Stat(p.readyPath); err == nil { | |
| 418 | - | if info.Mode()&os.ModeSocket == 0 { | |
| 419 | - | return fmt.Errorf("daemon readiness path %s is not a socket", p.readyPath) | |
| 420 | - | } | |
| 421 | - | return nil | |
| 518 | + | if p.startCommand != nil { | |
| 519 | + | // The first control stream is only a start authorization. The daemon may | |
| 520 | + | // close that stream while publishing its first catalogue, so readiness is | |
| 521 | + | // decided exclusively by the semantic probe below. | |
| 522 | + | _, _ = p.startCommand.CombinedOutput() | |
| 523 | + | } | |
| 524 | + | if p.readyCommand == nil { | |
| 525 | + | return errors.New("broker readiness command is empty") | |
| 526 | + | } | |
| 527 | + | var stdout, stderr bytes.Buffer | |
| 528 | + | p.readyCommand.Stdout, p.readyCommand.Stderr = &stdout, &stderr | |
| 529 | + | probeDone := make(chan error, 1) | |
| 530 | + | if err := p.readyCommand.Start(); err != nil { | |
| 531 | + | return fmt.Errorf("start broker readiness probe: %w", err) | |
| 532 | + | } | |
| 533 | + | go func() { probeDone <- p.readyCommand.Wait() }() | |
| 534 | + | select { | |
| 535 | + | case err := <-probeDone: | |
| 536 | + | if err != nil { | |
| 537 | + | return fmt.Errorf("broker readiness probe: %w (stdout=%q stderr=%q)", err, stdout.String(), stderr.String()) | |
| 422 | 538 | } | |
| 423 | - | select { | |
| 424 | - | case err := <-p.waitErr: | |
| 425 | - | p.waitErr <- err | |
| 426 | - | if err == nil { | |
| 427 | - | return errors.New("daemon exited before readiness") | |
| 539 | + | var report struct { | |
| 540 | + | Status string `json:"status"` | |
| 541 | + | } | |
| 542 | + | if err := json.Unmarshal(bytes.TrimSpace(stdout.Bytes()), &report); err != nil || report.Status != "ready" { | |
| 543 | + | return fmt.Errorf("broker readiness invalid JSON/status: %v (stdout=%q stderr=%q)", err, stdout.String(), stderr.String()) | |
| 544 | + | } | |
| 545 | + | if p.configureCommand != nil { | |
| 546 | + | if output, err := p.configureCommand.CombinedOutput(); err != nil { | |
| 547 | + | return fmt.Errorf("configure remote broker membership: %w (output=%q)", err, output) | |
| 428 | 548 | } | |
| 429 | - | return fmt.Errorf("daemon exited before readiness: %w", err) | |
| 430 | - | case <-ticker.C: | |
| 431 | - | case <-timer.C: | |
| 432 | - | return fmt.Errorf("timed out waiting for daemon socket %s", p.readyPath) | |
| 433 | 549 | } | |
| 550 | + | return nil | |
| 551 | + | case err := <-p.waitErr: | |
| 552 | + | p.waitErr <- err | |
| 553 | + | _ = p.readyCommand.Process.Kill() | |
| 554 | + | probeErr := <-probeDone | |
| 555 | + | return fmt.Errorf("production server group died before readiness: %v (probe=%v stdout=%q stderr=%q servers=%q)", err, probeErr, stdout.String(), stderr.String(), p.serverOutput.String()) | |
| 434 | 556 | } | |
| 435 | 557 | } | |
| 436 | 558 | ||
| @@ -483,16 +605,20 @@ func (p *cliProcess) Measure(input []byte, injected, flushed func() error) error | |||
| 483 | 605 | func (p *cliProcess) waitForTerminalReady() error { | |
| 484 | 606 | timer := time.NewTimer(10 * time.Second) | |
| 485 | 607 | defer timer.Stop() | |
| 608 | + | var observed []byte | |
| 486 | 609 | for { | |
| 487 | 610 | select { | |
| 488 | 611 | case chunk, ok := <-p.chunks: | |
| 489 | 612 | if !ok { | |
| 490 | 613 | return p.terminalClosedError("readiness output") | |
| 491 | 614 | } | |
| 492 | - | if len(chunk) == 0 { | |
| 493 | - | continue | |
| 615 | + | observed = append(observed, chunk...) | |
| 616 | + | if bytes.Contains(observed, []byte("$")) || bytes.Contains(observed, []byte("#")) { | |
| 617 | + | return nil | |
| 618 | + | } | |
| 619 | + | if len(observed) > 64*1024 { | |
| 620 | + | observed = append([]byte(nil), observed[len(observed)-32*1024:]...) | |
| 494 | 621 | } | |
| 495 | - | return nil | |
| 496 | 622 | case err := <-p.waitErr: | |
| 497 | 623 | // Keep the exit result available to Close, which owns reaping. | |
| 498 | 624 | p.waitErr <- err | |
| @@ -501,11 +627,21 @@ func (p *cliProcess) waitForTerminalReady() error { | |||
| 501 | 627 | } | |
| 502 | 628 | return fmt.Errorf("client exited before readiness output: %w", err) | |
| 503 | 629 | case <-timer.C: | |
| 630 | + | if captured, ok := p.output.(*capturedTerminalOutput); ok { | |
| 631 | + | return fmt.Errorf("timed out waiting for client readiness output (terminal=%q)", tailString(captured.String(), 4096)) | |
| 632 | + | } | |
| 504 | 633 | return errors.New("timed out waiting for client readiness output") | |
| 505 | 634 | } | |
| 506 | 635 | } | |
| 507 | 636 | } | |
| 508 | 637 | ||
| 638 | + | func tailString(value string, limit int) string { | |
| 639 | + | if len(value) <= limit { | |
| 640 | + | return value | |
| 641 | + | } | |
| 642 | + | return value[len(value)-limit:] | |
| 643 | + | } | |
| 644 | + | ||
| 509 | 645 | func (p *cliProcess) terminalClosedError(boundary string) error { | |
| 510 | 646 | if p.waitErr != nil { | |
| 511 | 647 | select { | |
| @@ -541,6 +677,9 @@ func (p *cliProcess) waitForTerminalMarker(marker, input []byte) error { | |||
| 541 | 677 | } | |
| 542 | 678 | return fmt.Errorf("client exited before application output: %w", err) | |
| 543 | 679 | case <-timer.C: | |
| 680 | + | if captured, ok := p.output.(*capturedTerminalOutput); ok { | |
| 681 | + | return fmt.Errorf("timed out waiting for application terminal output (terminal=%q)", captured.String()) | |
| 682 | + | } | |
| 544 | 683 | return errors.New("timed out waiting for application terminal output") | |
| 545 | 684 | } | |
| 546 | 685 | } | |
| @@ -670,6 +809,17 @@ func (p *cliProcess) Close() error { | |||
| 670 | 809 | if drainDone != nil { | |
| 671 | 810 | <-drainDone | |
| 672 | 811 | } | |
| 812 | + | if p.peerCommand != nil && p.peerCommand.Process != nil { | |
| 813 | + | _ = p.peerCommand.Process.Signal(syscall.SIGTERM) | |
| 814 | + | if p.peerDone != nil { | |
| 815 | + | select { | |
| 816 | + | case <-p.peerDone: | |
| 817 | + | case <-time.After(2 * time.Second): | |
| 818 | + | _ = p.peerCommand.Process.Kill() | |
| 819 | + | <-p.peerDone | |
| 820 | + | } | |
| 821 | + | } | |
| 822 | + | } | |
| 673 | 823 | if p.output != nil { | |
| 674 | 824 | if err := p.output.Close(); p.closeResult == nil { | |
| 675 | 825 | p.closeResult = err | |
M cmd/vev-perf-harness/launcher_test.go +4 -15
| @@ -4,7 +4,6 @@ package main | |||
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "bytes" | |
| 7 | - | "context" | |
| 8 | 7 | "errors" | |
| 9 | 8 | "fmt" | |
| 10 | 9 | "net" | |
| @@ -337,19 +336,9 @@ func TestCLIProcessCloseEscalationOutcome(t *testing.T) { | |||
| 337 | 336 | } | |
| 338 | 337 | } | |
| 339 | 338 | ||
| 340 | - | func TestCLIProcessWaitReadyRequiresDaemonSocket(t *testing.T) { | |
| 341 | - | path := filepath.Join(t.TempDir(), "daemon.sock") | |
| 342 | - | var lc net.ListenConfig | |
| 343 | - | listener, err := lc.Listen(context.Background(), "unix", path) | |
| 344 | - | if err != nil { | |
| 345 | - | t.Fatal(err) | |
| 346 | - | } | |
| 347 | - | t.Cleanup(func() { | |
| 348 | - | if err := listener.Close(); err != nil { | |
| 349 | - | t.Error(err) | |
| 350 | - | } | |
| 351 | - | }) | |
| 352 | - | if err := (&cliProcess{readyPath: path, waitErr: make(chan error, 1)}).WaitReady(); err != nil { | |
| 339 | + | func TestCLIProcessWaitReadyRunsJSONProbe(t *testing.T) { | |
| 340 | + | command := exec.Command("sh", "-c", `printf '%s\n' '{"schema":"vev.broker-ready/v1","status":"ready"}'`) | |
| 341 | + | if err := (&cliProcess{readyCommand: command, waitErr: make(chan error, 1)}).WaitReady(); err != nil { | |
| 353 | 342 | t.Fatal(err) | |
| 354 | 343 | } | |
| 355 | 344 | } | |
| @@ -612,7 +601,7 @@ func TestHarnessUsesPublicRoleCommandsAndPTYWorkloads(t *testing.T) { | |||
| 612 | 601 | role string | |
| 613 | 602 | want []string | |
| 614 | 603 | }{ | |
| 615 | - | {"daemon", []string{"--daemon"}}, | |
| 604 | + | {"daemon", []string{"_broker-production-serve"}}, | |
| 616 | 605 | {"client", []string{"new", "perf-s-001"}}, | |
| 617 | 606 | {"ssh_stdio_peer", []string{"_stdio"}}, | |
| 618 | 607 | } { | |
M cmd/vev-perf-harness/runner.go +7 -3
| @@ -387,7 +387,9 @@ func routeRoleArgs(s scenario, m processMapping, selected transport) roleCommand | |||
| 387 | 387 | remote := []string{"attach", "harness@127.0.0.1"} | |
| 388 | 388 | switch m.Role { | |
| 389 | 389 | case "daemon": | |
| 390 | - | return roleCommand{Args: []string{"--daemon"}} | |
| 390 | + | // The historical role now owns the foreground broker. The broker starts | |
| 391 | + | // and reaches the local daemon only through its daemonmux route. | |
| 392 | + | return roleCommand{Args: []string{"_broker-production-serve"}} | |
| 391 | 393 | case "client": | |
| 392 | 394 | switch s.Transport { | |
| 393 | 395 | case "local": | |
| @@ -423,9 +425,11 @@ func launchOrder(mappings []processMapping) []processMapping { | |||
| 423 | 425 | out := append([]processMapping(nil), mappings...) | |
| 424 | 426 | priority := func(role string) int { | |
| 425 | 427 | switch role { | |
| 426 | - | case "daemon": | |
| 427 | - | return 0 | |
| 428 | 428 | case "ssh_stdio_peer": | |
| 429 | + | // Peer preparation installs the per-run SSH shim before the broker is | |
| 430 | + | // configured and probed; it does not start the remote process yet. | |
| 431 | + | return 0 | |
| 432 | + | case "daemon": | |
| 429 | 433 | return 1 | |
| 430 | 434 | case "client": | |
| 431 | 435 | return 2 | |
M cmd/vev-perf-harness/runner_test.go +9 -4
| @@ -102,8 +102,13 @@ func TestHarnessCanonicalLocalRolesAreIsolatedAcrossRepetitions(t *testing.T) { | |||
| 102 | 102 | } | |
| 103 | 103 | } | |
| 104 | 104 | } | |
| 105 | - | if _, err := os.Stat(filepath.Join(runDir, "runtime", "vev", "daemon.sock")); !errors.Is(err, os.ErrNotExist) { | |
| 106 | - | t.Fatalf("run %d left daemon socket for the next repetition: %v", run, err) | |
| 105 | + | for _, socket := range []string{ | |
| 106 | + | filepath.Join(runDir, "runtime", "vev", "broker", "broker.sock"), | |
| 107 | + | filepath.Join(runDir, "runtime", "vev", "daemonmux.sock"), | |
| 108 | + | } { | |
| 109 | + | if _, err := os.Stat(socket); !errors.Is(err, os.ErrNotExist) { | |
| 110 | + | t.Fatalf("run %d left production socket %q for the next repetition: %v", run, socket, err) | |
| 111 | + | } | |
| 107 | 112 | } | |
| 108 | 113 | } | |
| 109 | 114 | } | |
| @@ -276,10 +281,10 @@ func TestHarnessFakeRunnerRoutesClientToPeerAndCleansEveryRole(t *testing.T) { | |||
| 276 | 281 | if _, err := h.runOne(options{out: dir, warmup: time.Second, duration: minimumDuration, repetitions: minimumRepetitions}, manifest{}, s, 1, raw); err != nil { | |
| 277 | 282 | t.Fatal(err) | |
| 278 | 283 | } | |
| 279 | - | if len(l.mappings) != 3 || l.mappings[1].Role != tc.peer || l.mappings[2].Role != "client" { | |
| 284 | + | if len(l.mappings) != 3 || l.mappings[0].Role != tc.peer || l.mappings[1].Role != "daemon" || l.mappings[2].Role != "client" { | |
| 280 | 285 | t.Fatalf("dependency launch order=%+v", l.mappings) | |
| 281 | 286 | } | |
| 282 | - | if l.commands[1].Args[0] != tc.peerCommand || l.commands[2].Args[0] != "attach" { | |
| 287 | + | if l.commands[0].Args[0] != tc.peerCommand || l.commands[2].Args[0] != "attach" { | |
| 283 | 288 | t.Fatalf("commands do not connect client through declared peer: %+v", l.commands) | |
| 284 | 289 | } | |
| 285 | 290 | for i, p := range l.process { | |
M docs/architecture.md +116 -96
| @@ -64,10 +64,10 @@ Every result retains the request ID for strict correlation. Version negotiation | |||
| 64 | 64 | composed only by the hidden offline broker entry points (`_broker-serve`, | |
| 65 | 65 | `_broker-launcher`, and `_broker-status`). | |
| 66 | 66 | - `internal/adapters/uidriver`: owns strict JSONL decoding, response serialization, bounded controller queues, private Unix sockets, peer credentials, and the stdio bridge. It consumes `ports.UIService` and never creates an attachment. | |
| 67 | - | - `internal/adapters/webterm`: owns the authenticated loopback HTTP/WebSocket frontend, browser event encoding, VT-backed terminal and transactional HTML output. It implements `ports.Terminal`; application composition supplies the ordinary client runner. | |
| 67 | + | - `internal/adapters/webterm`: owns the authenticated loopback HTTP/WebSocket frontend, browser event encoding, VT-backed terminal and transactional HTML output. It implements `ports.Terminal`; application composition supplies the ordinary client `Supervisor`. | |
| 68 | 68 | - `internal/adapters/uiterm`: owns the immutable VT mirror/snapshot and deterministic headless terminal. The concrete terminal writer (`adapters/term`) taps successful writes and flushes into this sink only when observation is enabled. | |
| 69 | 69 | - `internal/adapters`: IPC, QUIC, SSH stdio, PTY, terminal, VT-backed UI terminal/observation, JSONL UI-driver sockets, persistence-facing, broker-wire, and observability implementations. `streamframe` owns the shared 4-byte big-endian length framing of one complete Protobuf envelope used by every stream carriage. | |
| 70 | - | - `internal/app`: CLI parsing and composition. It selects local or remote carriage, wraps raw dialers with `sessionwire`, injects typed ports into use cases, and owns explicit UI-driver endpoint launch configuration. | |
| 70 | + | - `internal/app`: CLI parsing and composition. It selects local or remote carriage, wraps raw dialers with `sessionwire`, and injects typed ports into use cases. The UI driver keeps no endpoint authority of its own: it composes the process' production broker connector and delegates every transport, daemon, and session decision to the broker. | |
| 71 | 71 | - `pkg`: reusable packages that never import `internal`. | |
| 72 | 72 | ||
| 73 | 73 | ## Dependency direction | |
| @@ -86,16 +86,18 @@ main → app → usecase → ports | |||
| 86 | 86 | ||
| 87 | 87 | ## UI observation and control | |
| 88 | 88 | ||
| 89 | - | Headless UI-driver mode composes one normal `client.Runner`, one `uiterm` terminal, one `client.UI` service, and one bounded JSONL adapter. It does not bypass the palette, input scanners, foreground leases, navigation handoffs, output replay chain, or render publication fences. EOF cancels only that driver and detaches the attachment; it does not kill a session shell. | |
| 89 | + | Headless UI-driver mode composes one normal `client.Supervisor`, one `uiterm` terminal, one `client.UI` service, and one bounded JSONL adapter. The supervisor obtains snapshots and opens every attachment through the single `ports.BrokerService` façade; the client does not select a daemon carriage or construct a remote registry. It does not bypass the picker, input scanners, foreground leases, navigation handoffs, output replay chain, or render publication fences. EOF cancels only that driver and detaches the attachment; it does not kill a session shell. | |
| 90 | 90 | ||
| 91 | 91 | Interactive observation is a separate opt-in composition. `term.Terminal` remains the owner of raw mode, physical geometry, terminal queries, serialized output writes, and flush success. Its optional observation sink mirrors byte prefixes and successful flush boundaries into `uiterm`; UI transaction methods are delegated through a composition wrapper so semantic context is committed atomically with the physical flush. A private per-client Unix endpoint uses the same `ports.UIService`; `--ui-control` is the only mode that permits input operations. Slow or disconnected observers cannot claim geometry or block the physical terminal. | |
| 92 | 92 | ||
| 93 | 93 | ## Browser terminal | |
| 94 | 94 | ||
| 95 | - | `--web-daemon` launches a separate gateway with an inherited HTTP listener, loopback by default. App composition resolves `web.listen`/`web.origin` and CLI overrides before binding. The web adapter validates the configured browser-facing Host and Origin without trusting forwarded headers; a proxy owns TLS. The same-user local control socket returns the running gateway's settings and memory-only credential; readiness probes contact only its local listener. Each authenticated WebSocket owns a normal client runner and a `webterm.Terminal`. The adapter encodes browser events as terminal input, mirrors flushed client output through `vev-vt`, and sends transactional HTML updates. Disconnect cancels only that attachment. The multiplexer remains daemon-owned; browser input does not weaken the restricted UI-driver automation contract. | |
| 95 | + | `--web-daemon` launches a separate gateway with an inherited HTTP listener, loopback by default. App composition resolves `web.listen`/`web.origin` and CLI overrides before binding. The web adapter validates the configured browser-facing Host and Origin without trusting forwarded headers; a proxy owns TLS. The same-user local control socket returns the running gateway's settings and memory-only credential; readiness probes contact only its local listener. Each authenticated WebSocket owns a normal client `Supervisor` and a `webterm.Terminal`. The adapter encodes browser events as terminal input, mirrors flushed client output through `vev-vt`, and sends transactional HTML updates. Disconnect cancels only that attachment. The multiplexer remains daemon-owned; browser input does not weaken the restricted UI-driver automation contract. | |
| 96 | 96 | ||
| 97 | 97 | ## Client-owned picker presentation | |
| 98 | 98 | ||
| 99 | + | A client publishes exactly one of three presentations at a time. `picker` is the local picker: the client is usable and describable without a broker and without a daemon. `connecting` is an attachment attempt that has not committed its first frame. `attached` is a committed attachment, and only it carries the validated session identity, the committed output boundary, and the real actionable generation; `picker` and `connecting` carry the run's stable service handle and their status with every session field zeroed, so a capture can never present session metadata as an attachment and no caller can fence an input action against a generation no attachment committed. The former `detached`, `reconnecting`, and `transitioning` values are removed without alias: a reconnect is `connecting`, a detach returns to `picker`, and terminating closes the UI service instead of becoming a fourth persistent presentation. The presentation is published inside the terminal's UI output transaction by its owner: the supervisor publishes `connecting` before the attachment foreground is granted and `picker` after the run drained and revoked, while the admitted foreground publishes `attached` only after its first frame was written, flushed, and committed. | |
| 100 | + | ||
| 99 | 101 | Every attachment presents the navigation picker itself. The serving daemon publishes a typed interaction instead of installing an overlay: `PickerOffer` names the intent and the acquisition barrier, `PickerSnapshot` carries opaque row keys plus display text, `PickerSelection` commits a key at the displayed revision, `PickerClose` retires the interaction in either direction, and `PickerFailure` reports a bounded rejection. The daemon owns no presentation model: it keeps facts, eligibility, and mutations, and resolves committed keys to navigation, move, or kill targets. | |
| 100 | 102 | ||
| 101 | 103 | Source eligibility travels with every row. `PickerLine.Focusable` is the source's authorisation to rest the cursor on that row, while `PickerLine.Actions` is what the row may commit; an action row is always focusable, a section header never is, and a focusable row without actions is published for inspection only. The client's cursor visits focusable rows, narrowed by its own search query, so it never lands on a row the source skipped and the footer never promises a commit the cursor cannot make. | |
| @@ -116,74 +118,20 @@ A terminal resize reaches the client as one coalesced invalidation owned by the | |||
| 116 | 118 | ||
| 117 | 119 | Attention is a daemon fact, not an overlay: a bell raised by a PTY, by a status-line notification, or by an agent run marks the owning tab, and the daemon publishes it in the frames its attachments receive. The status bar carries it as a pulsing glyph, the picker session header carries it inline, and `PickerLine.Attention` carries it on the tab row the client renders. Because a client-owned modal suppresses daemon paints, the attention pulse republishes the picker source for every attachment whose interaction the client presents, so a bell raised while the picker is open still reaches the displayed rows. | |
| 118 | 120 | ||
| 119 | - | ## Remote hosts | |
| 120 | - | ||
| 121 | - | Remote discovery has two owners with separated writers. The daemon composes its own store, catalogue client, cache, and monitor for the structured rows and exact-target validation its picker serves. The launching client composes a runner-scoped `remotes.HostRegistry` over its own store, catalogue client, and a runner-local catalogue cache that seeds from the durable snapshot and never writes it, so one monitor can never truncate the other's file. The registry projects that discovery through `ports.RemoteDirectory` and resolves each endpoint once into a `ports.RemoteEndpointBinding` — the typed dialer every session on that endpoint attaches through, plus the environment to advertise — which the runner reuses for its initial remote attach and for every later handoff. Transport mode, launch allowlist, and endpoint environment stay in `internal/app` behind `ports.RemoteEndpointFactory`; the client never inspects them, and a resolution failure is never cached, so a refused endpoint fails the handoff instead of falling back to another resolver. | |
| 122 | - | ||
| 123 | - | Current state: the daemon-side monitor is deliberately still active. `Serve` | |
| 124 | - | starts the runner through `daemon.WithRemoteMonitor` and joins it inside the | |
| 125 | - | bounded remote-shutdown budget, and the daemon keeps its own remote directory | |
| 126 | - | and host registry. That daemon-owned monitoring (and the host registry that | |
| 127 | - | feeds it) belongs to the coordinated P7 removal set; it is not removed, | |
| 128 | - | gated, or polled differently before that cutover, and no polling gate is | |
| 129 | - | introduced here (GO-001, deferred to P7). | |
| 130 | - | ||
| 131 | - | ## Prepared local inventory boundary (Plan 001 P4.3, preparation only) | |
| 132 | - | ||
| 133 | - | The daemon separates local registry state from foreign remote-directory rows. | |
| 134 | - | `captureLocalSessionInventory` (returning `localSessionInventory`) copies live | |
| 135 | - | sessions and inactive records and never reads the remote directory; | |
| 136 | - | `captureSessionInventory` composes that local capture with the foreign rows | |
| 137 | - | from `currentRemoteDirectory`. The local catalogue export | |
| 138 | - | (`controlExec.RemoteCatalog`), the prepared navigation projection | |
| 139 | - | (`localNavigationInventorySnapshot` / `localInventoryGroup`), the prepared | |
| 140 | - | navigation resolver (`localNavigationResolve`), and the prepared picker | |
| 141 | - | projection (`localPickerViews` / `localPickerViewProjections`) read only the | |
| 142 | - | local capture, so they cannot observe or depend on remote monitoring. The | |
| 143 | - | prepared control resolver (`localPickerControlAttachTarget`) is not an | |
| 144 | - | inventory projection: it reads the live session registry directly under | |
| 145 | - | `d.mu` and never the remote directory, the `sessionInventory` composite, or a | |
| 146 | - | stopped record — only live registry sessions are eligible there. | |
| 147 | - | ||
| 148 | - | The current hybrid projections keep their foreign behavior by composing the | |
| 149 | - | prepared local output with the foreign rows: `snapshotNavigationInventory` | |
| 150 | - | appends `remoteInventoryGroup` per host, `pickerViewProjections` interleaves | |
| 151 | - | `foreignPickerViews` between the local live and stopped groups, and | |
| 152 | - | `pickerControlAttachTarget` delegates to `localPickerControlAttachTarget` (via | |
| 153 | - | `pickerControlAttachTargetLocal`) after its remote branch. Prepared local | |
| 154 | - | resolution is exact: a structured foreign source key, registration, remote | |
| 155 | - | target, or remote key is rejected before any lookup; broken and purging records | |
| 156 | - | are never resumable; a stopped target that becomes live with the same lifecycle | |
| 157 | - | resolves; and a stale lifecycle or a same-name replacement never resolves by | |
| 158 | - | name. The prepared local resolver also rejects a stale preferred tab, but the | |
| 159 | - | hybrid wrapper preserves the live behavior on top of it: when the exact local | |
| 160 | - | lifecycle is still current and only the tab vanished between snapshot and | |
| 161 | - | resolve, it retries with an empty `PreferredTabID`, matching the client's own | |
| 162 | - | missing-tab fallback instead of failing the attach. | |
| 163 | - | ||
| 164 | - | Current ownership: `internal/usecase/daemon` owns the local capture, the | |
| 165 | - | prepared projections and resolvers, and the hybrid wrappers; `internal/app` | |
| 166 | - | composes the daemon-side monitor; `internal/adapters/remote` plus the | |
| 167 | - | runner-scoped `remotes.HostRegistry` own discovery. This is a preparation pass: | |
| 168 | - | no production composition is cut over, no monitor is gated or removed, no | |
| 169 | - | public protocol, schema, or version changes, and the signed purge machinery is | |
| 170 | - | untouched. | |
| 171 | - | ||
| 172 | - | P7 removal set: `WithRemoteMonitor` and the `remoteDirectory` / | |
| 173 | - | `remoteMonitorRun` state; `remoteDirectorySnapshot` and `currentRemoteDirectory`; | |
| 174 | - | `watchRemoteDirectory`, `refreshRemoteDirectoryViews`, and | |
| 175 | - | `notifyNewRemoteFailures`; the foreign picker composition (`foreignPickerViews`, | |
| 176 | - | `remoteInventoryGroup` and the remote groups in `snapshotNavigationInventory`, | |
| 177 | - | the remote branch of `pickerControlAttachTarget`, `resolveRemoteInventoryEntry`); | |
| 178 | - | the hybrid wrappers themselves (`captureSessionInventory`, the | |
| 179 | - | `sessionInventory` composite, `pickerViewProjections`, `pickerControlAttachTarget` | |
| 180 | - | and its `pickerControlAttachTargetLocal` vanished-tab fallback) once the local | |
| 181 | - | projections read `localSessionInventory` directly; the remote picker, palette, | |
| 182 | - | preview, attach, and route projections that read the directory; and the | |
| 183 | - | daemon-side `ports.RemoteDirectory` / `ports.RemotePreviewClient` composition. | |
| 184 | - | Once those foreign inputs are deleted, the prepared local functions are the | |
| 185 | - | whole projection. The daemon-side monitor and host registry stay active until | |
| 186 | - | then (GO-001, deferred to P7). | |
| 121 | + | ## Broker-owned hosts and daemon composition | |
| 122 | + | ||
| 123 | + | `vev _broker-ready --require local-authority|local-catalogue` is an internal, | |
| 124 | + | dial-only machine probe. It connects directly to the selected broker IPC | |
| 125 | + | socket, registers, subscribes, and validates complete snapshots. It never | |
| 126 | + | ensures or spawns, opens a logical stream, reconciles, or mutates. An optional | |
| 127 | + | `--offline-root ABS` selects the offline layout. Output is exactly one bounded | |
| 128 | + | `vev.broker-ready/v1` JSON line with decimal-string epoch/revision IDs. Exit | |
| 129 | + | codes are 0 ready, 1 internal/write, 2 arguments, 3 timeout, 4 terminal | |
| 130 | + | security/protocol/configuration failure, and 5 cancellation. | |
| 131 | + | ||
| 132 | + | The broker is the sole façade for local and remote inventory, endpoint membership, policy, observation, and logical session streams. `client.Supervisor` consumes `ports.BrokerService`; it does not import or compose a remote registry, remote adapter, endpoint factory, or direct session dialer. Host mutations and attachment requests therefore pass through the same broker authority and committed snapshot. | |
| 133 | + | ||
| 134 | + | A daemon serves only the sessions on its own machine. It has no remote-directory monitor and does not compose remote transports. In production it accepts the broker's authenticated multiplexed physical carriage through `internal/adapters/daemonmux`; logical observation, control, and attachment streams share that carriage while retaining independent admission and lifetime. The daemon's Unix daemonmux listener is local-only composition infrastructure between the broker and daemon, not a public client endpoint. Ordinary clients connect to `broker.sock` and never dial a daemon socket directly. | |
| 187 | 135 | ||
| 188 | 136 | ## Session composition | |
| 189 | 137 | ||
| @@ -242,11 +190,20 @@ are not keys. Identity plus complete trust, launch, isolation, transport, | |||
| 242 | 190 | protocol, catalogue, and environment policy select one physical connection; | |
| 243 | 191 | authenticated identity and policy are checked again after connection. | |
| 244 | 192 | ||
| 245 | - | Clients receive pool-issued epoch-scoped connection IDs and supply strictly | |
| 246 | - | increasing stream IDs. Failed admitted opens consume their IDs. Active clients, | |
| 247 | - | physical keys, and pending/live streams have explicit caps; retired IDs need no | |
| 248 | - | unbounded tombstone collection. Coalesced connection attempts are independent | |
| 249 | - | of any single waiter and are canceled when their final reservation leaves. | |
| 193 | + | Clients receive pool-issued epoch-scoped connection IDs and allocate their own | |
| 194 | + | logical stream IDs through `BrokerService.NextStreamID`, which is | |
| 195 | + | thread-safe, strictly monotone, never zero, and refuses an exhausted counter | |
| 196 | + | instead of wrapping. An allocated ID is consumed even when the open it names is | |
| 197 | + | refused. Admission at every layer (client, listener, pool) uses one bounded | |
| 198 | + | anti-replay window of 1024 IDs instead of a bare monotone comparison, so two | |
| 199 | + | concurrent opens that are scheduled in either order are both admitted while a | |
| 200 | + | replayed or evicted ID is stale. Active clients, physical keys, and | |
| 201 | + | pending/live streams have explicit caps; retired IDs need no unbounded tombstone | |
| 202 | + | collection. Coalesced connection attempts are independent of any single waiter | |
| 203 | + | and are canceled when their final reservation leaves. Acquisition coalescing | |
| 204 | + | keys on the exact policy plus the explicit `BrokerDaemonStartMode`, so an | |
| 205 | + | existing-only request is never shared with a dial that may start the target; | |
| 206 | + | after authentication the canonical key is identity plus exact policy alone. | |
| 250 | 207 | Physical retirement keeps its key occupied until Close finishes. Fake-clock idle | |
| 251 | 208 | eviction and shutdown close transports outside the bookkeeping lock. | |
| 252 | 209 | ||
| @@ -305,7 +262,13 @@ a stopped one. | |||
| 305 | 262 | Attachment streams declare one closed admission variant | |
| 306 | 263 | (`ports.BrokerStreamAdmission`: exact attach/resume, named creation, ephemeral | |
| 307 | 264 | creation) validated identically at the ports, broker IPC, and daemonmux | |
| 308 | - | boundaries. | |
| 265 | + | boundaries. Every stream also declares one closed daemon-start authorization | |
| 266 | + | (`ports.BrokerDaemonStartMode`: `ExistingOnly` or `StartIfNeeded`), carried | |
| 267 | + | unchanged from the open request through resolution, the wire, and the mux Open: | |
| 268 | + | observation and daemon-stop are always `ExistingOnly` and never start a target, | |
| 269 | + | while attach/creation and the explicit list/mutate controls may be | |
| 270 | + | `StartIfNeeded`; the zero value is refused. The mode can only narrow the | |
| 271 | + | resolved launch policy, never widen it. | |
| 309 | 272 | ||
| 310 | 273 | ## Broker-owned local route (Plan 001 P5.3a, not activated) | |
| 311 | 274 | ||
| @@ -337,11 +300,55 @@ from the binding and never from durable membership, since the local daemon has | |||
| 337 | 300 | no membership; its observed identity, incarnation, protocol version, | |
| 338 | 301 | availability, and session catalogue come only from the local probe. The local | |
| 339 | 302 | entry is never durable: the snapshot store persists and reloads remote | |
| 340 | - | observations only. Observing the local daemon completes only the daemonmux | |
| 341 | - | physical preamble and closes the carriage, so it never opens a logical stream | |
| 342 | - | (never attaches) and only ever dials an existing local Unix socket, so it never | |
| 343 | - | starts a stopped daemon. Nothing in `internal/app` or `main` activates this | |
| 344 | - | composition for ordinary operation; P7 performs the coordinated cutover. | |
| 303 | + | observations only. Observing the local daemon dials only an existing local Unix | |
| 304 | + | socket, so it never starts a stopped daemon; it completes the authenticated | |
| 305 | + | daemonmux physical preamble, opens exactly one `BrokerStreamObservation` logical | |
| 306 | + | stream, sends one typed remote-catalog control request, validates the catalogue | |
| 307 | + | the daemon answers, and closes the stream and carriage. It sends no `Hello`, | |
| 308 | + | claims no geometry, requests no admission, creates no session, process, or | |
| 309 | + | durable identity, and never attaches. The remote host probe and the local probe | |
| 310 | + | share one semantic implementation, so the two cannot drift apart. Nothing in | |
| 311 | + | `internal/app` or `main` activates this composition for ordinary operation; P7 | |
| 312 | + | performs the coordinated cutover. | |
| 313 | + | ||
| 314 | + | ## Broker-owned daemon start (Plan 001 P7, UI-driver slice 1) | |
| 315 | + | ||
| 316 | + | The one place a resolved start authorization reaches transport mechanics is | |
| 317 | + | `internal/app/broker_daemon_start.go`. `daemonmux.RawCarrierDialer` receives the | |
| 318 | + | whole `ports.BrokerDialTarget` — address, fence, policy, `StartMode`, and | |
| 319 | + | expected identity — instead of a bare opaque address, so an authorization can | |
| 320 | + | never be flattened into an address or smuggled through a context value, and the | |
| 321 | + | carriage owner sees exactly the authority the connector validated and is about | |
| 322 | + | to verify in the physical preamble. `brokerMuxConnector` looks the route up by | |
| 323 | + | the target's opaque address, re-checks that the provisioned entry still matches | |
| 324 | + | the resolved fence and policy, and passes the whole target on. | |
| 325 | + | ||
| 326 | + | `dialBrokerLocalDaemon` dials one local daemon for one resolved target. | |
| 327 | + | `BrokerDaemonExistingOnly` is exactly one private daemonmux carriage dial: no | |
| 328 | + | lifecycle probe, no spawn lock, no process, and no fallback of any kind. | |
| 329 | + | `BrokerDaemonStartIfNeeded` performs the same dial first and returns the daemon | |
| 330 | + | it finds — an already running daemon is never restarted. Only a dial that proves | |
| 331 | + | absence (an unclassified failure, a cancellation, a foreign path, a rejected | |
| 332 | + | peer, and a permission refusal all report false) can reach the start, and then | |
| 333 | + | only when the resolved policy names the broker launch authority and the carriage | |
| 334 | + | is exactly the private endpoint the launched daemon will publish. A registered | |
| 335 | + | endpoint is never started by the local mechanics. The start itself is the shared | |
| 336 | + | election in `spawn.go` — lifecycle availability, one elected spawner under the | |
| 337 | + | spawn lock, then a bounded redial of the same carriage — so observation and | |
| 338 | + | daemon-stop stay `ExistingOnly` and a policy that forbids launching still | |
| 339 | + | refuses visibly. `spawn.go` is generic over the dialed value, so the session | |
| 340 | + | transport and the daemonmux carriage obey exactly one election discipline, and | |
| 341 | + | no path here ever dials a session or a session control stream to prove | |
| 342 | + | readiness. | |
| 343 | + | ||
| 344 | + | The remote-side mux helpers apply the same rule from their own broker-owned | |
| 345 | + | configuration. `dialBrokerRoute` propagates the target's `StartMode` before any | |
| 346 | + | connect-or-spawn, `sshMuxCommandSpec` appends the closed | |
| 347 | + | `--daemon-start existing-only|if-needed` word to the provisioned remote argv, | |
| 348 | + | and `parseBrokerMuxArgs` defaults an absent flag to the safe `existing-only` | |
| 349 | + | while refusing an unknown value. A helper starts only the daemon behind its own | |
| 350 | + | provisioned mux carriage, under its own policy, and never starts a broker, an | |
| 351 | + | observer, or a recursive helper. | |
| 345 | 352 | ||
| 346 | 353 | ## Broker local IPC (Plan 001 P3.3, not activated) | |
| 347 | 354 | ||
| @@ -351,9 +358,7 @@ per-user Unix endpoint. The carriage is the P3.2 private AF_UNIX carriage in | |||
| 351 | 358 | parent directory, a bound socket tightened to 0600, race-safe recovery of a | |
| 352 | 359 | stale socket whose owner died, refusal (never removal) of a path that is not a | |
| 353 | 360 | socket, and same-user kernel peer credentials (`SO_PEERCRED` on Linux) on both | |
| 354 | - | accept and dial, failing closed on a platform or build without that check. The | |
| 355 | - | endpoint name (`broker.sock`) is deliberately distinct from the daemon's | |
| 356 | - | `daemon.sock`. | |
| 361 | + | accept and dial, failing closed on a platform or build without that check. The endpoint name (`broker.sock`) is the client-facing local endpoint. The daemon's Unix daemonmux socket is local-only composition infrastructure owned by the broker/daemon path; it is not published as a client API and ordinary clients never dial it. | |
| 357 | 362 | ||
| 358 | 363 | The listener owns its accept loop: one client slot is acquired before each | |
| 359 | 364 | accept, each accepted carriage completes the broker preamble and broker | |
| @@ -528,13 +533,21 @@ bounded route address: a digest of the route's own contents, so the pooling | |||
| 528 | 533 | layer never carries a target, a path, or an argv word. | |
| 529 | 534 | ||
| 530 | 535 | `_broker-serve` composes a transport-selecting `daemonmux.EndpointConnector`. Its | |
| 531 | - | dial function receives only the opaque address, looks the route up in the | |
| 532 | - | immutable configuration it was built with, and refuses an address the | |
| 533 | - | configuration did not produce. A `unix` route dials the provisioned private Unix | |
| 534 | - | mux carriage directly; an `ssh-stdio` route starts one explicit ssh child (built | |
| 536 | + | dial function receives the whole resolved `ports.BrokerDialTarget`, looks the | |
| 537 | + | route up by its opaque address in the immutable configuration it was built | |
| 538 | + | with, refuses an address the configuration did not produce, and re-checks that | |
| 539 | + | the provisioned entry still matches the resolved fence and policy before | |
| 540 | + | dialing. The target travels whole so the closed daemon-start authorization | |
| 541 | + | reaches the transport owner instead of being flattened into an address. A | |
| 542 | + | `unix` route dials the provisioned private Unix | |
| 543 | + | mux carriage directly when it is a provisioned endpoint carriage, and goes | |
| 544 | + | through the broker-owned daemon start (below) when it is the broker's own local | |
| 545 | + | binding route; an `ssh-stdio` route starts one explicit ssh child (built | |
| 535 | 546 | with `sshstdio.BuildCommandForMux` and carrying only trust inputs that narrow | |
| 536 | 547 | verification, never disabling host-key checking, forcing an auth bypass, or | |
| 537 | - | requesting a PTY) through `sshstdio.DialMuxContext`; an `ssh-quic` route runs one | |
| 548 | + | requesting a PTY) through `sshstdio.DialMuxContext`, and appends the closed | |
| 549 | + | `--daemon-start existing-only|if-needed` word to the provisioned remote argv; an | |
| 550 | + | `ssh-quic` route runs one | |
| 538 | 551 | SSH-authenticated QUIC bootstrap, reads exactly one readiness line, and pins the | |
| 539 | 552 | freshly minted ephemeral certificate through `quic.DialMuxContext`. One | |
| 540 | 553 | caller-supplied setup context bounds bootstrap, pinned dial, and the daemonmux | |
| @@ -551,15 +564,22 @@ and sanitized, so a token, a nonce, or raw remote text never reaches an address, | |||
| 551 | 564 | a log, or an error. | |
| 552 | 565 | ||
| 553 | 566 | The three hidden helpers are excluded from public help. `_broker-mux-stdio | |
| 554 | - | --offline-root ABS` validates the sandbox and bridges its own stdio to the single | |
| 555 | - | provisioned private Unix daemonmux carriage. `_broker-mux-quic-bootstrap | |
| 567 | + | --offline-root ABS [--daemon-start existing-only|if-needed]` validates the | |
| 568 | + | sandbox and bridges its own stdio to the single | |
| 569 | + | provisioned private Unix daemonmux carriage, applying exactly the start | |
| 570 | + | authorization the broker propagated: an absent flag defaults to the safe | |
| 571 | + | `existing-only`, an unknown value is refused, and a `start-if-needed` request is | |
| 572 | + | gated by the helper's own provisioned identity and policy, which can only narrow | |
| 573 | + | it. `_broker-mux-quic-bootstrap | |
| 556 | 574 | --offline-root ABS` validates the sandbox, starts one detached | |
| 557 | 575 | `_broker-mux-quic-proxy` in a new session, forwards its single readiness line, | |
| 558 | 576 | and exits. `_broker-mux-quic-proxy --offline-root ABS` mints one ephemeral QUIC | |
| 559 | 577 | server, admits exactly one authenticated carriage, and bridges it to the same | |
| 560 | - | private Unix daemonmux carriage. A helper only ever bridges to the configured | |
| 561 | - | private endpoint: it never starts a broker, an observer, or an ordinary daemon, | |
| 562 | - | never dials the production daemon socket, and never fabricates a daemon | |
| 578 | + | private Unix daemonmux carriage under the same propagated authorization. A | |
| 579 | + | helper only ever bridges to the configured | |
| 580 | + | private endpoint: it never starts a broker, an observer, or an ordinary daemon | |
| 581 | + | outside that one daemon-start authorization, never dials the production daemon | |
| 582 | + | socket, and never fabricates a daemon | |
| 563 | 583 | incarnation, which the daemon on the far side of the carriage alone owns. | |
| 564 | 584 | Ordinary production composition still waits for P7; the helpers change no | |
| 565 | 585 | ordinary command, path, or factory. | |
M docs/configuration.md +0 -39
| @@ -7,13 +7,6 @@ vev reads `~/.config/vev/config` (`$XDG_CONFIG_HOME` respected). No file means d | |||
| 7 | 7 | web.listen = 127.0.0.1:8778 | |
| 8 | 8 | web.origin = http://127.0.0.1:8778 | |
| 9 | 9 | ||
| 10 | - | # Client startup setting: retain suspended remote attachments. Reuse is on by | |
| 11 | - | # default; off disables it. Capacity bounds dormant destinations (LRU). An | |
| 12 | - | # optional positive idle timeout expires dormant entries; off has no age bound. | |
| 13 | - | remote.attachment-cache = on | |
| 14 | - | remote.attachment-cache-capacity = 8 | |
| 15 | - | remote.attachment-cache-idle-timeout = off | |
| 16 | - | ||
| 17 | 10 | # Theme: auto follows the client; dark/light use neutral built-in defaults. | |
| 18 | 11 | theme = auto | |
| 19 | 12 | # In auto mode with palette inheritance enabled, infer a terminal accent. | |
| @@ -95,38 +88,6 @@ code.detach = DET | |||
| 95 | 88 | ||
| 96 | 89 | Invalid values log a warning and resolve that setting to its default on both initial load and reload. | |
| 97 | 90 | ||
| 98 | - | ## Remote attachment cache | |
| 99 | - | ||
| 100 | - | These keys are read when a terminal client starts, not hot reloaded. | |
| 101 | - | `remote.attachment-cache` is `on` (default) or `off`. With reuse enabled the | |
| 102 | - | runner retains at most `remote.attachment-cache-capacity` dormant destinations | |
| 103 | - | (default `8`), evicting the least-recently used one when a new suspension would | |
| 104 | - | exceed capacity. `remote.attachment-cache-idle-timeout` is `off` (default, no | |
| 105 | - | age bound) or a positive Go duration such as `30s` or `5m`; a positive value | |
| 106 | - | expires a dormant entry that long after it was suspended. | |
| 107 | - | ||
| 108 | - | Navigating away suspends the remote attachment. Returning before eviction or | |
| 109 | - | expiry reuses its authenticated connection without another bootstrap or dial. | |
| 110 | - | Dormant attachments have no terminal input, output, or geometry authority. | |
| 111 | - | Expiry, remote closure, or rejected activation evicts the connection; selecting | |
| 112 | - | that endpoint then uses normal reconnect/attach behavior. Independently of the | |
| 113 | - | client policy, the daemon force-retires an attachment suspended for 24 hours as | |
| 114 | - | a safety bound. Endpoint aliases remain distinct, even if they resolve to the | |
| 115 | - | same server. Exiting the client closes its retained connections. Heartbeat or | |
| 116 | - | liveness traffic never refreshes | |
| 117 | - | activity: a successful use followed by suspension marks the entry most | |
| 118 | - | recently used, and only user navigation changes recency. | |
| 119 | - | ||
| 120 | - | Retention is bounded by the LRU capacity and, when configured, the idle | |
| 121 | - | timeout, so visiting many distinct endpoints cannot retain more than the | |
| 122 | - | capacity. Each dormant entry retains a transport, a reader, and daemon | |
| 123 | - | attachment state; SSH stdio can also retain an SSH process. The client inbox is | |
| 124 | - | bounded and dormant output is discarded. Warm reactivation has a short | |
| 125 | - | client-local budget: a stalled retained transport is logically relinquished at | |
| 126 | - | that deadline and the client cold-attaches immediately while the old transport | |
| 127 | - | retires in the background. Use `remote.attachment-cache = off` when no | |
| 128 | - | retention is wanted. | |
| 129 | - | ||
| 130 | 91 | ## Scrollback | |
| 131 | 92 | ||
| 132 | 93 | `vev` chooses the retention policy; `vev-vt` enforces it. The PTY transports | |
M docs/ui-driver.md +63 -44
| @@ -4,18 +4,66 @@ | |||
| 4 | 4 | ||
| 5 | 5 | ## Headless attachment | |
| 6 | 6 | ||
| 7 | - | Start a new numbered session with the default 80x24 viewport: | |
| 7 | + | Start a new numbered ephemeral session with the default 80x24 viewport: | |
| 8 | 8 | ||
| 9 | 9 | ```sh | |
| 10 | 10 | vev --ui-driver | |
| 11 | 11 | ``` | |
| 12 | 12 | ||
| 13 | - | The first line on stdout is the attachment discovery response. It is emitted only after the client has applied and published its initial full view: | |
| 13 | + | `--ui-driver` uses the per-user broker of the process' own XDG context. It never | |
| 14 | + | provisions, reconfigures, or destroys a daemon, a session-scoped root, or an | |
| 15 | + | endpoint: creation is expressed as one initial navigation through the broker, so | |
| 16 | + | two drivers can share one per-user broker without either owning it. | |
| 17 | + | ||
| 18 | + | The closed option set is: | |
| 19 | + | ||
| 20 | + | - `--session NAME` creates that named local session instead of an ephemeral one. | |
| 21 | + | - `--remote ENDPOINT` targets that configured remote endpoint: with `--session` | |
| 22 | + | it resolves that exact remote session, without it it creates a remote ephemeral | |
| 23 | + | session. Every remote identity comes from the broker's committed publication, | |
| 24 | + | never from a hostname or an environment value. | |
| 25 | + | - `--cols N --rows N` set the viewport, at most 512x256. | |
| 26 | + | - `--picker` starts with no initial navigation at all and is exclusive of | |
| 27 | + | `--session` and `--remote`. | |
| 28 | + | - `--socket ABS` is the JSONL bridge to an already running opted-in client, and | |
| 29 | + | is exclusive of every headless option. | |
| 30 | + | ||
| 31 | + | `--launch-config` no longer exists. It is an unknown option and is refused with | |
| 32 | + | the usage exit code before any terminal, broker, or filesystem interaction; a | |
| 33 | + | driver that needs an isolated endpoint provisions it through the broker's own | |
| 34 | + | configuration instead. | |
| 35 | + | ||
| 36 | + | The first line on stdout is the discovery response. It is emitted as soon as the | |
| 37 | + | client has published its initial state, which does not require a broker or a | |
| 38 | + | daemon: | |
| 14 | 39 | ||
| 15 | 40 | ```json | |
| 16 | - | {"version":1,"id":0,"result":{"attachment":"<opaque-handle>","generation":1,"control":true,"status":"attached"}} | |
| 41 | + | {"version":1,"id":0,"result":{"attachment":"<opaque-handle>","generation":0,"control":true,"status":"picker"}} | |
| 17 | 42 | ``` | |
| 18 | 43 | ||
| 44 | + | `status` is a closed union of exactly three presentations: | |
| 45 | + | ||
| 46 | + | | `status` | meaning | `generation` | session metadata in `capture`/`wait` | `keys`/`text` | | |
| 47 | + | |---|---|---:|---|---| | |
| 48 | + | | `picker` | the local picker; ready without a broker and without a daemon | `0` | all zero | refused (`unavailable`) | | |
| 49 | + | | `connecting` | an attachment attempt that has not committed its first frame | `0` | all zero | refused (`unavailable`) | | |
| 50 | + | | `attached` | a committed attachment | nonzero real action generation | the validated session identity and committed boundary | admitted under the current binding | | |
| 51 | + | ||
| 52 | + | The former `detached`, `reconnecting`, and `transitioning` states no longer | |
| 53 | + | exist and are not aliases of these three: a reconnect is `connecting`, a detach | |
| 54 | + | is `picker`, and terminating closes the service instead of becoming a fourth | |
| 55 | + | persistent presentation. A driver that never publishes anything is a real local | |
| 56 | + | error; a destination that is refused, times out, or is lost leaves a published | |
| 57 | + | `picker` and the same JSONL service running. | |
| 58 | + | ||
| 59 | + | The `attachment` handle is the UI service of this run, never a session identity, | |
| 60 | + | and it is stable for the whole run. `generation` is zero until a committed | |
| 61 | + | attachment publishes one; a client must wait for `status` to become `attached` | |
| 62 | + | and use the generation that publication reports, and never assume | |
| 63 | + | `generation == 1`. A broker that is absent or incompatible is not fatal: the | |
| 64 | + | driver stays in the picker with a bounded notice and keeps answering `capture` | |
| 65 | + | and `wait`. | |
| 66 | + | ||
| 19 | 67 | The stream then accepts one JSON object per line. Every request has `version`, a nonzero `id`, `op`, and the discovered `attachment`: | |
| 20 | 68 | ||
| 21 | 69 | ```json | |
| @@ -52,7 +100,7 @@ A request timeout only ends that request. If input was already accepted, the res | |||
| 52 | 100 | {"version":1,"id":6,"op":"wait","attachment":"<opaque-handle>","after_action":4,"timeout_ms":5000,"expect":{"text_contains":"driver ok","status":"attached"}} | |
| 53 | 101 | ``` | |
| 54 | 102 | ||
| 55 | - | Supported predicates are literal `text_contains`, an exact `session` (`lifecycle_id` and `session_name`), an exact `focus` (`tab_id` and `pane_id`), and `status` (`attached`, `transitioning`, `reconnecting`, or `detached`). Without `after_action`, a wait may match the current published snapshot. With it, only the action's confirmed publication boundary and later snapshots are eligible. This is a postcondition check, not a claim that the action alone caused the text. | |
| 103 | + | Supported predicates are literal `text_contains`, an exact `session` (`lifecycle_id` and `session_name`), an exact `focus` (`tab_id` and `pane_id`), and `status` (`picker`, `connecting`, or `attached`). Without `after_action`, a wait may match the current published snapshot. With it, only the action's confirmed publication boundary and later snapshots are eligible. This is a postcondition check, not a claim that the action alone caused the text. | |
| 56 | 104 | ||
| 57 | 105 | ## Limits and errors | |
| 58 | 106 | ||
| @@ -66,49 +114,20 @@ Supported predicates are literal `text_contains`, an exact `session` (`lifecycle | |||
| 66 | 114 | ||
| 67 | 115 | Errors are structured and sanitized. Stable codes include `invalid_request`, `unsupported_version`, `permission_denied`, `stale_attachment`, `unavailable`, `timeout`, `outcome_unknown`, `capture_too_large`, `action_expired`, `input_busy`, `busy`, `navigation_failed`, and `endpoint_not_configured`. Error messages contain only the code; screen contents, credentials, endpoint internals, and environment values are not logged or returned. | |
| 68 | 116 | ||
| 69 | - | EOF detaches this driver attachment and closes its access stream. It does not kill shells or sessions on an existing endpoint. For an explicit launch configuration, the app performs a separate owned-endpoint teardown after driver access closes; it stops and removes only roots this invocation created. | |
| 70 | - | ||
| 71 | - | ## Explicit launch configuration | |
| 72 | - | ||
| 73 | - | Without `--launch-config`, normal local daemon startup and inherited client environment policy are unchanged. The option is the only way to request an explicit isolated endpoint: | |
| 117 | + | EOF detaches this driver attachment and closes its access stream. It does not kill shells or sessions, does not stop the shared broker, and does not remove any root: the broker, its daemon, and every session stay alive for the other clients, and the pool's own idle shutdown remains the only thing that ends the daemon. | |
| 74 | 118 | ||
| 75 | - | ```sh | |
| 76 | - | vev --ui-driver --session test --cols 100 --rows 30 \ | |
| 77 | - | --launch-config /absolute/path/launch.json | |
| 78 | - | ``` | |
| 79 | - | ||
| 80 | - | The configuration file must be owned by the current user, mode `0600`, valid UTF-8 JSON, and at most 64 KiB: | |
| 81 | - | ||
| 82 | - | ```json | |
| 83 | - | { | |
| 84 | - | "version": 1, | |
| 85 | - | "local": { | |
| 86 | - | "binary": "/absolute/path/vev", | |
| 87 | - | "root": "/absolute/path/new-local-root", | |
| 88 | - | "env": { | |
| 89 | - | "HOME": "/home/test", | |
| 90 | - | "PATH": "/usr/bin:/bin", | |
| 91 | - | "SHELL": "/bin/sh" | |
| 92 | - | } | |
| 93 | - | }, | |
| 94 | - | "remotes": [ | |
| 95 | - | { | |
| 96 | - | "endpoint": "user@example.com", | |
| 97 | - | "binary": "/absolute/path/vev", | |
| 98 | - | "root": "/absolute/path/new-remote-root", | |
| 99 | - | "env": { | |
| 100 | - | "HOME": "/home/test", | |
| 101 | - | "PATH": "/usr/bin:/bin", | |
| 102 | - | "SHELL": "/bin/sh" | |
| 103 | - | } | |
| 104 | - | } | |
| 105 | - | ] | |
| 106 | - | } | |
| 107 | - | ``` | |
| 119 | + | ## Isolation | |
| 108 | 120 | ||
| 109 | - | `local` may be omitted only for a direct `--remote` start. `remotes` defaults to an empty list. Binary and root paths must be absolute. Environment maps are complete child environments, not merges with the invoking process. `XDG_CONFIG_HOME`, `XDG_STATE_HOME`, `XDG_RUNTIME_DIR`, `VEV_ENV`, and `VEV_ENV_ROOT` are reserved and supplied by vev; they cannot appear in the file. Values are passed as argument data through the existing process/SSH builders, never by shell-concatenating untrusted strings. | |
| 121 | + | The driver owns no endpoint root, no daemon, and no process. An isolated endpoint | |
| 122 | + | is provisioned through the broker's own configuration before the driver starts, | |
| 123 | + | exactly like any other broker route: the broker is the single authority over | |
| 124 | + | binary, root, environment, trust, and launch policy, and those values are never a | |
| 125 | + | payload of a JSONL request, a selected hostname, or a client environment variable. | |
| 126 | + | There is no per-invocation root creation, no owner token, and no remote cleanup | |
| 127 | + | step at driver exit. | |
| 110 | 128 | ||
| 111 | - | A configured root must not exist before the invocation creates it. Local roots and remotely created roots use private derived config, state, runtime, and temporary directories; a remote root is reused only by later connections carrying the same invocation-owned token. A launch-config invocation refuses to fall back to an unlisted remote endpoint, including during catalogue and preview discovery. Directory isolation is not a process, filesystem, or network sandbox; environment files and binaries remain sensitive. | |
| 129 | + | Directory isolation is not a process, filesystem, or network sandbox; environment | |
| 130 | + | files and binaries remain sensitive. | |
| 112 | 131 | ||
| 113 | 132 | ## Existing interactive clients | |
| 114 | 133 | ||
M internal/adapters/brokerconfig/config.go +34 -3
| @@ -294,6 +294,31 @@ func Load(layout Layout) (*Config, error) { | |||
| 294 | 294 | // LoadPath parses the broker configuration at an explicit application-owned | |
| 295 | 295 | // path while retaining the route and reserved-path validation from layout. | |
| 296 | 296 | func LoadPath(layout Layout, path string) (*Config, error) { | |
| 297 | + | return loadPath(layout, path, nil) | |
| 298 | + | } | |
| 299 | + | ||
| 300 | + | // LoadProduction reads broker.json while deriving local daemon authority from | |
| 301 | + | // daemon-owned state. The production file must not carry a local identity. | |
| 302 | + | func LoadProduction(layout Layout, path string, identity ports.BrokerDaemonIdentity, policy ports.BrokerPolicy, socketPath string) (*Config, error) { | |
| 303 | + | // An absent identity is valid before the local daemon's first start. A | |
| 304 | + | // present identity remains strict and becomes the expected handshake fence. | |
| 305 | + | if identity != "" { | |
| 306 | + | if err := identity.Validate(); err != nil { | |
| 307 | + | return nil, err | |
| 308 | + | } | |
| 309 | + | } | |
| 310 | + | if err := policy.Validate(); err != nil { | |
| 311 | + | return nil, err | |
| 312 | + | } | |
| 313 | + | route, err := newUnixRoute(socketPath) | |
| 314 | + | if err != nil { | |
| 315 | + | return nil, err | |
| 316 | + | } | |
| 317 | + | local := &LocalBinding{Identity: identity, DisplayOrigin: LocalDisplayOrigin, Policy: policy, Route: route} | |
| 318 | + | return loadPath(layout, path, local) | |
| 319 | + | } | |
| 320 | + | ||
| 321 | + | func loadPath(layout Layout, path string, authoritativeLocal *LocalBinding) (*Config, error) { | |
| 297 | 322 | raw, err := readConfig(path) | |
| 298 | 323 | if err != nil { | |
| 299 | 324 | return nil, err | |
| @@ -351,9 +376,15 @@ func LoadPath(layout Layout, path string) (*Config, error) { | |||
| 351 | 376 | } | |
| 352 | 377 | config.order = append(config.order, registration.Registration.Endpoint) | |
| 353 | 378 | } | |
| 354 | - | local, err := parseLocal(document.Local) | |
| 355 | - | if err != nil { | |
| 356 | - | return nil, fmt.Errorf("brokerconfig: %s: local: %w", ConfigFileName, err) | |
| 379 | + | if authoritativeLocal != nil && document.Local != nil { | |
| 380 | + | return nil, fmt.Errorf("brokerconfig: %s: production local authority must not be configured", ConfigFileName) | |
| 381 | + | } | |
| 382 | + | local := authoritativeLocal | |
| 383 | + | if local == nil { | |
| 384 | + | local, err = parseLocal(document.Local) | |
| 385 | + | if err != nil { | |
| 386 | + | return nil, fmt.Errorf("brokerconfig: %s: local: %w", ConfigFileName, err) | |
| 387 | + | } | |
| 357 | 388 | } | |
| 358 | 389 | if local != nil { | |
| 359 | 390 | if err := rejectRouteOverlap(local.Route.Path(), layout.reserved); err != nil { | |
M internal/adapters/brokerconfig/config_test.go +13 -8
| @@ -120,15 +120,17 @@ func TestLoadValidConfig(t *testing.T) { | |||
| 120 | 120 | require.NoError(t, err) | |
| 121 | 121 | require.Equal(t, []string{testEndpoint}, config.Endpoints()) | |
| 122 | 122 | ||
| 123 | - | resolved, err := config.Resolver().Resolve(context.Background(), ports.BrokerOpenStreamRequest{ | |
| 123 | + | resolved, err := config.Resolver().ResolveDialTarget(context.Background(), ports.BrokerOpenStreamRequest{ | |
| 124 | 124 | Purpose: ports.BrokerStreamControl, | |
| 125 | 125 | Endpoint: testEndpoint, | |
| 126 | 126 | Registration: testRegistration(), | |
| 127 | 127 | Policy: testPolicy(), | |
| 128 | + | StartMode: ports.BrokerDaemonExistingOnly, | |
| 128 | 129 | }) | |
| 129 | 130 | require.NoError(t, err) | |
| 130 | - | require.Equal(t, ports.BrokerDaemonIdentity("offline-daemon"), resolved.Identity) | |
| 131 | + | require.Equal(t, ports.BrokerDaemonIdentity("offline-daemon"), resolved.ExpectedIdentity.Identity) | |
| 131 | 132 | require.Equal(t, testPolicy(), resolved.Policy) | |
| 133 | + | require.Equal(t, ports.BrokerDaemonExistingOnly, resolved.StartMode, "resolution propagates the requested start mode") | |
| 132 | 134 | ||
| 133 | 135 | local, err := config.LocalMuxRoute() | |
| 134 | 136 | require.NoError(t, err) | |
| @@ -484,14 +486,16 @@ func TestLoadAliasesShareOneRoute(t *testing.T) { | |||
| 484 | 486 | for _, endpoint := range []string{first, second} { | |
| 485 | 487 | registration := testRegistration() | |
| 486 | 488 | registration.Endpoint = endpoint | |
| 487 | - | resolved, err := config.Resolver().Resolve(context.Background(), ports.BrokerOpenStreamRequest{ | |
| 489 | + | resolved, err := config.Resolver().ResolveDialTarget(context.Background(), ports.BrokerOpenStreamRequest{ | |
| 488 | 490 | Purpose: ports.BrokerStreamControl, | |
| 489 | 491 | Endpoint: endpoint, | |
| 490 | 492 | Registration: registration, | |
| 491 | 493 | Policy: testPolicy(), | |
| 494 | + | StartMode: ports.BrokerDaemonStartIfNeeded, | |
| 492 | 495 | }) | |
| 493 | 496 | require.NoError(t, err) | |
| 494 | 497 | require.Equal(t, local.Address(), resolved.Address) | |
| 498 | + | require.Equal(t, ports.BrokerDaemonStartIfNeeded, resolved.StartMode) | |
| 495 | 499 | } | |
| 496 | 500 | } | |
| 497 | 501 | ||
| @@ -535,12 +539,13 @@ func TestResolverRefusals(t *testing.T) { | |||
| 535 | 539 | Endpoint: testEndpoint, | |
| 536 | 540 | Registration: testRegistration(), | |
| 537 | 541 | Policy: testPolicy(), | |
| 542 | + | StartMode: ports.BrokerDaemonExistingOnly, | |
| 538 | 543 | } | |
| 539 | 544 | ||
| 540 | 545 | t.Run("unknown endpoint", func(t *testing.T) { | |
| 541 | 546 | request := base | |
| 542 | 547 | request.Endpoint = "other@daemon" | |
| 543 | - | _, err := resolver.Resolve(context.Background(), request) | |
| 548 | + | _, err := resolver.ResolveDialTarget(context.Background(), request) | |
| 544 | 549 | var typed ports.BrokerError | |
| 545 | 550 | require.ErrorAs(t, err, &typed) | |
| 546 | 551 | require.Equal(t, ports.BrokerErrorUnavailable, typed.Code) | |
| @@ -549,7 +554,7 @@ func TestResolverRefusals(t *testing.T) { | |||
| 549 | 554 | t.Run("local request", func(t *testing.T) { | |
| 550 | 555 | request := base | |
| 551 | 556 | request.Local = true | |
| 552 | - | _, err := resolver.Resolve(context.Background(), request) | |
| 557 | + | _, err := resolver.ResolveDialTarget(context.Background(), request) | |
| 553 | 558 | var typed ports.BrokerError | |
| 554 | 559 | require.ErrorAs(t, err, &typed) | |
| 555 | 560 | require.Equal(t, ports.BrokerErrorUnavailable, typed.Code) | |
| @@ -558,7 +563,7 @@ func TestResolverRefusals(t *testing.T) { | |||
| 558 | 563 | t.Run("stale registration", func(t *testing.T) { | |
| 559 | 564 | request := base | |
| 560 | 565 | request.Registration.Generation = 2 | |
| 561 | - | _, err := resolver.Resolve(context.Background(), request) | |
| 566 | + | _, err := resolver.ResolveDialTarget(context.Background(), request) | |
| 562 | 567 | var typed ports.BrokerError | |
| 563 | 568 | require.ErrorAs(t, err, &typed) | |
| 564 | 569 | require.Equal(t, ports.BrokerErrorUnavailable, typed.Code) | |
| @@ -567,7 +572,7 @@ func TestResolverRefusals(t *testing.T) { | |||
| 567 | 572 | t.Run("conflicting policy", func(t *testing.T) { | |
| 568 | 573 | request := base | |
| 569 | 574 | request.Policy.Transport = "other-transport" | |
| 570 | - | _, err := resolver.Resolve(context.Background(), request) | |
| 575 | + | _, err := resolver.ResolveDialTarget(context.Background(), request) | |
| 571 | 576 | var typed ports.BrokerError | |
| 572 | 577 | require.ErrorAs(t, err, &typed) | |
| 573 | 578 | require.Equal(t, ports.BrokerErrorConflictingPolicy, typed.Code) | |
| @@ -575,7 +580,7 @@ func TestResolverRefusals(t *testing.T) { | |||
| 575 | 580 | t.Run("cancelled context", func(t *testing.T) { | |
| 576 | 581 | ctx, cancel := context.WithCancel(context.Background()) | |
| 577 | 582 | cancel() | |
| 578 | - | _, err := resolver.Resolve(ctx, base) | |
| 583 | + | _, err := resolver.ResolveDialTarget(ctx, base) | |
| 579 | 584 | require.ErrorIs(t, err, context.Canceled) | |
| 580 | 585 | }) | |
| 581 | 586 | } | |
M internal/adapters/brokerconfig/local_test.go +8 -7
| @@ -57,9 +57,10 @@ func loadDocument(t *testing.T, document any, reserved []string) (*Config, error | |||
| 57 | 57 | // localRequest builds one local open-stream request under the fixture policy. | |
| 58 | 58 | func localRequest() ports.BrokerOpenStreamRequest { | |
| 59 | 59 | return ports.BrokerOpenStreamRequest{ | |
| 60 | - | Purpose: ports.BrokerStreamControl, | |
| 61 | - | Local: true, | |
| 62 | - | Policy: testPolicy(), | |
| 60 | + | Purpose: ports.BrokerStreamControl, | |
| 61 | + | Local: true, | |
| 62 | + | Policy: testPolicy(), | |
| 63 | + | StartMode: ports.BrokerDaemonStartIfNeeded, | |
| 63 | 64 | } | |
| 64 | 65 | } | |
| 65 | 66 | ||
| @@ -78,9 +79,9 @@ func TestLoadLocalBinding(t *testing.T) { | |||
| 78 | 79 | require.True(t, binding.Route.IsLocal()) | |
| 79 | 80 | require.Equal(t, localTestSocket, binding.Route.Path()) | |
| 80 | 81 | ||
| 81 | - | resolved, err := config.Resolver().Resolve(context.Background(), localRequest()) | |
| 82 | + | resolved, err := config.Resolver().ResolveDialTarget(context.Background(), localRequest()) | |
| 82 | 83 | require.NoError(t, err) | |
| 83 | - | require.Equal(t, binding.Identity, resolved.Identity) | |
| 84 | + | require.Equal(t, binding.Identity, resolved.ExpectedIdentity.Identity) | |
| 84 | 85 | require.Equal(t, binding.Policy, resolved.Policy) | |
| 85 | 86 | require.Equal(t, binding.Route.Address(), resolved.Address) | |
| 86 | 87 | ||
| @@ -276,10 +277,10 @@ func TestResolveLocalSuccessAndRefusals(t *testing.T) { | |||
| 276 | 277 | cancel() | |
| 277 | 278 | ctx = cancelled | |
| 278 | 279 | } | |
| 279 | - | resolved, err := tt.resolver.Resolve(ctx, tt.request) | |
| 280 | + | resolved, err := tt.resolver.ResolveDialTarget(ctx, tt.request) | |
| 280 | 281 | if tt.wantErr == nil { | |
| 281 | 282 | require.NoError(t, err) | |
| 282 | - | require.Equal(t, binding.Identity, resolved.Identity) | |
| 283 | + | require.Equal(t, binding.Identity, resolved.ExpectedIdentity.Identity) | |
| 283 | 284 | require.Equal(t, binding.Policy, resolved.Policy) | |
| 284 | 285 | require.Equal(t, binding.Route.Address(), resolved.Address) | |
| 285 | 286 | return | |
M internal/adapters/brokerconfig/resolver.go +22 -14
| @@ -7,7 +7,7 @@ import ( | |||
| 7 | 7 | "github.com/bnema/vev/internal/ports" | |
| 8 | 8 | ) | |
| 9 | 9 | ||
| 10 | - | // Resolver is the immutable ports.BrokerEndpointResolver over one parsed | |
| 10 | + | // Resolver is the immutable ports.BrokerRouteAuthority over one parsed | |
| 11 | 11 | // offline configuration. It holds no mutable state and performs no I/O: every | |
| 12 | 12 | // Resolve call fences the request against the provisioned registration and | |
| 13 | 13 | // returns the configured identity, policy, and opaque route address. A request | |
| @@ -24,7 +24,7 @@ type Resolver struct { | |||
| 24 | 24 | local *LocalBinding | |
| 25 | 25 | } | |
| 26 | 26 | ||
| 27 | - | var _ ports.BrokerEndpointResolver = (*Resolver)(nil) | |
| 27 | + | var _ ports.BrokerRouteAuthority = (*Resolver)(nil) | |
| 28 | 28 | ||
| 29 | 29 | // Resolve fences one open-stream request against the immutable configuration. | |
| 30 | 30 | // | |
| @@ -40,26 +40,30 @@ var _ ports.BrokerEndpointResolver = (*Resolver)(nil) | |||
| 40 | 40 | // Unknown, stale, conflicting, or unprovisioned requests are refused with a | |
| 41 | 41 | // typed broker error and no dial is attempted; a cancelled context is reported | |
| 42 | 42 | // as such so the pool classifies it as cancellation rather than unavailability. | |
| 43 | - | func (r *Resolver) Resolve(ctx context.Context, request ports.BrokerOpenStreamRequest) (ports.BrokerResolvedEndpoint, error) { | |
| 43 | + | func (r *Resolver) ResolveDialTarget(ctx context.Context, request ports.BrokerOpenStreamRequest) (ports.BrokerDialTarget, error) { | |
| 44 | 44 | if err := ctx.Err(); err != nil { | |
| 45 | - | return ports.BrokerResolvedEndpoint{}, err | |
| 45 | + | return ports.BrokerDialTarget{}, err | |
| 46 | 46 | } | |
| 47 | 47 | if request.Local { | |
| 48 | 48 | return r.resolveLocal(request) | |
| 49 | 49 | } | |
| 50 | 50 | provisioned, ok := r.byEndpoint[request.Endpoint] | |
| 51 | 51 | if !ok { | |
| 52 | - | return ports.BrokerResolvedEndpoint{}, ports.BrokerError{Code: ports.BrokerErrorUnavailable, Cause: errUnknownEndpoint} | |
| 52 | + | return ports.BrokerDialTarget{}, ports.BrokerError{Code: ports.BrokerErrorUnavailable, Text: "unknown endpoint", Cause: errUnknownEndpoint} | |
| 53 | 53 | } | |
| 54 | 54 | if !request.Registration.Equal(provisioned.Registration) { | |
| 55 | - | return ports.BrokerResolvedEndpoint{}, ports.BrokerError{Code: ports.BrokerErrorUnavailable, Cause: errStaleRegistration} | |
| 55 | + | return ports.BrokerDialTarget{}, ports.BrokerError{Code: ports.BrokerErrorUnavailable, Text: "stale registration", Cause: errStaleRegistration} | |
| 56 | 56 | } | |
| 57 | 57 | if !provisioned.Policy.Compatible(request.Policy) { | |
| 58 | - | return ports.BrokerResolvedEndpoint{}, ports.BrokerError{Code: ports.BrokerErrorConflictingPolicy, Cause: errors.New("brokerconfig: request policy conflicts with the provisioned policy")} | |
| 58 | + | return ports.BrokerDialTarget{}, ports.BrokerError{Code: ports.BrokerErrorConflictingPolicy, Cause: errors.New("brokerconfig: request policy conflicts with the provisioned policy")} | |
| 59 | + | } | |
| 60 | + | resolved := ports.BrokerDialTarget{ | |
| 61 | + | Fence: ports.BrokerEndpointFence{Registration: provisioned.Registration}, Policy: provisioned.Policy, Address: provisioned.Route.Address(), | |
| 62 | + | StartMode: request.StartMode, | |
| 63 | + | ExpectedIdentity: ports.BrokerExpectedIdentity{Identity: provisioned.Identity, Bound: provisioned.Identity != ""}, | |
| 59 | 64 | } | |
| 60 | - | resolved := ports.BrokerResolvedEndpoint{Identity: provisioned.Identity, Policy: provisioned.Policy, Address: provisioned.Route.Address()} | |
| 61 | 65 | if err := resolved.Validate(); err != nil { | |
| 62 | - | return ports.BrokerResolvedEndpoint{}, ports.BrokerError{Code: ports.BrokerErrorIncompatible, Cause: err} | |
| 66 | + | return ports.BrokerDialTarget{}, ports.BrokerError{Code: ports.BrokerErrorIncompatible, Cause: err} | |
| 63 | 67 | } | |
| 64 | 68 | return resolved, nil | |
| 65 | 69 | } | |
| @@ -70,16 +74,20 @@ func (r *Resolver) Resolve(ctx context.Context, request ports.BrokerOpenStreamRe | |||
| 70 | 74 | // provisioned local policy is refused as a conflicting policy. The resolved | |
| 71 | 75 | // identity, policy, and address are the binding's own, so the request supplies | |
| 72 | 76 | // none of them. | |
| 73 | - | func (r *Resolver) resolveLocal(request ports.BrokerOpenStreamRequest) (ports.BrokerResolvedEndpoint, error) { | |
| 77 | + | func (r *Resolver) resolveLocal(request ports.BrokerOpenStreamRequest) (ports.BrokerDialTarget, error) { | |
| 74 | 78 | if r.local == nil { | |
| 75 | - | return ports.BrokerResolvedEndpoint{}, ports.BrokerError{Code: ports.BrokerErrorUnavailable, Cause: errNoLocalRoute} | |
| 79 | + | return ports.BrokerDialTarget{}, ports.BrokerError{Code: ports.BrokerErrorUnavailable, Text: "local route is not provisioned", Cause: errNoLocalRoute} | |
| 76 | 80 | } | |
| 77 | 81 | if !r.local.Policy.Compatible(request.Policy) { | |
| 78 | - | return ports.BrokerResolvedEndpoint{}, ports.BrokerError{Code: ports.BrokerErrorConflictingPolicy, Cause: errLocalPolicyConflict} | |
| 82 | + | return ports.BrokerDialTarget{}, ports.BrokerError{Code: ports.BrokerErrorConflictingPolicy, Cause: errLocalPolicyConflict} | |
| 83 | + | } | |
| 84 | + | resolved := ports.BrokerDialTarget{ | |
| 85 | + | Fence: ports.BrokerEndpointFence{Local: true}, Policy: r.local.Policy, Address: r.local.Route.Address(), | |
| 86 | + | StartMode: request.StartMode, | |
| 87 | + | ExpectedIdentity: ports.BrokerExpectedIdentity{Identity: r.local.Identity, Bound: r.local.Identity != ""}, | |
| 79 | 88 | } | |
| 80 | - | resolved := ports.BrokerResolvedEndpoint{Identity: r.local.Identity, Policy: r.local.Policy, Address: r.local.Route.Address()} | |
| 81 | 89 | if err := resolved.Validate(); err != nil { | |
| 82 | - | return ports.BrokerResolvedEndpoint{}, ports.BrokerError{Code: ports.BrokerErrorIncompatible, Cause: err} | |
| 90 | + | return ports.BrokerDialTarget{}, ports.BrokerError{Code: ports.BrokerErrorIncompatible, Cause: err} | |
| 83 | 91 | } | |
| 84 | 92 | return resolved, nil | |
| 85 | 93 | } | |
M internal/adapters/brokerconfig/route.go +13 -0
| @@ -15,6 +15,7 @@ import ( | |||
| 15 | 15 | "unicode/utf8" | |
| 16 | 16 | ||
| 17 | 17 | "github.com/bnema/vev/internal/domain" | |
| 18 | + | "github.com/bnema/vev/internal/ports" | |
| 18 | 19 | ) | |
| 19 | 20 | ||
| 20 | 21 | // Bounded route variants (Plan 001 P3.4 slice E). | |
| @@ -317,6 +318,18 @@ func parseTrust(document *trustDocument) (TrustInputs, error) { | |||
| 317 | 318 | return trust, nil | |
| 318 | 319 | } | |
| 319 | 320 | ||
| 321 | + | // RouteFromSpec materializes canonical durable routing authority. Runtime SSH | |
| 322 | + | // trust is OpenSSH-owned; no startup configuration lookup participates. | |
| 323 | + | func RouteFromSpec(spec ports.BrokerRouteSpec) (Route, error) { | |
| 324 | + | if err := spec.Validate(); err != nil { | |
| 325 | + | return Route{}, err | |
| 326 | + | } | |
| 327 | + | if spec.Kind == ports.BrokerRouteUnix { | |
| 328 | + | return newUnixRoute(spec.Path) | |
| 329 | + | } | |
| 330 | + | return newSSHRoute(RouteKind(spec.Kind), spec.Target, spec.Argv, TrustInputs{}) | |
| 331 | + | } | |
| 332 | + | ||
| 320 | 333 | func newUnixRoute(path string) (Route, error) { | |
| 321 | 334 | route := Route{kind: RouteUnix, path: path, local: true} | |
| 322 | 335 | if err := route.Validate(); err != nil { | |
M internal/adapters/brokeripc/client.go +54 -29
| @@ -216,14 +216,16 @@ type client struct { | |||
| 216 | 216 | err error | |
| 217 | 217 | sub *clientSubscription | |
| 218 | 218 | generation brokerwire.SubscriptionGeneration | |
| 219 | - | highest ports.BrokerStreamID | |
| 220 | 219 | nextStream ports.BrokerStreamID | |
| 221 | 220 | snapshot ports.BrokerSnapshot | |
| 222 | 221 | pending map[ports.BrokerOperationID]chan operationResult | |
| 223 | 222 | kinds map[ports.BrokerOperationID]brokerwire.RegisterMutationKind | |
| 224 | - | streams map[ports.BrokerStreamID]*clientStream | |
| 225 | - | closeOnce sync.Once | |
| 226 | - | done chan struct{} | |
| 223 | + | // The connection's own stream tracker is the anti-replay authority for | |
| 224 | + | // this connection: it admits every identity at most once inside the bounded | |
| 225 | + | // window, whether the open is afterwards accepted or refused. | |
| 226 | + | streams map[ports.BrokerStreamID]*clientStream | |
| 227 | + | closeOnce sync.Once | |
| 228 | + | done chan struct{} | |
| 227 | 229 | } | |
| 228 | 230 | ||
| 229 | 231 | var _ ports.BrokerService = (*client)(nil) | |
| @@ -349,11 +351,15 @@ func (c *client) dispatch(message brokerwire.ServerMessage) error { | |||
| 349 | 351 | } | |
| 350 | 352 | cause := failureFromDetail(m.Error) | |
| 351 | 353 | if cause == nil { | |
| 352 | - | cause = ports.BrokerError{Code: ports.BrokerErrorUnavailable} | |
| 354 | + | cause = ports.BrokerError{Code: ports.BrokerErrorUnavailable, Text: "empty broker error frame"} | |
| 353 | 355 | } | |
| 354 | 356 | return cause | |
| 355 | 357 | case brokerwire.Shutdown: | |
| 356 | - | return ports.BrokerError{Code: ports.BrokerErrorUnavailable, Text: m.Text} | |
| 358 | + | text := m.Text | |
| 359 | + | if text == "" { | |
| 360 | + | text = "broker shutdown" | |
| 361 | + | } | |
| 362 | + | return ports.BrokerError{Code: ports.BrokerErrorUnavailable, Text: text} | |
| 357 | 363 | default: | |
| 358 | 364 | return errors.Join(ErrProtocol, errors.New("brokeripc: unexpected server message")) | |
| 359 | 365 | } | |
| @@ -403,6 +409,27 @@ func (c *client) completeOperation(operation ports.BrokerOperationID, result ope | |||
| 403 | 409 | // ConnectionID returns the identity assigned to this connection at accept. | |
| 404 | 410 | func (c *client) ConnectionID() ports.BrokerConnectionID { return c.scope.Connection } | |
| 405 | 411 | ||
| 412 | + | // NextStreamID allocates this connection's next logical stream identity. It is | |
| 413 | + | // strictly monotone, never zero, and consumes no wire frame: it is the local | |
| 414 | + | // allocation of the calling service, which then carries the exact | |
| 415 | + | // epoch/connection/stream triplet on OpenStream. It performs no I/O and refuses | |
| 416 | + | // a closed connection or an exhausted counter rather than wrapping. | |
| 417 | + | func (c *client) NextStreamID() (ports.BrokerStreamID, error) { | |
| 418 | + | if c == nil { | |
| 419 | + | return 0, ErrConnectionClosed | |
| 420 | + | } | |
| 421 | + | c.mu.Lock() | |
| 422 | + | defer c.mu.Unlock() | |
| 423 | + | if c.closed { | |
| 424 | + | return 0, c.terminalErrorLocked() | |
| 425 | + | } | |
| 426 | + | if c.nextStream == ^ports.BrokerStreamID(0) { | |
| 427 | + | return 0, ports.BrokerAdmissionLimit | |
| 428 | + | } | |
| 429 | + | c.nextStream++ | |
| 430 | + | return c.nextStream, nil | |
| 431 | + | } | |
| 432 | + | ||
| 406 | 433 | // Done closes exactly once when this connection is terminal: the carriage | |
| 407 | 434 | // failed, the broker retired it, or this side closed it. Err is stable | |
| 408 | 435 | // afterwards. | |
| @@ -476,9 +503,13 @@ func (c *client) Subscribe() (ports.BrokerSubscription, error) { | |||
| 476 | 503 | } | |
| 477 | 504 | ||
| 478 | 505 | // OpenStream opens one independently cancellable logical stream. An absent | |
| 479 | - | // epoch, connection, or stream identity is filled from this connection; a | |
| 480 | - | // caller-supplied identity that belongs elsewhere, or a stream identity that is | |
| 481 | - | // not strictly increasing, is refused as stale. | |
| 506 | + | // epoch or connection identity is filled from this connection and a | |
| 507 | + | // caller-supplied identity that belongs elsewhere is refused as stale. The | |
| 508 | + | // stream identity must be the exact one the calling service allocated with | |
| 509 | + | // NextStreamID: it is admitted on this connection's bounded anti-replay window | |
| 510 | + | // before any frame travels, so a duplicate and an identity evicted past the | |
| 511 | + | // window are both refused, while a concurrent lower identity that was allocated | |
| 512 | + | // first is still admitted. | |
| 482 | 513 | func (c *client) OpenStream(ctx context.Context, request ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) { | |
| 483 | 514 | scoped, err := c.scopeRequest(request) | |
| 484 | 515 | if err != nil { | |
| @@ -491,7 +522,10 @@ func (c *client) OpenStream(ctx context.Context, request ports.BrokerOpenStreamR | |||
| 491 | 522 | return nil, err | |
| 492 | 523 | } | |
| 493 | 524 | // Admit the identity on the connection's own tracker before any frame | |
| 494 | - | // travels, so a racing StreamOpened can never precede local admission. | |
| 525 | + | // travels, so a racing StreamOpened can never precede local admission. The | |
| 526 | + | // tracker applies the bounded anti-replay window: a duplicate and an | |
| 527 | + | // identity evicted past the window are refused, while a concurrent lower | |
| 528 | + | // identity that was allocated first is still admitted. | |
| 495 | 529 | if err := c.conn.OpenStream(scoped.Stream); err != nil { | |
| 496 | 530 | c.mu.Unlock() | |
| 497 | 531 | return nil, admissionError(err) | |
| @@ -514,6 +548,7 @@ func (c *client) OpenStream(ctx context.Context, request ports.BrokerOpenStreamR | |||
| 514 | 548 | Local: scoped.Local, | |
| 515 | 549 | Endpoint: scoped.Endpoint, Registration: scoped.Registration, | |
| 516 | 550 | Target: scoped.Target, Env: scoped.Env, Policy: scoped.Policy, | |
| 551 | + | StartMode: scoped.StartMode, | |
| 517 | 552 | }); err != nil { | |
| 518 | 553 | c.retireStream(scoped.Stream, err) | |
| 519 | 554 | return nil, err | |
| @@ -615,8 +650,12 @@ func (c *client) Close() error { | |||
| 615 | 650 | return nil | |
| 616 | 651 | } | |
| 617 | 652 | ||
| 618 | - | // scopeRequest fills an absent identity from this connection and refuses one | |
| 619 | - | // that belongs elsewhere, then validates the resulting request. | |
| 653 | + | // scopeRequest fills an absent epoch or connection identity from this | |
| 654 | + | // connection and refuses one that belongs elsewhere, then validates the | |
| 655 | + | // resulting request. The stream identity must be present: after cutover the | |
| 656 | + | // calling service allocates it with NextStreamID and carries the exact | |
| 657 | + | // epoch/connection/stream triplet, so this adapter never invents an identity on | |
| 658 | + | // an incomplete request. | |
| 620 | 659 | func (c *client) scopeRequest(request ports.BrokerOpenStreamRequest) (ports.BrokerOpenStreamRequest, error) { | |
| 621 | 660 | scoped := request | |
| 622 | 661 | if scoped.Epoch == 0 { | |
| @@ -629,23 +668,9 @@ func (c *client) scopeRequest(request ports.BrokerOpenStreamRequest) (ports.Brok | |||
| 629 | 668 | } else if scoped.Connection != c.scope.Connection { | |
| 630 | 669 | return ports.BrokerOpenStreamRequest{}, errors.Join(ErrScopeMismatch, ports.BrokerAdmissionStale) | |
| 631 | 670 | } | |
| 632 | - | c.mu.Lock() | |
| 633 | - | switch { | |
| 634 | - | case scoped.Stream == 0: | |
| 635 | - | if c.nextStream == ^ports.BrokerStreamID(0) { | |
| 636 | - | c.mu.Unlock() | |
| 637 | - | return ports.BrokerOpenStreamRequest{}, ports.BrokerAdmissionLimit | |
| 638 | - | } | |
| 639 | - | c.nextStream++ | |
| 640 | - | scoped.Stream = c.nextStream | |
| 641 | - | c.highest = scoped.Stream | |
| 642 | - | case scoped.Stream <= c.highest: | |
| 643 | - | c.mu.Unlock() | |
| 644 | - | return ports.BrokerOpenStreamRequest{}, errors.Join(ErrScopeMismatch, ports.BrokerAdmissionStale) | |
| 645 | - | default: | |
| 646 | - | c.highest = scoped.Stream | |
| 671 | + | if scoped.Stream == 0 { | |
| 672 | + | return ports.BrokerOpenStreamRequest{}, errors.Join(ports.BrokerAdmissionInvalid, errors.New("brokeripc: open stream request has no stream identity")) | |
| 647 | 673 | } | |
| 648 | - | c.mu.Unlock() | |
| 649 | 674 | if err := scoped.Validate(); err != nil { | |
| 650 | 675 | return ports.BrokerOpenStreamRequest{}, errors.Join(ports.BrokerAdmissionInvalid, err) | |
| 651 | 676 | } | |
| @@ -742,7 +767,7 @@ func (c *client) sendAsync(message brokerwire.ClientMessage) error { | |||
| 742 | 767 | if async, ok := c.transport.(wire.AsyncTransport); ok { | |
| 743 | 768 | if err := async.SendAsync(wire.Envelope{Payload: payload}); err != nil { | |
| 744 | 769 | if errors.Is(err, streamframe.ErrBackpressure) { | |
| 745 | - | return ports.BrokerError{Code: ports.BrokerErrorUnavailable} | |
| 770 | + | return ports.BrokerError{Code: ports.BrokerErrorUnavailable, Text: "broker IPC send queue is full"} | |
| 746 | 771 | } | |
| 747 | 772 | return transportFailure(err) | |
| 748 | 773 | } | |
M internal/adapters/brokeripc/errors.go +10 -1
| @@ -131,10 +131,19 @@ func errorDetail(err error) brokerwire.ErrorDetail { | |||
| 131 | 131 | case errors.Is(err, context.Canceled): | |
| 132 | 132 | return brokerwire.ErrorDetail{Code: ports.BrokerErrorCancelled} | |
| 133 | 133 | default: | |
| 134 | - | return brokerwire.ErrorDetail{Code: ports.BrokerErrorUnavailable} | |
| 134 | + | return brokerwire.ErrorDetail{Code: ports.BrokerErrorUnavailable, Text: boundedBrokerErrorText(err)} | |
| 135 | 135 | } | |
| 136 | 136 | } | |
| 137 | 137 | ||
| 138 | + | func boundedBrokerErrorText(err error) string { | |
| 139 | + | const max = 256 | |
| 140 | + | text := err.Error() | |
| 141 | + | if len(text) > max { | |
| 142 | + | text = text[:max] | |
| 143 | + | } | |
| 144 | + | return text | |
| 145 | + | } | |
| 146 | + | ||
| 138 | 147 | // failureFromDetail converts one received detail back into the typed local | |
| 139 | 148 | // failure. A detail with no code is the absent-detail case and yields nil. | |
| 140 | 149 | func failureFromDetail(detail brokerwire.ErrorDetail) error { | |
M internal/adapters/brokeripc/helpers_test.go +44 -0
| @@ -46,6 +46,39 @@ func testPolicy() ports.BrokerPolicy { | |||
| 46 | 46 | } | |
| 47 | 47 | } | |
| 48 | 48 | ||
| 49 | + | // openRequest builds one valid control-purpose open request for exactly stream, | |
| 50 | + | // with this connection's start-if-needed authorization. After the cutover the | |
| 51 | + | // calling service allocates every identity with NextStreamID, so a test that | |
| 52 | + | // names a stream always states it explicitly. | |
| 53 | + | func openRequest(stream ports.BrokerStreamID) ports.BrokerOpenStreamRequest { | |
| 54 | + | return ports.BrokerOpenStreamRequest{ | |
| 55 | + | Purpose: ports.BrokerStreamControl, | |
| 56 | + | Local: true, | |
| 57 | + | Stream: stream, | |
| 58 | + | Policy: testPolicy(), | |
| 59 | + | StartMode: ports.BrokerDaemonStartIfNeeded, | |
| 60 | + | } | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | // attachmentRequest builds one valid attachment request for exactly stream with | |
| 64 | + | // the given admission variant and creation name. | |
| 65 | + | func attachmentRequest(stream ports.BrokerStreamID, admission ports.BrokerStreamAdmission, name string) ports.BrokerOpenStreamRequest { | |
| 66 | + | request := openRequest(stream) | |
| 67 | + | request.Purpose = ports.BrokerStreamAttachment | |
| 68 | + | request.Admission = admission | |
| 69 | + | request.Name = name | |
| 70 | + | return request | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | // nextStreamID reads one fresh identity from the service's only allocator, | |
| 74 | + | // exactly as the composed supervisor and broker operations do. | |
| 75 | + | func nextStreamID(t *testing.T, service ports.BrokerService) ports.BrokerStreamID { | |
| 76 | + | t.Helper() | |
| 77 | + | stream, err := service.NextStreamID() | |
| 78 | + | require.NoError(t, err) | |
| 79 | + | return stream | |
| 80 | + | } | |
| 81 | + | ||
| 49 | 82 | // membershipProbeEndpoint is the endpoint every liveness probe adds through the | |
| 50 | 83 | // real membership delegation path. | |
| 51 | 84 | const membershipProbeEndpoint = "probe@host:22" | |
| @@ -367,10 +400,21 @@ type fakeCore struct { | |||
| 367 | 400 | addResult *domain.RemoteRegistration | |
| 368 | 401 | updateResult *domain.RemoteRegistration | |
| 369 | 402 | removeRemoved bool | |
| 403 | + | ||
| 404 | + | nextStream ports.BrokerStreamID | |
| 370 | 405 | } | |
| 371 | 406 | ||
| 372 | 407 | func (c *fakeCore) ConnectionID() ports.BrokerConnectionID { return c.id } | |
| 373 | 408 | ||
| 409 | + | // NextStreamID is the admitted core connection's only stream allocator, exactly | |
| 410 | + | // like the production service: the listener delegates it unchanged. | |
| 411 | + | func (c *fakeCore) NextStreamID() (ports.BrokerStreamID, error) { | |
| 412 | + | c.mu.Lock() | |
| 413 | + | defer c.mu.Unlock() | |
| 414 | + | c.nextStream++ | |
| 415 | + | return c.nextStream, nil | |
| 416 | + | } | |
| 417 | + | ||
| 374 | 418 | func (c *fakeCore) Done() <-chan struct{} { return c.done } | |
| 375 | 419 | ||
| 376 | 420 | func (c *fakeCore) Err() error { return nil } | |
M internal/adapters/brokeripc/limits_test.go +14 -9
| @@ -258,9 +258,9 @@ func TestAdmissionErrorMapping(t *testing.T) { | |||
| 258 | 258 | require.ErrorIs(t, admissionError(brokerwire.ErrFutureStream), ports.BrokerAdmissionInvalid) | |
| 259 | 259 | } | |
| 260 | 260 | ||
| 261 | - | // TestScopeRequestFillsIdentity proves an absent identity is filled from the | |
| 262 | - | // connection, a foreign one is refused as stale, and stream identities advance | |
| 263 | - | // strictly. | |
| 261 | + | // TestScopeRequestFillsIdentity proves an absent epoch/connection identity is | |
| 262 | + | // filled from the connection, a foreign one is refused as stale, and a request | |
| 263 | + | // without a stream identity is refused as invalid rather than allocated here. | |
| 264 | 264 | func TestScopeRequestFillsIdentity(t *testing.T) { | |
| 265 | 265 | c := &client{ | |
| 266 | 266 | scope: brokerwire.Scope{Epoch: 4, Connection: ports.BrokerConnectionID{4}}, | |
| @@ -271,28 +271,33 @@ func TestScopeRequestFillsIdentity(t *testing.T) { | |||
| 271 | 271 | cfg: Config{}.withDefaults(), | |
| 272 | 272 | transport: nil, | |
| 273 | 273 | } | |
| 274 | - | scoped, err := c.scopeRequest(ports.BrokerOpenStreamRequest{Purpose: ports.BrokerStreamControl, Local: true, Policy: testPolicy()}) | |
| 274 | + | scoped, err := c.scopeRequest(openRequest(1)) | |
| 275 | 275 | require.NoError(t, err) | |
| 276 | 276 | require.Equal(t, ports.BrokerEpoch(4), scoped.Epoch) | |
| 277 | 277 | require.Equal(t, ports.BrokerConnectionID{4}, scoped.Connection) | |
| 278 | 278 | require.Equal(t, ports.BrokerStreamID(1), scoped.Stream) | |
| 279 | 279 | ||
| 280 | 280 | _, err = c.scopeRequest(ports.BrokerOpenStreamRequest{ | |
| 281 | - | Purpose: ports.BrokerStreamControl, Local: true, Policy: testPolicy(), | |
| 281 | + | Purpose: ports.BrokerStreamControl, Local: true, Stream: 1, Policy: testPolicy(), StartMode: ports.BrokerDaemonStartIfNeeded, | |
| 282 | 282 | Connection: ports.BrokerConnectionID{0x11}, | |
| 283 | 283 | }) | |
| 284 | 284 | require.ErrorIs(t, err, ports.BrokerAdmissionStale) | |
| 285 | 285 | ||
| 286 | 286 | _, err = c.scopeRequest(ports.BrokerOpenStreamRequest{ | |
| 287 | - | Purpose: ports.BrokerStreamControl, Local: true, Policy: testPolicy(), Epoch: 99, | |
| 287 | + | Purpose: ports.BrokerStreamControl, Local: true, Stream: 1, Policy: testPolicy(), StartMode: ports.BrokerDaemonStartIfNeeded, Epoch: 99, | |
| 288 | 288 | }) | |
| 289 | 289 | require.ErrorIs(t, err, ErrScopeMismatch) | |
| 290 | 290 | ||
| 291 | - | scoped, err = c.scopeRequest(ports.BrokerOpenStreamRequest{Purpose: ports.BrokerStreamControl, Local: true, Stream: 5, Policy: testPolicy()}) | |
| 291 | + | scoped, err = c.scopeRequest(openRequest(5)) | |
| 292 | 292 | require.NoError(t, err) | |
| 293 | 293 | require.Equal(t, ports.BrokerStreamID(5), scoped.Stream) | |
| 294 | - | _, err = c.scopeRequest(ports.BrokerOpenStreamRequest{Purpose: ports.BrokerStreamControl, Local: true, Stream: 5, Policy: testPolicy()}) | |
| 295 | - | require.ErrorIs(t, err, ports.BrokerAdmissionStale) | |
| 294 | + | ||
| 295 | + | // The adapter never invents a stream identity: an incomplete request is | |
| 296 | + | // refused, and the calling service is the only allocator. | |
| 297 | + | _, err = c.scopeRequest(ports.BrokerOpenStreamRequest{ | |
| 298 | + | Purpose: ports.BrokerStreamControl, Local: true, Policy: testPolicy(), StartMode: ports.BrokerDaemonStartIfNeeded, | |
| 299 | + | }) | |
| 300 | + | require.ErrorIs(t, err, ports.BrokerAdmissionInvalid) | |
| 296 | 301 | } | |
| 297 | 302 | ||
| 298 | 303 | // TestSocketPaths proves the endpoint name is distinct from the daemon socket | |
M internal/adapters/brokeripc/protocol_test.go +3 -0
| @@ -158,6 +158,7 @@ func TestNegotiatedChunkCeilingIsEnforced(t *testing.T) { | |||
| 158 | 158 | raw.send(t, brokerwire.OpenStream{ | |
| 159 | 159 | Epoch: scope.Epoch, Connection: scope.Connection, Stream: 1, | |
| 160 | 160 | Purpose: ports.BrokerStreamControl, Local: true, Policy: testPolicy(), | |
| 161 | + | StartMode: ports.BrokerDaemonStartIfNeeded, | |
| 161 | 162 | }) | |
| 162 | 163 | opened, ok := raw.recv(t).(brokerwire.StreamOpened) | |
| 163 | 164 | require.True(t, ok, "the stream must be established before chunk enforcement matters") | |
| @@ -186,6 +187,7 @@ func TestScopeMismatchOpenStreamIsRefusedAsStale(t *testing.T) { | |||
| 186 | 187 | raw.send(t, brokerwire.OpenStream{ | |
| 187 | 188 | Epoch: scope.Epoch, Connection: ports.BrokerConnectionID{0x7f}, | |
| 188 | 189 | Stream: 1, Purpose: ports.BrokerStreamControl, Local: true, Policy: testPolicy(), | |
| 190 | + | StartMode: ports.BrokerDaemonStartIfNeeded, | |
| 189 | 191 | }) | |
| 190 | 192 | closed, ok := raw.recv(t).(brokerwire.StreamClosed) | |
| 191 | 193 | require.True(t, ok, "a mismatched scope must be answered with StreamClosed") | |
| @@ -196,6 +198,7 @@ func TestScopeMismatchOpenStreamIsRefusedAsStale(t *testing.T) { | |||
| 196 | 198 | raw.send(t, brokerwire.OpenStream{ | |
| 197 | 199 | Epoch: scope.Epoch, Connection: scope.Connection, Stream: 1, | |
| 198 | 200 | Purpose: ports.BrokerStreamControl, Local: true, Policy: testPolicy(), | |
| 201 | + | StartMode: ports.BrokerDaemonStartIfNeeded, | |
| 199 | 202 | }) | |
| 200 | 203 | _, ok = raw.recv(t).(brokerwire.StreamOpened) | |
| 201 | 204 | require.True(t, ok) | |
M internal/adapters/brokeripc/server.go +16 -1
| @@ -899,6 +899,18 @@ func orderlyDisconnect(err error) bool { | |||
| 899 | 899 | // ConnectionID returns the identity assigned to this connection at accept. | |
| 900 | 900 | func (s *serverSession) ConnectionID() ports.BrokerConnectionID { return s.scope.Connection } | |
| 901 | 901 | ||
| 902 | + | // NextStreamID allocates the next logical stream identity of the admitted core | |
| 903 | + | // connection. The wire identities are allocated by the calling service and are | |
| 904 | + | // carried to this listener unchanged; this delegating form exists so a composed | |
| 905 | + | // caller can use the session as a scoped ports.BrokerService. It never | |
| 906 | + | // re-allocates a wire identity and performs no I/O. | |
| 907 | + | func (s *serverSession) NextStreamID() (ports.BrokerStreamID, error) { | |
| 908 | + | if s == nil { | |
| 909 | + | return 0, ErrSessionClosed | |
| 910 | + | } | |
| 911 | + | return s.core.NextStreamID() | |
| 912 | + | } | |
| 913 | + | ||
| 902 | 914 | // Done closes exactly once when this session is terminal: the carriage failed, | |
| 903 | 915 | // the peer disconnected, or the owner closed it. Err is stable afterwards. | |
| 904 | 916 | func (s *serverSession) Done() <-chan struct{} { return s.done } | |
| @@ -929,7 +941,10 @@ func (s *serverSession) Subscribe() (ports.BrokerSubscription, error) { return s | |||
| 929 | 941 | // service. The connection's own reader drives the same core through | |
| 930 | 942 | // openStreamMessage, which additionally bridges the stream onto the wire; this | |
| 931 | 943 | // method exists so a composed caller can use the session as a scoped | |
| 932 | - | // ports.BrokerService. | |
| 944 | + | // ports.BrokerService. The stream identity is the one the caller already | |
| 945 | + | // allocated (NextStreamID) and is transmitted unchanged: this listener never | |
| 946 | + | // re-allocates a wire identity, whether the request arrives over the wire or | |
| 947 | + | // through this in-process delegate. | |
| 933 | 948 | func (s *serverSession) OpenStream(ctx context.Context, request ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) { | |
| 934 | 949 | if err := s.checkRequestScope(request.Epoch, request.Connection); err != nil { | |
| 935 | 950 | return nil, err | |
M internal/adapters/brokeripc/session_test.go +66 -30
| @@ -109,9 +109,7 @@ func TestSessionCloseStreamFillsConnectionScope(t *testing.T) { | |||
| 109 | 109 | ||
| 110 | 110 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |
| 111 | 111 | defer cancel() | |
| 112 | - | opened, err := session.OpenStream(ctx, ports.BrokerOpenStreamRequest{ | |
| 113 | - | Purpose: ports.BrokerStreamControl, Local: true, Stream: 1, Policy: testPolicy(), | |
| 114 | - | }) | |
| 112 | + | opened, err := session.OpenStream(ctx, openRequest(1)) | |
| 115 | 113 | require.NoError(t, err) | |
| 116 | 114 | require.NotNil(t, opened) | |
| 117 | 115 | ||
| @@ -143,10 +141,7 @@ func TestOpenStreamAdmissionRoundTrips(t *testing.T) { | |||
| 143 | 141 | ||
| 144 | 142 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |
| 145 | 143 | defer cancel() | |
| 146 | - | stream, err := client.OpenStream(ctx, ports.BrokerOpenStreamRequest{ | |
| 147 | - | Purpose: ports.BrokerStreamAttachment, Admission: ports.BrokerAdmissionCreateNamed, Name: "work", | |
| 148 | - | Local: true, Policy: testPolicy(), | |
| 149 | - | }) | |
| 144 | + | stream, err := client.OpenStream(ctx, attachmentRequest(1, ports.BrokerAdmissionCreateNamed, "work")) | |
| 150 | 145 | require.NoError(t, err) | |
| 151 | 146 | require.NotNil(t, stream) | |
| 152 | 147 | ||
| @@ -157,6 +152,42 @@ func TestOpenStreamAdmissionRoundTrips(t *testing.T) { | |||
| 157 | 152 | require.Equal(t, "work", core.opens[0].Name) | |
| 158 | 153 | require.Equal(t, ports.BrokerStreamAttachment, core.opens[0].Purpose) | |
| 159 | 154 | require.True(t, core.opens[0].Local) | |
| 155 | + | require.Equal(t, ports.BrokerDaemonStartIfNeeded, core.opens[0].StartMode) | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | // TestObservationForcesExistingOnly proves the observation authorization | |
| 159 | + | // travels the IPC wire: a control stream may carry start-if-needed, while an | |
| 160 | + | // observation must carry existing-only and a spawn-capable observation is | |
| 161 | + | // refused before any frame travels. | |
| 162 | + | func TestObservationForcesExistingOnly(t *testing.T) { | |
| 163 | + | e := startEndpoint(t, Config{}) | |
| 164 | + | client, _ := e.pair() | |
| 165 | + | core := e.authority.last() | |
| 166 | + | require.NotNil(t, core) | |
| 167 | + | ||
| 168 | + | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |
| 169 | + | defer cancel() | |
| 170 | + | streamID := nextStreamID(t, client) | |
| 171 | + | observation := openRequest(streamID) | |
| 172 | + | observation.Purpose = ports.BrokerStreamObservation | |
| 173 | + | observation.StartMode = ports.BrokerDaemonExistingOnly | |
| 174 | + | stream, err := client.OpenStream(ctx, observation) | |
| 175 | + | require.NoError(t, err) | |
| 176 | + | require.NoError(t, stream.Close()) | |
| 177 | + | ||
| 178 | + | // A spawn-capable observation is refused locally: it never reaches the | |
| 179 | + | // admitted core at all. | |
| 180 | + | startable := openRequest(nextStreamID(t, client)) | |
| 181 | + | startable.Purpose = ports.BrokerStreamObservation | |
| 182 | + | startable.StartMode = ports.BrokerDaemonStartIfNeeded | |
| 183 | + | _, err = client.OpenStream(ctx, startable) | |
| 184 | + | require.ErrorIs(t, err, ports.BrokerAdmissionInvalid, "observation never authorizes a spawn") | |
| 185 | + | ||
| 186 | + | core.mu.Lock() | |
| 187 | + | defer core.mu.Unlock() | |
| 188 | + | require.Len(t, core.opens, 1, "only the existing-only observation reached the core") | |
| 189 | + | require.Equal(t, ports.BrokerStreamObservation, core.opens[0].Purpose) | |
| 190 | + | require.Equal(t, ports.BrokerDaemonExistingOnly, core.opens[0].StartMode) | |
| 160 | 191 | } | |
| 161 | 192 | ||
| 162 | 193 | // TestRegistrationAssignsScopeAtAccept proves the client adapter adopts exactly | |
| @@ -304,11 +335,7 @@ func TestOpenStreamCarriesTypedTraffic(t *testing.T) { | |||
| 304 | 335 | ||
| 305 | 336 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |
| 306 | 337 | defer cancel() | |
| 307 | - | stream, err := client.OpenStream(ctx, ports.BrokerOpenStreamRequest{ | |
| 308 | - | Purpose: ports.BrokerStreamControl, | |
| 309 | - | Local: true, | |
| 310 | - | Policy: testPolicy(), | |
| 311 | - | }) | |
| 338 | + | stream, err := client.OpenStream(ctx, openRequest(1)) | |
| 312 | 339 | require.NoError(t, err) | |
| 313 | 340 | t.Cleanup(func() { _ = stream.Close() }) | |
| 314 | 341 | ||
| @@ -351,11 +378,7 @@ func TestOpenStreamRefusedByCoreReachesClient(t *testing.T) { | |||
| 351 | 378 | ||
| 352 | 379 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |
| 353 | 380 | defer cancel() | |
| 354 | - | stream, err := client.OpenStream(ctx, ports.BrokerOpenStreamRequest{ | |
| 355 | - | Purpose: ports.BrokerStreamControl, | |
| 356 | - | Local: true, | |
| 357 | - | Policy: testPolicy(), | |
| 358 | - | }) | |
| 381 | + | stream, err := client.OpenStream(ctx, openRequest(1)) | |
| 359 | 382 | require.Error(t, err) | |
| 360 | 383 | require.Nil(t, stream) | |
| 361 | 384 | var failure ports.BrokerError | |
| @@ -379,9 +402,9 @@ func TestTerminalStreamFailureFailsOnlyThatStream(t *testing.T) { | |||
| 379 | 402 | ||
| 380 | 403 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |
| 381 | 404 | defer cancel() | |
| 382 | - | first, err := client.OpenStream(ctx, ports.BrokerOpenStreamRequest{Purpose: ports.BrokerStreamControl, Local: true, Policy: testPolicy()}) | |
| 405 | + | first, err := client.OpenStream(ctx, openRequest(nextStreamID(t, client))) | |
| 383 | 406 | require.NoError(t, err) | |
| 384 | - | second, err := client.OpenStream(ctx, ports.BrokerOpenStreamRequest{Purpose: ports.BrokerStreamControl, Local: true, Policy: testPolicy()}) | |
| 407 | + | second, err := client.OpenStream(ctx, openRequest(nextStreamID(t, client))) | |
| 385 | 408 | require.NoError(t, err) | |
| 386 | 409 | t.Cleanup(func() { _ = first.Close(); _ = second.Close() }) | |
| 387 | 410 | ||
| @@ -419,7 +442,7 @@ func TestCloseStreamRetiresBothSides(t *testing.T) { | |||
| 419 | 442 | ||
| 420 | 443 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |
| 421 | 444 | defer cancel() | |
| 422 | - | stream, err := client.OpenStream(ctx, ports.BrokerOpenStreamRequest{Purpose: ports.BrokerStreamControl, Local: true, Policy: testPolicy()}) | |
| 445 | + | stream, err := client.OpenStream(ctx, openRequest(nextStreamID(t, client))) | |
| 423 | 446 | require.NoError(t, err) | |
| 424 | 447 | require.NoError(t, client.CloseStream(client.ConnectionID(), 1)) | |
| 425 | 448 | ||
| @@ -705,7 +728,7 @@ func TestDisconnectCleanupReleasesEveryOwnedResource(t *testing.T) { | |||
| 705 | 728 | ||
| 706 | 729 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |
| 707 | 730 | defer cancel() | |
| 708 | - | _, err := client.OpenStream(ctx, ports.BrokerOpenStreamRequest{Purpose: ports.BrokerStreamControl, Local: true, Policy: testPolicy()}) | |
| 731 | + | _, err := client.OpenStream(ctx, openRequest(nextStreamID(t, client))) | |
| 709 | 732 | require.NoError(t, err) | |
| 710 | 733 | conn := core.logicalConn(1) | |
| 711 | 734 | require.NotNil(t, conn) | |
| @@ -767,7 +790,7 @@ func TestClientStreamBackpressureSettlesOnlyThatStream(t *testing.T) { | |||
| 767 | 790 | ||
| 768 | 791 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |
| 769 | 792 | defer cancel() | |
| 770 | - | stream, err := client.OpenStream(ctx, ports.BrokerOpenStreamRequest{Purpose: ports.BrokerStreamControl, Local: true, Policy: testPolicy()}) | |
| 793 | + | stream, err := client.OpenStream(ctx, openRequest(nextStreamID(t, client))) | |
| 771 | 794 | require.NoError(t, err) | |
| 772 | 795 | conn := core.logicalConn(1) | |
| 773 | 796 | require.NotNil(t, conn) | |
| @@ -802,7 +825,7 @@ func TestOrderlyStreamCloseReportsNoError(t *testing.T) { | |||
| 802 | 825 | ||
| 803 | 826 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |
| 804 | 827 | defer cancel() | |
| 805 | - | stream, err := client.OpenStream(ctx, ports.BrokerOpenStreamRequest{Purpose: ports.BrokerStreamControl, Local: true, Policy: testPolicy()}) | |
| 828 | + | stream, err := client.OpenStream(ctx, openRequest(nextStreamID(t, client))) | |
| 806 | 829 | require.NoError(t, err) | |
| 807 | 830 | require.NoError(t, stream.Close()) | |
| 808 | 831 | select { | |
| @@ -825,23 +848,36 @@ func TestScopeIdentityFencing(t *testing.T) { | |||
| 825 | 848 | defer cancel() | |
| 826 | 849 | foreign := ports.BrokerConnectionID{0x99} | |
| 827 | 850 | _, err := client.OpenStream(ctx, ports.BrokerOpenStreamRequest{ | |
| 828 | - | Purpose: ports.BrokerStreamControl, Local: true, Connection: foreign, Policy: testPolicy(), | |
| 851 | + | Purpose: ports.BrokerStreamControl, Local: true, Stream: 1, Connection: foreign, Policy: testPolicy(), StartMode: ports.BrokerDaemonStartIfNeeded, | |
| 829 | 852 | }) | |
| 830 | 853 | require.ErrorIs(t, err, ports.BrokerAdmissionStale) | |
| 831 | 854 | ||
| 832 | 855 | _, err = client.OpenStream(ctx, ports.BrokerOpenStreamRequest{ | |
| 833 | - | Purpose: ports.BrokerStreamControl, Local: true, Epoch: e.epoch + 1, Policy: testPolicy(), | |
| 856 | + | Purpose: ports.BrokerStreamControl, Local: true, Stream: 1, Epoch: e.epoch + 1, Policy: testPolicy(), StartMode: ports.BrokerDaemonStartIfNeeded, | |
| 834 | 857 | }) | |
| 835 | 858 | require.ErrorIs(t, err, ErrScopeMismatch) | |
| 836 | 859 | var stale ports.BrokerError | |
| 837 | 860 | require.ErrorAs(t, err, &stale) | |
| 838 | 861 | require.Equal(t, ports.BrokerErrorStaleEpoch, stale.Code) | |
| 839 | 862 | ||
| 840 | - | first, err := client.OpenStream(ctx, ports.BrokerOpenStreamRequest{Purpose: ports.BrokerStreamControl, Local: true, Policy: testPolicy()}) | |
| 863 | + | // A concurrent lower identity is admitted: an ID allocated before a higher | |
| 864 | + | // one can legitimately arrive after it. | |
| 865 | + | first, err := client.OpenStream(ctx, openRequest(2)) | |
| 841 | 866 | require.NoError(t, err) | |
| 842 | 867 | t.Cleanup(func() { _ = first.Close() }) | |
| 843 | - | _, err = client.OpenStream(ctx, ports.BrokerOpenStreamRequest{Purpose: ports.BrokerStreamControl, Local: true, Stream: 1, Policy: testPolicy()}) | |
| 844 | - | require.ErrorIs(t, err, ports.BrokerAdmissionStale) | |
| 868 | + | second, err := client.OpenStream(ctx, openRequest(1)) | |
| 869 | + | require.NoError(t, err, "a concurrent lower identity is admitted, not refused as stale") | |
| 870 | + | t.Cleanup(func() { _ = second.Close() }) | |
| 871 | + | // Replaying a consumed identity is refused, and so is an identity evicted | |
| 872 | + | // past the anti-replay window. | |
| 873 | + | _, err = client.OpenStream(ctx, openRequest(1)) | |
| 874 | + | require.ErrorIs(t, err, ports.BrokerAdmissionStale, "a replayed identity is stale") | |
| 875 | + | _, err = client.OpenStream(ctx, openRequest(ports.BrokerStreamWindowSize+3)) | |
| 876 | + | require.NoError(t, err) | |
| 877 | + | _, err = client.OpenStream(ctx, openRequest(1)) | |
| 878 | + | require.ErrorIs(t, err, ports.BrokerAdmissionStale, "an identity evicted from the window is stale") | |
| 879 | + | _, err = client.OpenStream(ctx, openRequest(0)) | |
| 880 | + | require.ErrorIs(t, err, ports.BrokerAdmissionInvalid, "a zero identity is never admitted") | |
| 845 | 881 | } | |
| 846 | 882 | ||
| 847 | 883 | // TestRequestReconcileIsBoundedHint proves the reconcile hint is sent only for an | |
| @@ -881,7 +917,7 @@ func TestCoreErrorSurfacesAsTypedFailure(t *testing.T) { | |||
| 881 | 917 | ||
| 882 | 918 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |
| 883 | 919 | defer cancel() | |
| 884 | - | stream, err := client.OpenStream(ctx, ports.BrokerOpenStreamRequest{Purpose: ports.BrokerStreamControl, Local: true, Policy: testPolicy()}) | |
| 920 | + | stream, err := client.OpenStream(ctx, openRequest(nextStreamID(t, client))) | |
| 885 | 921 | require.NoError(t, err) | |
| 886 | 922 | t.Cleanup(func() { _ = stream.Close() }) | |
| 887 | 923 | ||
| @@ -906,7 +942,7 @@ func TestSessionHandshakeFailureIsStreamLocal(t *testing.T) { | |||
| 906 | 942 | ||
| 907 | 943 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |
| 908 | 944 | defer cancel() | |
| 909 | - | stream, err := client.OpenStream(ctx, ports.BrokerOpenStreamRequest{Purpose: ports.BrokerStreamControl, Local: true, Policy: testPolicy()}) | |
| 945 | + | stream, err := client.OpenStream(ctx, openRequest(nextStreamID(t, client))) | |
| 910 | 946 | require.NoError(t, err) | |
| 911 | 947 | conn := core.logicalConn(1) | |
| 912 | 948 | require.NotNil(t, conn) | |
M internal/adapters/brokeripc/stream.go +1 -0
| @@ -208,6 +208,7 @@ func (s *serverSession) startStream(m brokerwire.OpenStream) { | |||
| 208 | 208 | Connection: m.Connection, Stream: m.Stream, | |
| 209 | 209 | Endpoint: m.Endpoint, Registration: m.Registration, | |
| 210 | 210 | Target: m.Target, Env: m.Env, Policy: m.Policy, | |
| 211 | + | StartMode: m.StartMode, | |
| 211 | 212 | } | |
| 212 | 213 | s.wg.Add(1) | |
| 213 | 214 | go func() { | |
M internal/adapters/brokerstore/legacy.go +12 -3
| @@ -110,12 +110,17 @@ func migrate(o Options) (recovery, error) { | |||
| 110 | 110 | if r.Cache, err = source(o.LegacyCache); err != nil { | |
| 111 | 111 | return r, err | |
| 112 | 112 | } | |
| 113 | - | r.State.Manifest = manifest{Version: 1, Sources: []manifestSource{ | |
| 113 | + | r.State.Manifest = manifest{Version: 1, ImportVersion: 1, ImportCompleted: true, Sources: []manifestSource{ | |
| 114 | 114 | {Label: manifestMembership, Present: o.LegacyHosts != "", SHA256: digest(r.Hosts)}, | |
| 115 | 115 | {Label: manifestObservations, Present: o.LegacyCache != "", SHA256: digest(r.Cache)}, | |
| 116 | 116 | }} | |
| 117 | 117 | r.State.Hosts = ports.BrokerHosts{Revision: 1} | |
| 118 | - | if o.LegacyHosts != "" { | |
| 118 | + | if o.InitialImportProvided { | |
| 119 | + | r.State.Hosts.Hosts = ports.CloneBrokerHostRecords(o.InitialHosts) | |
| 120 | + | if err := ports.ValidateBrokerHostRecords(r.State.Hosts.Hosts); err != nil && len(r.State.Hosts.Hosts) != 0 { | |
| 121 | + | return r, fmt.Errorf("brokerstore: initial import: %w", err) | |
| 122 | + | } | |
| 123 | + | } else if o.LegacyHosts != "" { | |
| 119 | 124 | r.State.Hosts.Hosts, err = decodeHosts(r.Hosts, o.Policies) | |
| 120 | 125 | if err != nil { | |
| 121 | 126 | return r, err | |
| @@ -273,7 +278,11 @@ func decodeHosts(raw []byte, policies map[string]ports.BrokerPolicy) ([]ports.Br | |||
| 273 | 278 | return nil, err | |
| 274 | 279 | } | |
| 275 | 280 | indices[record.Endpoint] = len(out) | |
| 276 | - | out = append(out, ports.BrokerHostRecord{Registration: reg, Pinned: group == 0, Learned: group == 1, Policy: policy}) | |
| 281 | + | route, err := ports.BrokerRouteForTransport(policy.Transport, reg.Endpoint) | |
| 282 | + | if err != nil { | |
| 283 | + | return nil, fmt.Errorf("brokerstore: upgrade route for %q: %w", record.Endpoint, err) | |
| 284 | + | } | |
| 285 | + | out = append(out, ports.BrokerHostRecord{Registration: reg, Pinned: group == 0, Learned: group == 1, Policy: policy, Route: route}) | |
| 277 | 286 | } | |
| 278 | 287 | } | |
| 279 | 288 | return out, nil | |
A internal/adapters/brokerstore/routes_test.go +259 -0
| @@ -0,0 +1,259 @@ | |||
| 1 | + | package brokerstore | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "encoding/json" | |
| 6 | + | "errors" | |
| 7 | + | "os" | |
| 8 | + | "path/filepath" | |
| 9 | + | "testing" | |
| 10 | + | ||
| 11 | + | "github.com/bnema/vev/internal/domain" | |
| 12 | + | "github.com/bnema/vev/internal/ports" | |
| 13 | + | "github.com/stretchr/testify/require" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | // canonicalRoutes are the exact routes production derives for the closed remote | |
| 17 | + | // policy transport vocabulary the fixtures use. | |
| 18 | + | func canonicalQUICRoute(endpoint string) ports.BrokerRouteSpec { | |
| 19 | + | return ports.BrokerRouteSpec{Kind: ports.BrokerRouteSSHQUIC, Target: endpoint, Argv: []string{"vev", "_broker-mux-quic-bootstrap", "--production"}} | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | // TestInitialImportEmptyCompletesAndNeverReimports pins the explicit initial | |
| 23 | + | // import seam: when the caller supplies an empty initial membership, the import | |
| 24 | + | // is recorded as completed with no records, and a later reopen never falls back | |
| 25 | + | // to re-importing the legacy source that is still named in Options. | |
| 26 | + | func TestInitialImportEmptyCompletesAndNeverReimports(t *testing.T) { | |
| 27 | + | dir := filepath.Join(t.TempDir(), "broker") | |
| 28 | + | o := options(t) | |
| 29 | + | o.Dir = dir | |
| 30 | + | o.LegacyCache = "" | |
| 31 | + | o.InitialHosts = nil | |
| 32 | + | o.InitialImportProvided = true | |
| 33 | + | ||
| 34 | + | store, err := Open(o) | |
| 35 | + | require.NoError(t, err) | |
| 36 | + | hosts, err := store.LoadHosts() | |
| 37 | + | require.NoError(t, err) | |
| 38 | + | require.Empty(t, hosts.Hosts, "an empty initial import seeds no membership") | |
| 39 | + | ||
| 40 | + | raw, err := readBounded(filepath.Join(dir, "recovery.json")) | |
| 41 | + | require.NoError(t, err) | |
| 42 | + | var record recovery | |
| 43 | + | require.NoError(t, strict(raw, &record)) | |
| 44 | + | require.Equal(t, 1, record.State.Manifest.ImportVersion) | |
| 45 | + | require.True(t, record.State.Manifest.ImportCompleted, "the initial import is completed even when empty") | |
| 46 | + | ||
| 47 | + | // Remove-all CAS-verifies the empty authority and still commits. | |
| 48 | + | require.NoError(t, store.ReplaceHosts(hosts.Revision, nil)) | |
| 49 | + | removed, err := store.LoadHosts() | |
| 50 | + | require.NoError(t, err) | |
| 51 | + | require.Empty(t, removed.Hosts) | |
| 52 | + | require.Equal(t, hosts.Revision+1, removed.Revision) | |
| 53 | + | require.NoError(t, store.Close()) | |
| 54 | + | ||
| 55 | + | // Reopening names the legacy source again, but state.json exists, so no | |
| 56 | + | // migration runs: the empty, completed import stays authoritative. | |
| 57 | + | store, err = Open(o) | |
| 58 | + | require.NoError(t, err) | |
| 59 | + | defer store.Close() | |
| 60 | + | again, err := store.LoadHosts() | |
| 61 | + | require.NoError(t, err) | |
| 62 | + | require.Empty(t, again.Hosts, "a supplied legacy source is never re-imported after the import completed") | |
| 63 | + | require.Equal(t, removed.Revision, again.Revision) | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | // TestRecoveryRetainsRoutes proves the retained recovery record carries the | |
| 67 | + | // canonical routes as durable authority: reopening from recovery alone (state.json | |
| 68 | + | // removed) restores the same routed membership. | |
| 69 | + | func TestRecoveryRetainsRoutes(t *testing.T) { | |
| 70 | + | o := options(t) | |
| 71 | + | store, err := Open(o) | |
| 72 | + | require.NoError(t, err) | |
| 73 | + | require.NoError(t, store.Close()) | |
| 74 | + | ||
| 75 | + | raw, err := readBounded(filepath.Join(o.Dir, "recovery.json")) | |
| 76 | + | require.NoError(t, err) | |
| 77 | + | var record recovery | |
| 78 | + | require.NoError(t, strict(raw, &record)) | |
| 79 | + | require.Len(t, record.State.Hosts.Hosts, 1) | |
| 80 | + | require.Equal(t, canonicalQUICRoute("user@arch"), record.State.Hosts.Hosts[0].Route) | |
| 81 | + | ||
| 82 | + | // Drop the committed state so the recovery record is the only source. | |
| 83 | + | require.NoError(t, os.Remove(filepath.Join(o.Dir, "state.json"))) | |
| 84 | + | o.LegacyHosts, o.LegacyCache, o.Policies = "", "", nil | |
| 85 | + | reopened, err := Open(o) | |
| 86 | + | require.NoError(t, err) | |
| 87 | + | defer reopened.Close() | |
| 88 | + | hosts, err := reopened.LoadHosts() | |
| 89 | + | require.NoError(t, err) | |
| 90 | + | require.Len(t, hosts.Hosts, 1) | |
| 91 | + | require.Equal(t, canonicalQUICRoute("user@arch"), hosts.Hosts[0].Route) | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | // TestCorruptRouteStateFailsClosed proves a committed state whose route is | |
| 95 | + | // invalid is never silently replaced or repaired: Open reports invalid durable | |
| 96 | + | // state and leaves the exact bytes it read in place for offline recovery. | |
| 97 | + | func TestCorruptRouteStateFailsClosed(t *testing.T) { | |
| 98 | + | o := options(t) | |
| 99 | + | store, err := Open(o) | |
| 100 | + | require.NoError(t, err) | |
| 101 | + | require.NoError(t, store.Close()) | |
| 102 | + | ||
| 103 | + | statePath := filepath.Join(o.Dir, "state.json") | |
| 104 | + | raw, err := os.ReadFile(statePath) | |
| 105 | + | require.NoError(t, err) | |
| 106 | + | poisoned := bytes.Replace(raw, []byte(`"ssh-quic"`), []byte(`"bogus"`), 1) | |
| 107 | + | require.NotEqual(t, raw, poisoned, "the fixture must carry a route kind to corrupt") | |
| 108 | + | require.NoError(t, os.WriteFile(statePath, poisoned, 0600)) | |
| 109 | + | ||
| 110 | + | reopened, err := Open(o) | |
| 111 | + | require.Error(t, err) | |
| 112 | + | require.Nil(t, reopened) | |
| 113 | + | require.ErrorIs(t, err, ErrInvalidState) | |
| 114 | + | require.ErrorIs(t, err, ports.ErrBrokerStoreInvalidState) | |
| 115 | + | ||
| 116 | + | after, err := os.ReadFile(statePath) | |
| 117 | + | require.NoError(t, err) | |
| 118 | + | require.Equal(t, poisoned, after, "invalid durable state is never silently replaced") | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | // TestLegacyRemoteUnixAndUnknownTransportRefused proves the migration route | |
| 122 | + | // upgrade is closed over the approved remote vocabulary: a legacy policy naming | |
| 123 | + | // a unix or unknown transport is refused before any state is committed, so a | |
| 124 | + | // legacy local route can never become broker membership. | |
| 125 | + | func TestLegacyRemoteUnixAndUnknownTransportRefused(t *testing.T) { | |
| 126 | + | for _, tc := range []struct { | |
| 127 | + | name string | |
| 128 | + | transport string | |
| 129 | + | }{ | |
| 130 | + | {name: "unix", transport: "unix"}, | |
| 131 | + | {name: "tcp", transport: "tcp"}, | |
| 132 | + | {name: "ssh", transport: "ssh"}, | |
| 133 | + | {name: "empty", transport: ""}, | |
| 134 | + | } { | |
| 135 | + | t.Run(tc.name, func(t *testing.T) { | |
| 136 | + | o := options(t) | |
| 137 | + | policy := testPolicy() | |
| 138 | + | policy.Transport = tc.transport | |
| 139 | + | o.Policies = map[string]ports.BrokerPolicy{"user@arch": policy} | |
| 140 | + | store, err := Open(o) | |
| 141 | + | require.Error(t, err) | |
| 142 | + | require.Nil(t, store) | |
| 143 | + | _, err = os.Stat(filepath.Join(o.Dir, "state.json")) | |
| 144 | + | require.True(t, os.IsNotExist(err), "a refused legacy transport never commits state") | |
| 145 | + | }) | |
| 146 | + | } | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | // TestRouteUpgradeIsAtomicAcrossReopen proves the pre-route upgrade is a durable | |
| 150 | + | // migration: a state written without routes is upgraded exactly once, a fault at | |
| 151 | + | // any upgrade boundary never leaves an unreadable state behind, and reopening | |
| 152 | + | // completes the upgrade so the restored membership carries canonical routes. | |
| 153 | + | func TestRouteUpgradeIsAtomicAcrossReopen(t *testing.T) { | |
| 154 | + | // seedStripped opens and closes a valid routed store, then rewrites its | |
| 155 | + | // committed state without routes, exactly as a pre-route process would have | |
| 156 | + | // written it. Each case gets its own directory so a completed upgrade in one | |
| 157 | + | // case never hides the migration in the next. | |
| 158 | + | seedStripped := func(t *testing.T) string { | |
| 159 | + | t.Helper() | |
| 160 | + | dir := filepath.Join(t.TempDir(), "broker") | |
| 161 | + | o := options(t) | |
| 162 | + | o.Dir = dir | |
| 163 | + | store, err := Open(o) | |
| 164 | + | require.NoError(t, err) | |
| 165 | + | require.NoError(t, store.Close()) | |
| 166 | + | statePath := filepath.Join(dir, "state.json") | |
| 167 | + | raw, err := os.ReadFile(statePath) | |
| 168 | + | require.NoError(t, err) | |
| 169 | + | var legacyState state | |
| 170 | + | require.NoError(t, strict(raw, &legacyState)) | |
| 171 | + | require.Len(t, legacyState.Hosts.Hosts, 1) | |
| 172 | + | legacyState.Hosts.Hosts[0].Route = ports.BrokerRouteSpec{} | |
| 173 | + | stripped, err := json.Marshal(legacyState) | |
| 174 | + | require.NoError(t, err) | |
| 175 | + | require.NoError(t, os.WriteFile(statePath, stripped, 0600)) | |
| 176 | + | return dir | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | for _, point := range []string{"recovery.json:write", "state.json:write", "state.json:rename", "state.json:dirsync"} { | |
| 180 | + | t.Run(point, func(t *testing.T) { | |
| 181 | + | dir := seedStripped(t) | |
| 182 | + | fault := errors.New("power loss") | |
| 183 | + | failed := Options{Dir: dir, Fault: func(p string) error { | |
| 184 | + | if p == point { | |
| 185 | + | return fault | |
| 186 | + | } | |
| 187 | + | return nil | |
| 188 | + | }} | |
| 189 | + | failedStore, err := Open(failed) | |
| 190 | + | require.ErrorIs(t, err, fault) | |
| 191 | + | require.Nil(t, failedStore) | |
| 192 | + | ||
| 193 | + | // The upgrade is atomic: reopening without the fault always yields a | |
| 194 | + | // readable, routed store, whether or not the failed attempt renamed. | |
| 195 | + | store, err := Open(Options{Dir: dir}) | |
| 196 | + | require.NoError(t, err) | |
| 197 | + | hosts, err := store.LoadHosts() | |
| 198 | + | require.NoError(t, err) | |
| 199 | + | require.Len(t, hosts.Hosts, 1) | |
| 200 | + | require.Equal(t, canonicalQUICRoute("user@arch"), hosts.Hosts[0].Route) | |
| 201 | + | require.NoError(t, hosts.Validate()) | |
| 202 | + | require.NoError(t, store.Close()) | |
| 203 | + | ||
| 204 | + | // The upgrade is idempotent: a further reopen keeps the same authority. | |
| 205 | + | store, err = Open(Options{Dir: dir}) | |
| 206 | + | require.NoError(t, err) | |
| 207 | + | defer store.Close() | |
| 208 | + | again, err := store.LoadHosts() | |
| 209 | + | require.NoError(t, err) | |
| 210 | + | require.Equal(t, hosts, again) | |
| 211 | + | }) | |
| 212 | + | } | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | // TestRouteChangeRequiresFreshGenerationAndRefusesOldFence proves a policy | |
| 216 | + | // change that re-routes an endpoint is admitted only with a fresh registration | |
| 217 | + | // generation, is persisted with the canonical route, and fences the retired | |
| 218 | + | // registration: a publication bound to the old registration is refused. | |
| 219 | + | func TestRouteChangeRequiresFreshGenerationAndRefusesOldFence(t *testing.T) { | |
| 220 | + | o := options(t) | |
| 221 | + | store, err := Open(o) | |
| 222 | + | require.NoError(t, err) | |
| 223 | + | defer store.Close() | |
| 224 | + | h, err := store.LoadHosts() | |
| 225 | + | require.NoError(t, err) | |
| 226 | + | require.Len(t, h.Hosts, 1) | |
| 227 | + | base := h.Hosts[0] | |
| 228 | + | require.Equal(t, canonicalQUICRoute(base.Registration.Endpoint), base.Route) | |
| 229 | + | ||
| 230 | + | stdio := base.Policy | |
| 231 | + | stdio.Transport = "stdio" | |
| 232 | + | ||
| 233 | + | // A re-route without a fresh generation is refused as a stale authority | |
| 234 | + | // change: a queued observation must never be accepted under the new route. | |
| 235 | + | changed := append([]ports.BrokerHostRecord(nil), h.Hosts...) | |
| 236 | + | changed[0].Policy = stdio | |
| 237 | + | require.ErrorIs(t, store.ReplaceHosts(h.Revision, changed), ErrStale) | |
| 238 | + | ||
| 239 | + | // The same change with a fresh generation is accepted and durable, with the | |
| 240 | + | // route the mutation path derives from the new transport. | |
| 241 | + | changed[0].Registration.Generation++ | |
| 242 | + | changed[0].Route = ports.BrokerRouteSpec{Kind: ports.BrokerRouteSSHStdio, Target: base.Registration.Endpoint, Argv: []string{"vev", "_broker-mux-stdio", "--production"}} | |
| 243 | + | require.NoError(t, store.ReplaceHosts(h.Revision, changed)) | |
| 244 | + | after, err := store.LoadHosts() | |
| 245 | + | require.NoError(t, err) | |
| 246 | + | require.Equal(t, base.Registration.Generation+1, after.Hosts[0].Registration.Generation) | |
| 247 | + | require.Equal(t, stdio, after.Hosts[0].Policy) | |
| 248 | + | require.Equal(t, changed[0].Route, after.Hosts[0].Route) | |
| 249 | + | ||
| 250 | + | // The retired registration is fenced: a publication naming it is refused even | |
| 251 | + | // though the endpoint is still live under a newer generation. | |
| 252 | + | retired := ports.BrokerDaemonObservation{ | |
| 253 | + | Endpoint: base.Registration.Endpoint, | |
| 254 | + | DisplayOrigin: domain.RemoteDisplayOrigin(base.Registration.Endpoint), | |
| 255 | + | Registration: base.Registration, | |
| 256 | + | Availability: domain.RemoteAvailabilityReachable, | |
| 257 | + | } | |
| 258 | + | require.ErrorIs(t, store.Store(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{retired}}), ErrStale) | |
| 259 | + | } | |
M internal/adapters/brokerstore/store.go +29 -5
| @@ -13,6 +13,7 @@ import ( | |||
| 13 | 13 | "fmt" | |
| 14 | 14 | "os" | |
| 15 | 15 | "path/filepath" | |
| 16 | + | "reflect" | |
| 16 | 17 | "sync" | |
| 17 | 18 | "syscall" | |
| 18 | 19 | ||
| @@ -68,8 +69,10 @@ type manifestSource struct { | |||
| 68 | 69 | } | |
| 69 | 70 | ||
| 70 | 71 | type manifest struct { | |
| 71 | - | Version int | |
| 72 | - | Sources []manifestSource | |
| 72 | + | Version int | |
| 73 | + | Sources []manifestSource | |
| 74 | + | ImportVersion int | |
| 75 | + | ImportCompleted bool | |
| 73 | 76 | } | |
| 74 | 77 | ||
| 75 | 78 | // source returns the manifest entry for one generic role label. | |
| @@ -88,6 +91,8 @@ func (m manifest) source(label manifestSourceLabel) (manifestSource, bool) { | |||
| 88 | 91 | type Options struct { | |
| 89 | 92 | Dir, LegacyHosts, LegacyCache string | |
| 90 | 93 | Policies map[string]ports.BrokerPolicy | |
| 94 | + | InitialHosts []ports.BrokerHostRecord | |
| 95 | + | InitialImportProvided bool | |
| 91 | 96 | Fault func(string) error | |
| 92 | 97 | } | |
| 93 | 98 | ||
| @@ -183,7 +188,23 @@ func Open(o Options) (*Store, error) { | |||
| 183 | 188 | if err != nil { | |
| 184 | 189 | return nil, err | |
| 185 | 190 | } | |
| 186 | - | if err = validate(s.state); err != nil { | |
| 191 | + | upgraded, upgradeErr := ports.UpgradeBrokerHostRoutes(s.state.Hosts.Hosts) | |
| 192 | + | if upgradeErr != nil { | |
| 193 | + | return nil, upgradeErr | |
| 194 | + | } | |
| 195 | + | if !reflect.DeepEqual(upgraded, s.state.Hosts.Hosts) { | |
| 196 | + | next := s.state | |
| 197 | + | next.Hosts.Hosts = upgraded | |
| 198 | + | next.Manifest.ImportVersion = 1 | |
| 199 | + | next.Manifest.ImportCompleted = true | |
| 200 | + | recovery := recovery{State: next} | |
| 201 | + | if err = s.write("recovery.json", recovery); err != nil { | |
| 202 | + | return nil, err | |
| 203 | + | } | |
| 204 | + | if err = s.commit(next); err != nil { | |
| 205 | + | return nil, err | |
| 206 | + | } | |
| 207 | + | } else if err = validate(s.state); err != nil { | |
| 187 | 208 | return nil, err | |
| 188 | 209 | } | |
| 189 | 210 | ok = true | |
| @@ -240,7 +261,7 @@ func (s *Store) LoadHosts() (ports.BrokerHosts, error) { | |||
| 240 | 261 | return ports.BrokerHosts{}, err | |
| 241 | 262 | } | |
| 242 | 263 | h := s.state.Hosts | |
| 243 | - | h.Hosts = append([]ports.BrokerHostRecord(nil), h.Hosts...) | |
| 264 | + | h.Hosts = ports.CloneBrokerHostRecords(h.Hosts) | |
| 244 | 265 | return h, nil | |
| 245 | 266 | } | |
| 246 | 267 | func (s *Store) Load() (ports.BrokerSnapshot, error) { | |
| @@ -267,7 +288,7 @@ func (s *Store) ReplaceHosts(expected uint64, hosts []ports.BrokerHostRecord) er | |||
| 267 | 288 | return ErrStale | |
| 268 | 289 | } | |
| 269 | 290 | next := s.state | |
| 270 | - | next.Hosts = ports.BrokerHosts{Revision: expected + 1, Hosts: append([]ports.BrokerHostRecord(nil), hosts...)} | |
| 291 | + | next.Hosts = ports.BrokerHosts{Revision: expected + 1, Hosts: ports.CloneBrokerHostRecords(hosts)} | |
| 271 | 292 | // Policy changes require a new registration generation as well; otherwise | |
| 272 | 293 | // an already queued observation could be accepted under changed trust. | |
| 273 | 294 | for _, h := range hosts { | |
| @@ -350,6 +371,9 @@ func validate(st state) error { | |||
| 350 | 371 | if st.Manifest.Version != 1 { | |
| 351 | 372 | return invalid("unsupported manifest") | |
| 352 | 373 | } | |
| 374 | + | if st.Manifest.ImportVersion != 1 || !st.Manifest.ImportCompleted { | |
| 375 | + | return invalid("initial import is incomplete") | |
| 376 | + | } | |
| 353 | 377 | labels := make(map[manifestSourceLabel]bool, len(st.Manifest.Sources)) | |
| 354 | 378 | for _, source := range st.Manifest.Sources { | |
| 355 | 379 | if labels[source.Label] { | |
M internal/adapters/brokerwire/codec.go +38 -1
| @@ -499,6 +499,7 @@ func openStreamToWire(m OpenStream) (*wire.OpenStream, error) { | |||
| 499 | 499 | Connection: m.Connection, Stream: m.Stream, | |
| 500 | 500 | Endpoint: m.Endpoint, Registration: m.Registration, | |
| 501 | 501 | Target: m.Target, Env: m.Env, Policy: m.Policy, | |
| 502 | + | StartMode: m.StartMode, | |
| 502 | 503 | } | |
| 503 | 504 | if err := request.Validate(); err != nil { | |
| 504 | 505 | // Distinguish bound refusals from semantic refusals. | |
| @@ -530,6 +531,10 @@ func openStreamToWire(m OpenStream) (*wire.OpenStream, error) { | |||
| 530 | 531 | if err != nil { | |
| 531 | 532 | return nil, err | |
| 532 | 533 | } | |
| 534 | + | startMode, err := startModeToWire(m.StartMode) | |
| 535 | + | if err != nil { | |
| 536 | + | return nil, err | |
| 537 | + | } | |
| 533 | 538 | out := &wire.OpenStream{ | |
| 534 | 539 | Ref: refToWire(m.Epoch, m.Connection, m.Stream), | |
| 535 | 540 | Purpose: purpose, Local: m.Local, Endpoint: m.Endpoint, | |
| @@ -538,7 +543,7 @@ func openStreamToWire(m OpenStream) (*wire.OpenStream, error) { | |||
| 538 | 543 | // The admission taxonomy mirrors the port values (0 none, 1 exact, | |
| 539 | 544 | // 2 create named, 3 create ephemeral); the name travels only for the | |
| 540 | 545 | // create-named variant. | |
| 541 | - | Admission: admission, Name: m.Name, | |
| 546 | + | Admission: admission, Name: m.Name, StartMode: startMode, | |
| 542 | 547 | } | |
| 543 | 548 | if !m.Local { | |
| 544 | 549 | out.Registration = registrationToWire(m.Registration) | |
| @@ -584,6 +589,32 @@ func admissionFromWire(value uint32) (ports.BrokerStreamAdmission, error) { | |||
| 584 | 589 | } | |
| 585 | 590 | } | |
| 586 | 591 | ||
| 592 | + | // startModeToWire maps the closed daemon-start taxonomy onto its wire code. | |
| 593 | + | // The zero value is refused rather than encoded, so a peer never has to guess | |
| 594 | + | // whether an absent mode authorized a spawn. | |
| 595 | + | func startModeToWire(mode ports.BrokerDaemonStartMode) (uint32, error) { | |
| 596 | + | switch mode { | |
| 597 | + | case ports.BrokerDaemonExistingOnly: | |
| 598 | + | return 1, nil | |
| 599 | + | case ports.BrokerDaemonStartIfNeeded: | |
| 600 | + | return 2, nil | |
| 601 | + | default: | |
| 602 | + | return 0, errConvertRange | |
| 603 | + | } | |
| 604 | + | } | |
| 605 | + | ||
| 606 | + | // startModeFromWire maps a wire daemon-start code onto the closed taxonomy. | |
| 607 | + | func startModeFromWire(value uint32) (ports.BrokerDaemonStartMode, error) { | |
| 608 | + | switch value { | |
| 609 | + | case 1: | |
| 610 | + | return ports.BrokerDaemonExistingOnly, nil | |
| 611 | + | case 2: | |
| 612 | + | return ports.BrokerDaemonStartIfNeeded, nil | |
| 613 | + | default: | |
| 614 | + | return 0, errConvertRange | |
| 615 | + | } | |
| 616 | + | } | |
| 617 | + | ||
| 587 | 618 | func openStreamFromWire(message *wire.OpenStream) (OpenStream, error) { | |
| 588 | 619 | var out OpenStream | |
| 589 | 620 | if message == nil { | |
| @@ -608,6 +639,10 @@ func openStreamFromWire(message *wire.OpenStream) (OpenStream, error) { | |||
| 608 | 639 | if err != nil { | |
| 609 | 640 | return OpenStream{}, ErrInvalidMessage | |
| 610 | 641 | } | |
| 642 | + | startMode, err := startModeFromWire(message.GetStartMode()) | |
| 643 | + | if err != nil { | |
| 644 | + | return OpenStream{}, ErrInvalidMessage | |
| 645 | + | } | |
| 611 | 646 | local := message.GetLocal() | |
| 612 | 647 | var registration domain.RemoteRegistration | |
| 613 | 648 | if !local { | |
| @@ -647,6 +682,7 @@ func openStreamFromWire(message *wire.OpenStream) (OpenStream, error) { | |||
| 647 | 682 | Local: local, | |
| 648 | 683 | Endpoint: endpoint, | |
| 649 | 684 | Registration: registration, Target: target, Env: env, Policy: policy, | |
| 685 | + | StartMode: startMode, | |
| 650 | 686 | } | |
| 651 | 687 | request := ports.BrokerOpenStreamRequest{ | |
| 652 | 688 | Epoch: candidate.Epoch, Purpose: candidate.Purpose, | |
| @@ -654,6 +690,7 @@ func openStreamFromWire(message *wire.OpenStream) (OpenStream, error) { | |||
| 654 | 690 | Connection: candidate.Connection, Stream: candidate.Stream, | |
| 655 | 691 | Endpoint: candidate.Endpoint, Registration: candidate.Registration, | |
| 656 | 692 | Target: candidate.Target, Env: candidate.Env, Policy: candidate.Policy, | |
| 693 | + | StartMode: candidate.StartMode, | |
| 657 | 694 | } | |
| 658 | 695 | if err := request.Validate(); err != nil { | |
| 659 | 696 | return OpenStream{}, ErrInvalidMessage | |
M internal/adapters/brokerwire/codec_test.go +74 -8
| @@ -354,7 +354,7 @@ func TestBrokerClientVariants(t *testing.T) { | |||
| 354 | 354 | {"add_host", AddHost{Epoch: 7, Connection: connection, Operation: operation, Endpoint: "dev@host:22", Policy: testPolicy()}, 105}, | |
| 355 | 355 | {"remove_host", RemoveHost{Epoch: 7, Connection: connection, Operation: operation, Registration: testRegistration()}, 106}, | |
| 356 | 356 | {"reconcile", Reconcile{Epoch: 7, Connection: connection, Registration: testRegistration()}, 107}, | |
| 357 | - | {"open_stream", OpenStream{Epoch: 7, Connection: connection, Stream: 3, Purpose: ports.BrokerStreamAttachment, Admission: ports.BrokerAdmissionExact, Endpoint: "dev@host:22", Registration: testRegistration(), Target: testTarget(), Env: []string{"TERM=xterm"}, Policy: testPolicy()}, 108}, | |
| 357 | + | {"open_stream", OpenStream{Epoch: 7, Connection: connection, Stream: 3, Purpose: ports.BrokerStreamAttachment, Admission: ports.BrokerAdmissionExact, Endpoint: "dev@host:22", Registration: testRegistration(), Target: testTarget(), Env: []string{"TERM=xterm"}, Policy: testPolicy(), StartMode: ports.BrokerDaemonStartIfNeeded}, 108}, | |
| 358 | 358 | {"client_stream_data", ClientStreamData{Epoch: 7, Connection: connection, Stream: 3, Data: []byte("frame")}, 109}, | |
| 359 | 359 | {"close_stream", CloseStream{Epoch: 7, Connection: connection, Stream: 3}, 110}, | |
| 360 | 360 | {"update_host_policy", UpdateHostPolicy{Epoch: 7, Connection: connection, Operation: operation, Registration: testRegistration(), Policy: testPolicy()}, 111}, | |
| @@ -384,6 +384,9 @@ func TestBrokerOpenStreamAdmissionVariants(t *testing.T) { | |||
| 384 | 384 | return OpenStream{ | |
| 385 | 385 | Epoch: 7, Connection: connection, Stream: 3, Purpose: ports.BrokerStreamAttachment, | |
| 386 | 386 | Endpoint: "dev@host:22", Registration: testRegistration(), Policy: testPolicy(), | |
| 387 | + | // An attachment or creation always carries the explicit | |
| 388 | + | // start-if-needed authorization; the zero mode is refused. | |
| 389 | + | StartMode: ports.BrokerDaemonStartIfNeeded, | |
| 387 | 390 | } | |
| 388 | 391 | } | |
| 389 | 392 | roundTrips := []struct { | |
| @@ -409,7 +412,7 @@ func TestBrokerOpenStreamAdmissionVariants(t *testing.T) { | |||
| 409 | 412 | }()}, | |
| 410 | 413 | {"control carries none", OpenStream{ | |
| 411 | 414 | Epoch: 7, Connection: connection, Stream: 3, Purpose: ports.BrokerStreamControl, | |
| 412 | - | Local: true, Policy: testPolicy(), | |
| 415 | + | Local: true, Policy: testPolicy(), StartMode: ports.BrokerDaemonStartIfNeeded, | |
| 413 | 416 | }}, | |
| 414 | 417 | } | |
| 415 | 418 | for _, tc := range roundTrips { | |
| @@ -455,7 +458,17 @@ func TestBrokerOpenStreamAdmissionVariants(t *testing.T) { | |||
| 455 | 458 | }(), | |
| 456 | 459 | "control with admission": OpenStream{ | |
| 457 | 460 | Epoch: 7, Connection: connection, Stream: 3, Purpose: ports.BrokerStreamControl, | |
| 458 | - | Local: true, Policy: testPolicy(), Admission: ports.BrokerAdmissionExact, | |
| 461 | + | Local: true, Policy: testPolicy(), Admission: ports.BrokerAdmissionExact, StartMode: ports.BrokerDaemonStartIfNeeded, | |
| 462 | + | }, | |
| 463 | + | "attachment with zero start mode": func() OpenStream { m := exact(); m.StartMode = 0; return m }(), | |
| 464 | + | "attachment with existing-only start mode": func() OpenStream { | |
| 465 | + | m := exact() | |
| 466 | + | m.StartMode = ports.BrokerDaemonExistingOnly | |
| 467 | + | return m | |
| 468 | + | }(), | |
| 469 | + | "observation with start-if-needed start mode": OpenStream{ | |
| 470 | + | Epoch: 7, Connection: connection, Stream: 3, Purpose: ports.BrokerStreamObservation, | |
| 471 | + | Local: true, Policy: testPolicy(), StartMode: ports.BrokerDaemonStartIfNeeded, | |
| 459 | 472 | }, | |
| 460 | 473 | } | |
| 461 | 474 | for name, message := range cases { | |
| @@ -480,6 +493,59 @@ func TestBrokerOpenStreamAdmissionVariants(t *testing.T) { | |||
| 480 | 493 | _, err = DecodeClient(mutated, testEnvelopeCeiling, testChunkCeiling) | |
| 481 | 494 | require.ErrorIs(t, err, ErrInvalidMessage) | |
| 482 | 495 | }) | |
| 496 | + | ||
| 497 | + | t.Run("start mode round-trips and is never defaulted", func(t *testing.T) { | |
| 498 | + | // The mode round-trips with the purpose it is legal for: an existing-only | |
| 499 | + | // authorization belongs to control/observation, a start-if-needed one to | |
| 500 | + | // attachment/control. | |
| 501 | + | control := func(mode ports.BrokerDaemonStartMode) OpenStream { | |
| 502 | + | return OpenStream{Epoch: 7, Connection: connection, Stream: 3, Purpose: ports.BrokerStreamControl, Local: true, Policy: testPolicy(), StartMode: mode} | |
| 503 | + | } | |
| 504 | + | for _, message := range []OpenStream{ | |
| 505 | + | control(ports.BrokerDaemonExistingOnly), | |
| 506 | + | control(ports.BrokerDaemonStartIfNeeded), | |
| 507 | + | func() OpenStream { | |
| 508 | + | m := base() | |
| 509 | + | m.Admission = ports.BrokerAdmissionExact | |
| 510 | + | m.Target = testTarget() | |
| 511 | + | m.StartMode = ports.BrokerDaemonStartIfNeeded | |
| 512 | + | return m | |
| 513 | + | }(), | |
| 514 | + | } { | |
| 515 | + | decoded, err := DecodeClient(mustEncodeClient(t, message), testEnvelopeCeiling, testChunkCeiling) | |
| 516 | + | require.NoError(t, err) | |
| 517 | + | admitting, ok := decoded.(OpenStream) | |
| 518 | + | require.True(t, ok) | |
| 519 | + | require.Equal(t, message.StartMode, admitting.StartMode) | |
| 520 | + | } | |
| 521 | + | // An absent wire mode is refused, never widened into a spawn-capable | |
| 522 | + | // default, and an unknown code is refused too. | |
| 523 | + | message := base() | |
| 524 | + | message.Admission = ports.BrokerAdmissionExact | |
| 525 | + | message.Target = testTarget() | |
| 526 | + | raw := mustEncodeClient(t, message) | |
| 527 | + | envelope := &wire.BrokerClientEnvelope{} | |
| 528 | + | require.NoError(t, proto.Unmarshal(raw, envelope)) | |
| 529 | + | envelope.GetOpenStream().StartMode = 0 | |
| 530 | + | mutated, err := proto.Marshal(envelope) | |
| 531 | + | require.NoError(t, err) | |
| 532 | + | _, err = DecodeClient(mutated, testEnvelopeCeiling, testChunkCeiling) | |
| 533 | + | require.ErrorIs(t, err, ErrInvalidMessage) | |
| 534 | + | envelope.GetOpenStream().StartMode = 3 | |
| 535 | + | mutated, err = proto.Marshal(envelope) | |
| 536 | + | require.NoError(t, err) | |
| 537 | + | _, err = DecodeClient(mutated, testEnvelopeCeiling, testChunkCeiling) | |
| 538 | + | require.ErrorIs(t, err, ErrInvalidMessage) | |
| 539 | + | // An observation that authorized a spawn is refused rather than narrowed. | |
| 540 | + | envelope.GetOpenStream().Purpose = 3 | |
| 541 | + | envelope.GetOpenStream().StartMode = 2 | |
| 542 | + | envelope.GetOpenStream().Target = nil | |
| 543 | + | envelope.GetOpenStream().Admission = 0 | |
| 544 | + | mutated, err = proto.Marshal(envelope) | |
| 545 | + | require.NoError(t, err) | |
| 546 | + | _, err = DecodeClient(mutated, testEnvelopeCeiling, testChunkCeiling) | |
| 547 | + | require.ErrorIs(t, err, ErrInvalidMessage) | |
| 548 | + | }) | |
| 483 | 549 | } | |
| 484 | 550 | ||
| 485 | 551 | // TestBrokerSnapshotDaemonByteForByte proves the daemon and session parts are | |
| @@ -949,7 +1015,7 @@ func TestBrokerValidationNegatives(t *testing.T) { | |||
| 949 | 1015 | require.ErrorIs(t, err, ErrInvalidMessage) | |
| 950 | 1016 | }) | |
| 951 | 1017 | t.Run("taxonomy switches", func(t *testing.T) { | |
| 952 | - | raw := mustEncodeClient(t, OpenStream{Epoch: 7, Connection: connection, Stream: 3, Purpose: ports.BrokerStreamAttachment, Admission: ports.BrokerAdmissionExact, Endpoint: "dev@host:22", Registration: testRegistration(), Target: testTarget(), Policy: testPolicy()}) | |
| 1018 | + | raw := mustEncodeClient(t, OpenStream{Epoch: 7, Connection: connection, Stream: 3, Purpose: ports.BrokerStreamAttachment, Admission: ports.BrokerAdmissionExact, Endpoint: "dev@host:22", Registration: testRegistration(), Target: testTarget(), Policy: testPolicy(), StartMode: ports.BrokerDaemonStartIfNeeded}) | |
| 953 | 1019 | envelope := &wire.BrokerClientEnvelope{} | |
| 954 | 1020 | require.NoError(t, wire.ScanEnvelope(envelope, raw)) | |
| 955 | 1021 | require.NoError(t, proto.Unmarshal(raw, envelope)) | |
| @@ -981,11 +1047,11 @@ func TestBrokerValidationNegatives(t *testing.T) { | |||
| 981 | 1047 | require.ErrorIs(t, err, ErrInvalidMessage) | |
| 982 | 1048 | }) | |
| 983 | 1049 | t.Run("env without equals refused", func(t *testing.T) { | |
| 984 | - | _, err := EncodeClient(OpenStream{Epoch: 7, Connection: connection, Stream: 3, Purpose: ports.BrokerStreamAttachment, Admission: ports.BrokerAdmissionExact, Endpoint: "dev@host:22", Registration: testRegistration(), Target: testTarget(), Env: []string{"NOEQUALS"}, Policy: testPolicy()}, testEnvelopeCeiling, testChunkCeiling) | |
| 1050 | + | _, err := EncodeClient(OpenStream{Epoch: 7, Connection: connection, Stream: 3, Purpose: ports.BrokerStreamAttachment, Admission: ports.BrokerAdmissionExact, Endpoint: "dev@host:22", Registration: testRegistration(), Target: testTarget(), Env: []string{"NOEQUALS"}, Policy: testPolicy(), StartMode: ports.BrokerDaemonStartIfNeeded}, testEnvelopeCeiling, testChunkCeiling) | |
| 985 | 1051 | require.ErrorIs(t, err, ErrInvalidMessage) | |
| 986 | 1052 | }) | |
| 987 | 1053 | t.Run("invalid policy refused", func(t *testing.T) { | |
| 988 | - | _, err := EncodeClient(OpenStream{Epoch: 7, Connection: connection, Stream: 3, Purpose: ports.BrokerStreamAttachment, Admission: ports.BrokerAdmissionExact, Endpoint: "dev@host:22", Registration: testRegistration(), Target: testTarget(), Policy: ports.BrokerPolicy{}}, testEnvelopeCeiling, testChunkCeiling) | |
| 1054 | + | _, err := EncodeClient(OpenStream{Epoch: 7, Connection: connection, Stream: 3, Purpose: ports.BrokerStreamAttachment, Admission: ports.BrokerAdmissionExact, Endpoint: "dev@host:22", Registration: testRegistration(), Target: testTarget(), Policy: ports.BrokerPolicy{}, StartMode: ports.BrokerDaemonStartIfNeeded}, testEnvelopeCeiling, testChunkCeiling) | |
| 989 | 1055 | require.ErrorIs(t, err, ErrInvalidMessage) | |
| 990 | 1056 | }) | |
| 991 | 1057 | t.Run("snapshot revision zero refused", func(t *testing.T) { | |
| @@ -1075,7 +1141,7 @@ func TestBrokerCorruptInputSmoke(t *testing.T) { | |||
| 1075 | 1141 | mustEncodeClient(t, AddHost{Epoch: 7, Connection: testConnectionID(2), Operation: testOperationID(0x21), Endpoint: "dev@host:22", Policy: testPolicy()}), | |
| 1076 | 1142 | mustEncodeClient(t, RemoveHost{Epoch: 7, Connection: testConnectionID(2), Operation: testOperationID(0x22), Registration: testRegistration()}), | |
| 1077 | 1143 | mustEncodeClient(t, UpdateHostPolicy{Epoch: 7, Connection: testConnectionID(2), Operation: testOperationID(0x23), Registration: testRegistration(), Policy: testPolicy()}), | |
| 1078 | - | mustEncodeClient(t, OpenStream{Epoch: 7, Connection: testConnectionID(2), Stream: 3, Purpose: ports.BrokerStreamControl, Local: true, Policy: testPolicy()}), | |
| 1144 | + | mustEncodeClient(t, OpenStream{Epoch: 7, Connection: testConnectionID(2), Stream: 3, Purpose: ports.BrokerStreamControl, Local: true, Policy: testPolicy(), StartMode: ports.BrokerDaemonStartIfNeeded}), | |
| 1079 | 1145 | mustEncodeServer(t, SnapshotPart{Epoch: 7, Connection: testConnectionID(2), Generation: 1, Revision: 1, Index: 0, Part: SnapshotEnd{}}), | |
| 1080 | 1146 | mustEncodeServer(t, OperationResult{Epoch: 7, Connection: testConnectionID(2), Operation: testOperationID(0x24), Outcome: ports.BrokerOutcomeFailed, Error: testErrorDetail(), HasError: true}), | |
| 1081 | 1147 | mustEncodeServer(t, Shutdown{Epoch: 7, Connection: testConnectionID(2), Reason: ShutdownIdleExit}), | |
| @@ -1108,7 +1174,7 @@ func fuzzClientSeeds(t testing.TB) [][]byte { | |||
| 1108 | 1174 | AddHost{Epoch: 7, Connection: testConnectionID(0x11), Operation: testOperationID(0x11), Endpoint: "dev@host:22", Policy: testPolicy()}, | |
| 1109 | 1175 | RemoveHost{Epoch: 7, Connection: testConnectionID(0x11), Operation: testOperationID(0x12), Registration: testRegistration()}, | |
| 1110 | 1176 | UpdateHostPolicy{Epoch: 7, Connection: testConnectionID(0x11), Operation: testOperationID(0x13), Registration: testRegistration(), Policy: testPolicy()}, | |
| 1111 | - | OpenStream{Epoch: 7, Connection: testConnectionID(0x11), Stream: 3, Purpose: ports.BrokerStreamAttachment, Admission: ports.BrokerAdmissionExact, Endpoint: "dev@host:22", Registration: testRegistration(), Target: testTarget(), Policy: testPolicy()}, | |
| 1177 | + | OpenStream{Epoch: 7, Connection: testConnectionID(0x11), Stream: 3, Purpose: ports.BrokerStreamAttachment, Admission: ports.BrokerAdmissionExact, Endpoint: "dev@host:22", Registration: testRegistration(), Target: testTarget(), Policy: testPolicy(), StartMode: ports.BrokerDaemonStartIfNeeded}, | |
| 1112 | 1178 | ClientStreamData{Epoch: 7, Connection: testConnectionID(0x11), Stream: 3, Data: []byte("frame")}, | |
| 1113 | 1179 | CloseStream{Epoch: 7, Connection: testConnectionID(0x11), Stream: 3}, | |
| 1114 | 1180 | } { | |
M internal/adapters/brokerwire/messages.go +4 -1
| @@ -122,7 +122,9 @@ func (Reconcile) brokerClientMessage() {} | |||
| 122 | 122 | // stream to the owning daemon. Env is the per-request session environment | |
| 123 | 123 | // and is never inherited from the broker process environment. Admission | |
| 124 | 124 | // selects the attachment admission variant; Name is the validated session | |
| 125 | - | // name for create-named and is empty otherwise. | |
| 125 | + | // name for create-named and is empty otherwise. StartMode is the explicit | |
| 126 | + | // daemon-start authorization carried to the transport; it is never zero on an | |
| 127 | + | // admitted request. | |
| 126 | 128 | type OpenStream struct { | |
| 127 | 129 | Epoch ports.BrokerEpoch | |
| 128 | 130 | Connection ports.BrokerConnectionID | |
| @@ -136,6 +138,7 @@ type OpenStream struct { | |||
| 136 | 138 | Target protocol.ExactSessionTarget | |
| 137 | 139 | Env []string | |
| 138 | 140 | Policy ports.BrokerPolicy | |
| 141 | + | StartMode ports.BrokerDaemonStartMode | |
| 139 | 142 | } | |
| 140 | 143 | ||
| 141 | 144 | func (OpenStream) brokerClientMessage() {} | |
M internal/adapters/brokerwire/state.go +27 -12
| @@ -81,9 +81,9 @@ var ( | |||
| 81 | 81 | ErrUnknownOperation = errors.New("brokerwire: unknown operation") | |
| 82 | 82 | // ErrInvalidStream reports a zero stream identity. | |
| 83 | 83 | ErrInvalidStream = errors.New("brokerwire: invalid broker stream") | |
| 84 | - | // ErrStreamIDReused reports a stream identity that is not strictly above | |
| 85 | - | // every allocated identity: stream IDs strictly increase and are never | |
| 86 | - | // reused. | |
| 84 | + | // ErrStreamIDReused reports a stream identity that is already consumed or | |
| 85 | + | // evicted outside the bounded anti-replay window. Stream identities are | |
| 86 | + | // allocated by the calling service and are never reused. | |
| 87 | 87 | ErrStreamIDReused = errors.New("brokerwire: stream ID is not fresh") | |
| 88 | 88 | // ErrFutureStream reports a frame for a stream identity that was never | |
| 89 | 89 | // allocated. | |
| @@ -718,17 +718,22 @@ const ( | |||
| 718 | 718 | ) | |
| 719 | 719 | ||
| 720 | 720 | // StreamTracker bounds the logical streams of one connection scope. Stream | |
| 721 | - | // identities strictly increase and are never reused. At most MaxStreams | |
| 722 | - | // streams are opening or open at once; at most MaxRetiredStreams retired | |
| 723 | - | // records are retained, so an older late frame is still classified as | |
| 724 | - | // retired rather than mistaken for a future stream. It is safe for | |
| 725 | - | // concurrent use. | |
| 721 | + | // identities are allocated by the calling service and may arrive slightly out | |
| 722 | + | // of order when two opens race, so admission uses one bounded anti-replay | |
| 723 | + | // window instead of a bare high-water mark: every unconsumed identity inside | |
| 724 | + | // the window is admitted exactly once, a duplicate is refused, and an identity | |
| 725 | + | // that trails the newest admitted identity by the window size is stale. At most | |
| 726 | + | // MaxStreams streams are opening or open at once; at most MaxRetiredStreams | |
| 727 | + | // retired records are retained, so an older late frame is still classified as | |
| 728 | + | // retired rather than mistaken for a future stream. It is safe for concurrent | |
| 729 | + | // use. | |
| 726 | 730 | type StreamTracker struct { | |
| 727 | 731 | mu sync.Mutex | |
| 728 | 732 | scope Scope | |
| 729 | 733 | states map[ports.BrokerStreamID]StreamState | |
| 730 | 734 | retired []ports.BrokerStreamID | |
| 731 | 735 | highest ports.BrokerStreamID | |
| 736 | + | window ports.BrokerStreamWindow | |
| 732 | 737 | active int | |
| 733 | 738 | } | |
| 734 | 739 | ||
| @@ -743,8 +748,12 @@ func newStreamTracker(scope Scope) (*StreamTracker, error) { | |||
| 743 | 748 | return &StreamTracker{scope: scope, states: make(map[ports.BrokerStreamID]StreamState, MaxStreams)}, nil | |
| 744 | 749 | } | |
| 745 | 750 | ||
| 746 | - | // Open allocates one fresh logical stream in Opening state. The identity must | |
| 747 | - | // be strictly above every allocated identity and must not already be in use. | |
| 751 | + | // Open allocates one fresh logical stream in Opening state. The identity is | |
| 752 | + | // admitted on this connection's bounded anti-replay window: it must be | |
| 753 | + | // unconsumed and inside the window, and it must not already be in use. An | |
| 754 | + | // identity allocated by a concurrent open is therefore admitted even when it | |
| 755 | + | // arrives just after a higher one, while a replay and an identity evicted past | |
| 756 | + | // the window are both refused. | |
| 748 | 757 | func (t *StreamTracker) Open(scope Scope, stream ports.BrokerStreamID) error { | |
| 749 | 758 | if t == nil { | |
| 750 | 759 | return ErrConnectionClosed | |
| @@ -757,14 +766,19 @@ func (t *StreamTracker) Open(scope Scope, stream ports.BrokerStreamID) error { | |||
| 757 | 766 | if err := stream.Validate(); err != nil { | |
| 758 | 767 | return ErrInvalidStream | |
| 759 | 768 | } | |
| 760 | - | if stream <= t.highest { | |
| 769 | + | if err := t.window.Admit(stream); err != nil { | |
| 770 | + | // A duplicate or an identity evicted from the window is stale rather | |
| 771 | + | // than malformed, so the caller can classify it as a never-reused | |
| 772 | + | // identity instead of a protocol violation. | |
| 761 | 773 | return ErrStreamIDReused | |
| 762 | 774 | } | |
| 763 | 775 | if t.active >= MaxStreams { | |
| 764 | 776 | return ErrTooManyStreams | |
| 765 | 777 | } | |
| 766 | 778 | t.states[stream] = StreamOpening | |
| 767 | - | t.highest = stream | |
| 779 | + | if stream > t.highest { | |
| 780 | + | t.highest = stream | |
| 781 | + | } | |
| 768 | 782 | t.active++ | |
| 769 | 783 | return nil | |
| 770 | 784 | } | |
| @@ -909,5 +923,6 @@ func (t *StreamTracker) reset() { | |||
| 909 | 923 | t.states = make(map[ports.BrokerStreamID]StreamState, MaxStreams) | |
| 910 | 924 | t.retired = nil | |
| 911 | 925 | t.highest = 0 | |
| 926 | + | t.window = ports.BrokerStreamWindow{} | |
| 912 | 927 | t.active = 0 | |
| 913 | 928 | } | |
M internal/adapters/brokerwire/state_test.go +18 -4
| @@ -674,8 +674,9 @@ type streamStep struct { | |||
| 674 | 674 | wantErr error | |
| 675 | 675 | } | |
| 676 | 676 | ||
| 677 | - | // TestStreamTrackerOpenRules proves stream identities strictly increase, are | |
| 678 | - | // never reused, and that at most MaxStreams streams are concurrent. | |
| 677 | + | // TestStreamTrackerOpenRules proves a stream identity is admitted at most once | |
| 678 | + | // inside the bounded anti-replay window, that a concurrent lower identity is not | |
| 679 | + | // mistaken for a replay, and that at most MaxStreams streams are concurrent. | |
| 679 | 680 | func TestStreamTrackerOpenRules(t *testing.T) { | |
| 680 | 681 | scope := testBrokerScope() | |
| 681 | 682 | ||
| @@ -685,12 +686,15 @@ func TestStreamTrackerOpenRules(t *testing.T) { | |||
| 685 | 686 | require.ErrorIs(t, tr.Open(scope, 0), ErrInvalidStream) | |
| 686 | 687 | }) | |
| 687 | 688 | ||
| 688 | - | t.Run("strictly increasing and never reused", func(t *testing.T) { | |
| 689 | + | t.Run("each identity consumed at most once", func(t *testing.T) { | |
| 689 | 690 | tr, err := newStreamTracker(scope) | |
| 690 | 691 | require.NoError(t, err) | |
| 691 | 692 | require.NoError(t, tr.Open(scope, 5)) | |
| 692 | 693 | require.ErrorIs(t, tr.Open(scope, 5), ErrStreamIDReused) | |
| 693 | - | require.ErrorIs(t, tr.Open(scope, 1), ErrStreamIDReused) | |
| 694 | + | // A concurrent lower identity that was allocated first is admitted, not | |
| 695 | + | // refused as a replay: two racing opens may be scheduled in either | |
| 696 | + | // order. | |
| 697 | + | require.NoError(t, tr.Open(scope, 1)) | |
| 694 | 698 | require.NoError(t, tr.Opened(scope, 5)) | |
| 695 | 699 | require.NoError(t, tr.Open(scope, 6)) | |
| 696 | 700 | if disposition, err := tr.Close(scope, 5); err != nil || disposition != StreamAccepted { | |
| @@ -698,9 +702,19 @@ func TestStreamTrackerOpenRules(t *testing.T) { | |||
| 698 | 702 | } | |
| 699 | 703 | require.ErrorIs(t, tr.Open(scope, 5), ErrStreamIDReused) | |
| 700 | 704 | require.ErrorIs(t, tr.Open(scope, 6), ErrStreamIDReused) | |
| 705 | + | require.ErrorIs(t, tr.Open(scope, 1), ErrStreamIDReused) | |
| 701 | 706 | require.NoError(t, tr.Open(scope, 7)) | |
| 702 | 707 | }) | |
| 703 | 708 | ||
| 709 | + | t.Run("identity evicted past the window is stale", func(t *testing.T) { | |
| 710 | + | tr, err := newStreamTracker(scope) | |
| 711 | + | require.NoError(t, err) | |
| 712 | + | require.NoError(t, tr.Open(scope, 1)) | |
| 713 | + | require.NoError(t, tr.Open(scope, ports.BrokerStreamWindowSize+1)) | |
| 714 | + | require.ErrorIs(t, tr.Open(scope, 1), ErrStreamIDReused, | |
| 715 | + | "an identity that trails the newest admitted one by the window is stale") | |
| 716 | + | }) | |
| 717 | + | ||
| 704 | 718 | t.Run("concurrent ceiling", func(t *testing.T) { | |
| 705 | 719 | tr, err := newStreamTracker(scope) | |
| 706 | 720 | require.NoError(t, err) | |
M internal/adapters/config/config.go +0 -29
| @@ -37,7 +37,6 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) { | |||
| 37 | 37 | seenTabsKeys := make(map[string]bool) | |
| 38 | 38 | seenScrollbackKeys := make(map[string]bool) | |
| 39 | 39 | seenWebKeys := make(map[string]bool) | |
| 40 | - | seenRemoteKeys := make(map[string]bool) | |
| 41 | 40 | ||
| 42 | 41 | scanner := bufio.NewScanner(r) | |
| 43 | 42 | lineNo := 0 | |
| @@ -65,34 +64,6 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) { | |||
| 65 | 64 | } | |
| 66 | 65 | ||
| 67 | 66 | switch { | |
| 68 | - | case key == "remote.attachment-cache": | |
| 69 | - | warnings = warnDuplicateKey(warnings, seenRemoteKeys, key, lineNo) | |
| 70 | - | on, ok := parseOnOff(value) | |
| 71 | - | if !ok { | |
| 72 | - | warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid %s %q", key, value)}) | |
| 73 | - | continue | |
| 74 | - | } | |
| 75 | - | cfg.AttachmentCache.Enabled = on | |
| 76 | - | case key == "remote.attachment-cache-capacity": | |
| 77 | - | warnings = warnDuplicateKey(warnings, seenRemoteKeys, key, lineNo) | |
| 78 | - | capacity, err := strconv.Atoi(value) | |
| 79 | - | if err != nil || capacity <= 0 { | |
| 80 | - | warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid %s %q", key, value)}) | |
| 81 | - | continue | |
| 82 | - | } | |
| 83 | - | cfg.AttachmentCache.Capacity = capacity | |
| 84 | - | case key == "remote.attachment-cache-idle-timeout": | |
| 85 | - | warnings = warnDuplicateKey(warnings, seenRemoteKeys, key, lineNo) | |
| 86 | - | if strings.EqualFold(value, "off") { | |
| 87 | - | cfg.AttachmentCache.IdleTimeout = 0 | |
| 88 | - | continue | |
| 89 | - | } | |
| 90 | - | timeout, err := time.ParseDuration(value) | |
| 91 | - | if err != nil || timeout <= 0 { | |
| 92 | - | warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid %s %q", key, value)}) | |
| 93 | - | continue | |
| 94 | - | } | |
| 95 | - | cfg.AttachmentCache.IdleTimeout = timeout | |
| 96 | 67 | case key == "web.listen" || key == "web.origin": | |
| 97 | 68 | warnings = warnDuplicateKey(warnings, seenWebKeys, key, lineNo) | |
| 98 | 69 | // Keep raw values for fail-closed validation at gateway startup. | |
M internal/adapters/config/config_test.go +0 -1
| @@ -359,7 +359,6 @@ func TestParse(t *testing.T) { | |||
| 359 | 359 | tt.want.Palette = domain.Defaults().Palette | |
| 360 | 360 | } | |
| 361 | 361 | tt.want.Scrollback = domain.DefaultScrollbackConfig() | |
| 362 | - | tt.want.AttachmentCache = domain.DefaultAttachmentCacheConfig() | |
| 363 | 362 | got, warnings, err := Parse(strings.NewReader(tt.input)) | |
| 364 | 363 | if err != nil { | |
| 365 | 364 | t.Fatalf("Parse() error = %v", err) | |
D internal/adapters/config/remote_cache_test.go +0 -46
| @@ -1,46 +0,0 @@ | |||
| 1 | - | package config | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "strings" | |
| 5 | - | "testing" | |
| 6 | - | "time" | |
| 7 | - | ||
| 8 | - | "github.com/bnema/vev/internal/domain" | |
| 9 | - | "github.com/stretchr/testify/require" | |
| 10 | - | ) | |
| 11 | - | ||
| 12 | - | func TestParseAttachmentCacheConfig(t *testing.T) { | |
| 13 | - | for _, tt := range []struct { | |
| 14 | - | name string | |
| 15 | - | input string | |
| 16 | - | want domain.AttachmentCacheConfig | |
| 17 | - | warnings int | |
| 18 | - | }{ | |
| 19 | - | {"default", "", domain.DefaultAttachmentCacheConfig(), 0}, | |
| 20 | - | {"enabled", "remote.attachment-cache = on", domain.AttachmentCacheConfig{Enabled: true, Capacity: 8}, 0}, | |
| 21 | - | {"disabled", "remote.attachment-cache = OFF", domain.AttachmentCacheConfig{Enabled: false, Capacity: 8}, 0}, | |
| 22 | - | {"capacity", "remote.attachment-cache-capacity = 4", domain.AttachmentCacheConfig{Enabled: true, Capacity: 4}, 0}, | |
| 23 | - | {"idle timeout", "remote.attachment-cache-idle-timeout = 30s", domain.AttachmentCacheConfig{Enabled: true, Capacity: 8, IdleTimeout: 30 * time.Second}, 0}, | |
| 24 | - | {"idle timeout off", "remote.attachment-cache-idle-timeout = Off", domain.DefaultAttachmentCacheConfig(), 0}, | |
| 25 | - | {"all keys", "remote.attachment-cache = on\nremote.attachment-cache-capacity = 2\nremote.attachment-cache-idle-timeout = 90s", domain.AttachmentCacheConfig{Enabled: true, Capacity: 2, IdleTimeout: 90 * time.Second}, 0}, | |
| 26 | - | {"malformed boolean", "remote.attachment-cache = sometimes", domain.DefaultAttachmentCacheConfig(), 1}, | |
| 27 | - | {"malformed capacity", "remote.attachment-cache-capacity = eight", domain.DefaultAttachmentCacheConfig(), 1}, | |
| 28 | - | {"zero capacity", "remote.attachment-cache-capacity = 0", domain.DefaultAttachmentCacheConfig(), 1}, | |
| 29 | - | {"negative capacity", "remote.attachment-cache-capacity = -3", domain.DefaultAttachmentCacheConfig(), 1}, | |
| 30 | - | {"malformed timeout", "remote.attachment-cache-idle-timeout = forever", domain.DefaultAttachmentCacheConfig(), 1}, | |
| 31 | - | {"zero timeout", "remote.attachment-cache-idle-timeout = 0s", domain.DefaultAttachmentCacheConfig(), 1}, | |
| 32 | - | {"negative timeout", "remote.attachment-cache-idle-timeout = -5s", domain.DefaultAttachmentCacheConfig(), 1}, | |
| 33 | - | {"overflow timeout", "remote.attachment-cache-idle-timeout = 999999999999999h", domain.DefaultAttachmentCacheConfig(), 1}, | |
| 34 | - | {"duplicate", "remote.attachment-cache-capacity = 2\nremote.attachment-cache-capacity = 3", domain.AttachmentCacheConfig{Enabled: true, Capacity: 3}, 1}, | |
| 35 | - | {"invalid keeps previous", "remote.attachment-cache-capacity = 4\nremote.attachment-cache-capacity = broken", domain.AttachmentCacheConfig{Enabled: true, Capacity: 4}, 2}, | |
| 36 | - | {"disabled with values", "remote.attachment-cache = off\nremote.attachment-cache-capacity = 2\nremote.attachment-cache-idle-timeout = 1m", domain.AttachmentCacheConfig{Enabled: false, Capacity: 2, IdleTimeout: time.Minute}, 0}, | |
| 37 | - | } { | |
| 38 | - | t.Run(tt.name, func(t *testing.T) { | |
| 39 | - | cfg, warnings, err := Parse(strings.NewReader(tt.input)) | |
| 40 | - | require.NoError(t, err) | |
| 41 | - | require.Equal(t, tt.want, cfg.AttachmentCache) | |
| 42 | - | require.Len(t, warnings, tt.warnings) | |
| 43 | - | require.Empty(t, cfg.BindingEntries) | |
| 44 | - | }) | |
| 45 | - | } | |
| 46 | - | } | |
A internal/adapters/daemonidentity/identity.go +121 -0
| @@ -0,0 +1,121 @@ | |||
| 1 | + | // Package daemonidentity persists the stable authenticated identity of the | |
| 2 | + | // per-user daemon. Only daemon-owned startup may create it; readers never do. | |
| 3 | + | package daemonidentity | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "crypto/rand" | |
| 7 | + | "encoding/hex" | |
| 8 | + | "errors" | |
| 9 | + | "fmt" | |
| 10 | + | "io" | |
| 11 | + | "os" | |
| 12 | + | "path/filepath" | |
| 13 | + | "syscall" | |
| 14 | + | ||
| 15 | + | "github.com/bnema/vev/internal/ports" | |
| 16 | + | "github.com/bnema/vev/pkg/safedir" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | const ( | |
| 20 | + | DirName = "daemon" | |
| 21 | + | FileName = "identity" | |
| 22 | + | encodedSize = 32 | |
| 23 | + | ) | |
| 24 | + | ||
| 25 | + | var ErrInvalid = errors.New("daemonidentity: invalid identity file") | |
| 26 | + | ||
| 27 | + | func Path(stateDir string) string { return filepath.Join(stateDir, DirName, FileName) } | |
| 28 | + | ||
| 29 | + | // Load reads an existing identity without creating any filesystem object. | |
| 30 | + | func Load(stateDir string) (ports.BrokerDaemonIdentity, error) { | |
| 31 | + | path := Path(stateDir) | |
| 32 | + | info, err := os.Lstat(path) | |
| 33 | + | if err != nil { | |
| 34 | + | return "", err | |
| 35 | + | } | |
| 36 | + | if !info.Mode().IsRegular() || info.Mode().Perm() != 0o600 { | |
| 37 | + | return "", ErrInvalid | |
| 38 | + | } | |
| 39 | + | st, ok := info.Sys().(*syscall.Stat_t) | |
| 40 | + | if !ok || int(st.Uid) != os.Getuid() { | |
| 41 | + | return "", ErrInvalid | |
| 42 | + | } | |
| 43 | + | f, err := os.OpenFile(path, os.O_RDONLY|syscall.O_NOFOLLOW, 0) | |
| 44 | + | if err != nil { | |
| 45 | + | return "", fmt.Errorf("%w: %v", ErrInvalid, err) | |
| 46 | + | } | |
| 47 | + | defer f.Close() | |
| 48 | + | data, err := io.ReadAll(io.LimitReader(f, encodedSize+1)) | |
| 49 | + | if err != nil || len(data) != encodedSize { | |
| 50 | + | return "", ErrInvalid | |
| 51 | + | } | |
| 52 | + | decoded := make([]byte, 16) | |
| 53 | + | if _, err := hex.Decode(decoded, data); err != nil { | |
| 54 | + | return "", ErrInvalid | |
| 55 | + | } | |
| 56 | + | id := ports.BrokerDaemonIdentity(string(data)) | |
| 57 | + | if err := id.Validate(); err != nil { | |
| 58 | + | return "", ErrInvalid | |
| 59 | + | } | |
| 60 | + | return id, nil | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | // LoadOrCreate is daemon-owner-only creation. It writes a fresh random value | |
| 64 | + | // through an owner-only temporary file and atomically publishes it. | |
| 65 | + | func LoadOrCreate(stateDir string) (ports.BrokerDaemonIdentity, error) { | |
| 66 | + | id, err := Load(stateDir) | |
| 67 | + | if err == nil { | |
| 68 | + | return id, nil | |
| 69 | + | } | |
| 70 | + | if !errors.Is(err, os.ErrNotExist) { | |
| 71 | + | return "", err | |
| 72 | + | } | |
| 73 | + | dir := filepath.Dir(Path(stateDir)) | |
| 74 | + | if err := safedir.EnsurePrivate(dir); err != nil { | |
| 75 | + | return "", err | |
| 76 | + | } | |
| 77 | + | var raw [16]byte | |
| 78 | + | if _, err := io.ReadFull(rand.Reader, raw[:]); err != nil { | |
| 79 | + | return "", err | |
| 80 | + | } | |
| 81 | + | data := []byte(hex.EncodeToString(raw[:])) | |
| 82 | + | tmp, err := os.CreateTemp(dir, ".identity-") | |
| 83 | + | if err != nil { | |
| 84 | + | return "", err | |
| 85 | + | } | |
| 86 | + | tmpName := tmp.Name() | |
| 87 | + | defer os.Remove(tmpName) | |
| 88 | + | if err = tmp.Chmod(0o600); err == nil { | |
| 89 | + | _, err = tmp.Write(data) | |
| 90 | + | } | |
| 91 | + | if err == nil { | |
| 92 | + | err = tmp.Sync() | |
| 93 | + | } | |
| 94 | + | if closeErr := tmp.Close(); err == nil { | |
| 95 | + | err = closeErr | |
| 96 | + | } | |
| 97 | + | if err != nil { | |
| 98 | + | return "", err | |
| 99 | + | } | |
| 100 | + | if err := os.Rename(tmpName, Path(stateDir)); err != nil { | |
| 101 | + | return "", err | |
| 102 | + | } | |
| 103 | + | d, err := os.Open(dir) | |
| 104 | + | if err == nil { | |
| 105 | + | err = d.Sync() | |
| 106 | + | _ = d.Close() | |
| 107 | + | } | |
| 108 | + | if err != nil { | |
| 109 | + | return "", err | |
| 110 | + | } | |
| 111 | + | return Load(stateDir) | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | func NewIncarnation() (ports.BrokerDaemonIncarnation, error) { | |
| 115 | + | var id ports.BrokerDaemonIncarnation | |
| 116 | + | _, err := io.ReadFull(rand.Reader, id[:]) | |
| 117 | + | if err == nil && id.IsZero() { | |
| 118 | + | return NewIncarnation() | |
| 119 | + | } | |
| 120 | + | return id, err | |
| 121 | + | } | |
A internal/adapters/daemonidentity/identity_test.go +84 -0
| @@ -0,0 +1,84 @@ | |||
| 1 | + | package daemonidentity | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "os" | |
| 6 | + | "path/filepath" | |
| 7 | + | "testing" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestLoadOrCreateStableAndIncarnationFresh(t *testing.T) { | |
| 11 | + | root := t.TempDir() | |
| 12 | + | first, err := LoadOrCreate(root) | |
| 13 | + | if err != nil { | |
| 14 | + | t.Fatal(err) | |
| 15 | + | } | |
| 16 | + | second, err := LoadOrCreate(root) | |
| 17 | + | if err != nil { | |
| 18 | + | t.Fatal(err) | |
| 19 | + | } | |
| 20 | + | if first != second { | |
| 21 | + | t.Fatalf("identity changed: %q != %q", first, second) | |
| 22 | + | } | |
| 23 | + | a, err := NewIncarnation() | |
| 24 | + | if err != nil { | |
| 25 | + | t.Fatal(err) | |
| 26 | + | } | |
| 27 | + | b, err := NewIncarnation() | |
| 28 | + | if err != nil { | |
| 29 | + | t.Fatal(err) | |
| 30 | + | } | |
| 31 | + | if a.IsZero() || b.IsZero() || a == b { | |
| 32 | + | t.Fatalf("incarnations not fresh: %x %x", a, b) | |
| 33 | + | } | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | func TestLoadFailsClosed(t *testing.T) { | |
| 37 | + | for _, tc := range []struct { | |
| 38 | + | name string | |
| 39 | + | prepare func(string) error | |
| 40 | + | }{ | |
| 41 | + | {"corrupt", func(root string) error { | |
| 42 | + | if err := os.Mkdir(filepath.Dir(Path(root)), 0o700); err != nil { | |
| 43 | + | return err | |
| 44 | + | } | |
| 45 | + | return os.WriteFile(Path(root), []byte("bad"), 0o600) | |
| 46 | + | }}, | |
| 47 | + | {"permissions", func(root string) error { | |
| 48 | + | if err := os.Mkdir(filepath.Dir(Path(root)), 0o700); err != nil { | |
| 49 | + | return err | |
| 50 | + | } | |
| 51 | + | return os.WriteFile(Path(root), []byte("00112233445566778899aabbccddeeff"), 0o644) | |
| 52 | + | }}, | |
| 53 | + | {"symlink", func(root string) error { | |
| 54 | + | if err := os.Mkdir(filepath.Dir(Path(root)), 0o700); err != nil { | |
| 55 | + | return err | |
| 56 | + | } | |
| 57 | + | target := filepath.Join(root, "target") | |
| 58 | + | if err := os.WriteFile(target, []byte("00112233445566778899aabbccddeeff"), 0o600); err != nil { | |
| 59 | + | return err | |
| 60 | + | } | |
| 61 | + | return os.Symlink(target, Path(root)) | |
| 62 | + | }}, | |
| 63 | + | } { | |
| 64 | + | t.Run(tc.name, func(t *testing.T) { | |
| 65 | + | root := t.TempDir() | |
| 66 | + | if err := tc.prepare(root); err != nil { | |
| 67 | + | t.Fatal(err) | |
| 68 | + | } | |
| 69 | + | if _, err := Load(root); !errors.Is(err, ErrInvalid) { | |
| 70 | + | t.Fatalf("error=%v, want ErrInvalid", err) | |
| 71 | + | } | |
| 72 | + | }) | |
| 73 | + | } | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | func TestLoadAbsentDoesNotCreate(t *testing.T) { | |
| 77 | + | root := filepath.Join(t.TempDir(), "absent") | |
| 78 | + | if _, err := Load(root); !errors.Is(err, os.ErrNotExist) { | |
| 79 | + | t.Fatalf("error=%v", err) | |
| 80 | + | } | |
| 81 | + | if _, err := os.Stat(root); !errors.Is(err, os.ErrNotExist) { | |
| 82 | + | t.Fatalf("read created state: %v", err) | |
| 83 | + | } | |
| 84 | + | } | |
M internal/adapters/daemonmux/admission_conversion_test.go +1 -0
| @@ -27,6 +27,7 @@ func TestOpenMessageCarriesAttachmentAdmission(t *testing.T) { | |||
| 27 | 27 | Connection: ports.BrokerConnectionID{1}, | |
| 28 | 28 | Stream: ports.BrokerStreamID(1), | |
| 29 | 29 | Policy: logicalTestPolicy(), | |
| 30 | + | StartMode: ports.BrokerDaemonStartIfNeeded, | |
| 30 | 31 | } | |
| 31 | 32 | target := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "alpha"} | |
| 32 | 33 | ||
A internal/adapters/daemonmux/admission_test.go +233 -0
| @@ -0,0 +1,233 @@ | |||
| 1 | + | package daemonmux | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "testing" | |
| 6 | + | "time" | |
| 7 | + | ||
| 8 | + | "github.com/stretchr/testify/require" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/vev/internal/domain" | |
| 11 | + | "github.com/bnema/vev/internal/ports" | |
| 12 | + | "github.com/bnema/vev/internal/protocol" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | // attachmentRequest builds one valid local attachment Open under policy for the | |
| 16 | + | // supplied admission variant, name, target, and environment, so a listener test | |
| 17 | + | // exercises the exact metadata a real attachment carries. | |
| 18 | + | func attachmentRequest(stream int, policy ports.BrokerPolicy, admission ports.BrokerStreamAdmission, name string, target protocol.ExactSessionTarget, env []string) ports.BrokerOpenStreamRequest { | |
| 19 | + | return ports.BrokerOpenStreamRequest{ | |
| 20 | + | Epoch: 1, | |
| 21 | + | Purpose: ports.BrokerStreamAttachment, | |
| 22 | + | Admission: admission, | |
| 23 | + | Name: name, | |
| 24 | + | Local: true, | |
| 25 | + | Connection: ports.BrokerConnectionID{1}, | |
| 26 | + | Stream: ports.BrokerStreamID(stream), | |
| 27 | + | Target: target, | |
| 28 | + | Env: append([]string(nil), env...), | |
| 29 | + | Policy: policy, | |
| 30 | + | StartMode: ports.BrokerDaemonStartIfNeeded, | |
| 31 | + | } | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | // admittedConnection opens one request over the harness while a concurrent | |
| 35 | + | // Accept confirms it, returning the accepted typed daemon-side connection. A | |
| 36 | + | // listener confirms Opened only as Accept delivers the stream, so the two must | |
| 37 | + | // run together. | |
| 38 | + | func admittedConnection(t *testing.T, h *listenerHarness, request ports.BrokerOpenStreamRequest) *ListenerConnection { | |
| 39 | + | t.Helper() | |
| 40 | + | accepted := h.acceptAsync() | |
| 41 | + | ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) | |
| 42 | + | defer cancel() | |
| 43 | + | _, err := h.connector.Open(ctx, request) | |
| 44 | + | require.NoError(t, err) | |
| 45 | + | return h.awaitAccept(accepted) | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | // TestListenerStampsProvisionedOrigin proves the accepted connection's admission | |
| 49 | + | // comes from the provisioned binding, not the peer's Open: a listener | |
| 50 | + | // provisioned as local stamps Origin=Local with the accepted policy, and every | |
| 51 | + | // declared field of the Open is carried verbatim. | |
| 52 | + | func TestListenerStampsProvisionedOrigin(t *testing.T) { | |
| 53 | + | policy := logicalTestPolicy() | |
| 54 | + | h := newListenerHarness(t, listenerIdleBudget, MaxAcceptQueue) | |
| 55 | + | ||
| 56 | + | // A local control stream carries no attachment state. | |
| 57 | + | connection := admittedConnection(t, h, controlRequest(1)) | |
| 58 | + | admission, ok := connection.SessionAdmission() | |
| 59 | + | require.True(t, ok) | |
| 60 | + | require.Equal(t, ports.SessionOriginLocal, admission.Origin, "the provisioned locality is stamped") | |
| 61 | + | require.Equal(t, policy, admission.Policy, "the accepted policy is the provisioned one") | |
| 62 | + | require.Equal(t, ports.BrokerStreamControl, admission.Purpose) | |
| 63 | + | require.Zero(t, admission.Admission) | |
| 64 | + | require.Empty(t, admission.Env) | |
| 65 | + | require.NoError(t, admission.Validate()) | |
| 66 | + | ||
| 67 | + | // An attachment stream carries its closed variant, validated name, exact | |
| 68 | + | // target, and bounded environment. | |
| 69 | + | target := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{7}, SessionName: "work"} | |
| 70 | + | attach := admittedConnection(t, h, attachmentRequest(2, policy, ports.BrokerAdmissionExact, "", target, []string{"TERM=xterm-256color"})) | |
| 71 | + | attachAdmission, ok := attach.SessionAdmission() | |
| 72 | + | require.True(t, ok) | |
| 73 | + | require.Equal(t, ports.SessionOriginLocal, attachAdmission.Origin) | |
| 74 | + | require.Equal(t, ports.BrokerStreamAttachment, attachAdmission.Purpose) | |
| 75 | + | require.Equal(t, ports.BrokerAdmissionExact, attachAdmission.Admission) | |
| 76 | + | require.Equal(t, target, attachAdmission.Target) | |
| 77 | + | require.Equal(t, []string{"TERM=xterm-256color"}, attachAdmission.Env) | |
| 78 | + | require.NoError(t, attachAdmission.Validate()) | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | // TestListenerRefusesLiarLocalOrigin proves the accepted origin is enforced, not | |
| 82 | + | // echoed: a listener provisioned for a remote carriage refuses a local Open (and | |
| 83 | + | // one provisioned local refuses a remote Open) exactly on its own stream, so a | |
| 84 | + | // peer can never stamp a provenance the accepting side did not provision. | |
| 85 | + | func TestListenerRefusesLiarLocalOrigin(t *testing.T) { | |
| 86 | + | policy := logicalTestPolicy() | |
| 87 | + | ||
| 88 | + | t.Run("remote carriage refuses a local Open", func(t *testing.T) { | |
| 89 | + | brokerPump, daemonPump, start := newPairedPumpsUnstarted(t, DefaultMuxCeilings()) | |
| 90 | + | clock := newListenerClock(time.Now()) | |
| 91 | + | listener, err := newListener(daemonPump, listenerIdleBudget, MaxAcceptQueue, clock, remoteAcceptance(policy)) | |
| 92 | + | require.NoError(t, err) | |
| 93 | + | t.Cleanup(func() { _ = listener.Close() }) | |
| 94 | + | start() | |
| 95 | + | connector := mustConnector(t, brokerPump) | |
| 96 | + | ||
| 97 | + | ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) | |
| 98 | + | defer cancel() | |
| 99 | + | _, err = connector.Open(ctx, controlRequest(1)) | |
| 100 | + | require.Error(t, err, "a local Open is refused by a remote carriage") | |
| 101 | + | ||
| 102 | + | refused := requireStreamState(t, daemonPump, 1, StreamTerminal) | |
| 103 | + | require.Equal(t, domain.RemoteFailureTransport, refused.FailureKind) | |
| 104 | + | var detail ports.BrokerError | |
| 105 | + | require.ErrorAs(t, refused.Err, &detail) | |
| 106 | + | require.Equal(t, invalidAdmissionText, detail.Text) | |
| 107 | + | ||
| 108 | + | // Nothing was queued for the daemon: a refused Open is never delivered. | |
| 109 | + | listener.mu.Lock() | |
| 110 | + | queued := len(listener.queue) | |
| 111 | + | listener.mu.Unlock() | |
| 112 | + | require.Zero(t, queued, "a refused Open is never queued") | |
| 113 | + | }) | |
| 114 | + | ||
| 115 | + | t.Run("local carriage refuses a remote Open", func(t *testing.T) { | |
| 116 | + | h := newListenerHarness(t, listenerIdleBudget, MaxAcceptQueue) | |
| 117 | + | remote := controlRequest(1) | |
| 118 | + | remote.Local = false | |
| 119 | + | remote.Endpoint = "dev@host:22" | |
| 120 | + | remote.Registration = testRegistration() | |
| 121 | + | ||
| 122 | + | ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) | |
| 123 | + | defer cancel() | |
| 124 | + | _, err := h.connector.Open(ctx, remote) | |
| 125 | + | require.Error(t, err, "a remote Open is refused by a local carriage") | |
| 126 | + | ||
| 127 | + | refused := requireStreamState(t, h.daemonPump, 1, StreamTerminal) | |
| 128 | + | var detail ports.BrokerError | |
| 129 | + | require.ErrorAs(t, refused.Err, &detail) | |
| 130 | + | require.Equal(t, invalidAdmissionText, detail.Text) | |
| 131 | + | }) | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | // TestListenerAdmissionIsolationAndDeepCopy proves every accepted connection | |
| 135 | + | // owns an independent admission: mutating one connection's returned copy never | |
| 136 | + | // changes a sibling's, and concurrent streams each receive exactly the metadata | |
| 137 | + | // their own Open declared. | |
| 138 | + | func TestListenerAdmissionIsolationAndDeepCopy(t *testing.T) { | |
| 139 | + | policy := logicalTestPolicy() | |
| 140 | + | h := newListenerHarness(t, listenerIdleBudget, MaxAcceptQueue) | |
| 141 | + | ||
| 142 | + | first := admittedConnection(t, h, attachmentRequest(1, policy, ports.BrokerAdmissionCreateNamed, "alpha", protocol.ExactSessionTarget{}, []string{"TERM=one"})) | |
| 143 | + | ||
| 144 | + | // The provider hands out a defensive copy: mutating it must not alter what | |
| 145 | + | // the connection (or a later read) reports. | |
| 146 | + | mutated, ok := first.SessionAdmission() | |
| 147 | + | require.True(t, ok) | |
| 148 | + | mutated.Env[0] = "TERM=mutated" | |
| 149 | + | mutated.Name = "mutated" | |
| 150 | + | mutated.Origin = ports.SessionOriginRemote | |
| 151 | + | reread, ok := first.SessionAdmission() | |
| 152 | + | require.True(t, ok) | |
| 153 | + | require.Equal(t, "alpha", reread.Name) | |
| 154 | + | require.Equal(t, ports.SessionOriginLocal, reread.Origin) | |
| 155 | + | require.Equal(t, []string{"TERM=one"}, reread.Env) | |
| 156 | + | ||
| 157 | + | // Two reads never share the same backing slice. | |
| 158 | + | left, _ := first.SessionAdmission() | |
| 159 | + | right, _ := first.SessionAdmission() | |
| 160 | + | left.Env[0] = "TERM=left" | |
| 161 | + | require.Equal(t, "TERM=one", right.Env[0]) | |
| 162 | + | ||
| 163 | + | // A sibling stream carries only its own declaration; the first's mutation | |
| 164 | + | // never leaks into it. | |
| 165 | + | second := admittedConnection(t, h, attachmentRequest(2, policy, ports.BrokerAdmissionCreateEphemeral, "", protocol.ExactSessionTarget{}, nil)) | |
| 166 | + | secondAdmission, ok := second.SessionAdmission() | |
| 167 | + | require.True(t, ok) | |
| 168 | + | require.Equal(t, ports.BrokerAdmissionCreateEphemeral, secondAdmission.Admission) | |
| 169 | + | require.Empty(t, secondAdmission.Name) | |
| 170 | + | require.Empty(t, secondAdmission.Env) | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | // TestListenerConcurrentAdmissionsKeepDistinctMetadata proves the listener's | |
| 174 | + | // per-stream admission stamp is per stream under concurrency: every admitted | |
| 175 | + | // attachment receives exactly the name, target, and environment its own Open | |
| 176 | + | // declared, and no stream sees a sibling's. | |
| 177 | + | func TestListenerConcurrentAdmissionsKeepDistinctMetadata(t *testing.T) { | |
| 178 | + | policy := logicalTestPolicy() | |
| 179 | + | h := newListenerHarness(t, listenerIdleBudget, 64) | |
| 180 | + | ||
| 181 | + | const streams = 24 | |
| 182 | + | type expectation struct { | |
| 183 | + | admission ports.SessionAdmission | |
| 184 | + | } | |
| 185 | + | want := make(map[ports.BrokerStreamID]expectation, streams) | |
| 186 | + | opened := make(chan *ListenerConnection, streams) | |
| 187 | + | openErrs := make(chan error, streams) | |
| 188 | + | for i := 0; i < streams; i++ { | |
| 189 | + | stream := ports.BrokerStreamID(i + 1) | |
| 190 | + | name := "" | |
| 191 | + | if i%2 == 0 { | |
| 192 | + | name = []string{"alpha", "beta", "gamma"}[i%3] | |
| 193 | + | } | |
| 194 | + | env := []string{[]string{"TERM=first", "TERM=second", "TERM=third"}[i%3]} | |
| 195 | + | admission := ports.BrokerAdmissionCreateNamed | |
| 196 | + | if name == "" { | |
| 197 | + | admission = ports.BrokerAdmissionCreateEphemeral | |
| 198 | + | } | |
| 199 | + | want[stream] = expectation{admission: ports.SessionAdmission{ | |
| 200 | + | Origin: ports.SessionOriginLocal, Policy: policy, Purpose: ports.BrokerStreamAttachment, | |
| 201 | + | Admission: admission, Name: name, Env: env, | |
| 202 | + | }} | |
| 203 | + | request := attachmentRequest(i+1, policy, admission, name, protocol.ExactSessionTarget{}, env) | |
| 204 | + | go func(request ports.BrokerOpenStreamRequest) { | |
| 205 | + | // The Accept that confirms this stream must be in flight before the | |
| 206 | + | // Open, because a listener sends Opened only as Accept delivers. | |
| 207 | + | accepted := h.acceptAsync() | |
| 208 | + | ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) | |
| 209 | + | defer cancel() | |
| 210 | + | _, err := h.connector.Open(ctx, request) | |
| 211 | + | if err != nil { | |
| 212 | + | openErrs <- err | |
| 213 | + | return | |
| 214 | + | } | |
| 215 | + | opened <- h.awaitAccept(accepted) | |
| 216 | + | }(request) | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | for i := 0; i < streams; i++ { | |
| 220 | + | select { | |
| 221 | + | case err := <-openErrs: | |
| 222 | + | require.NoError(t, err) | |
| 223 | + | case connection := <-opened: | |
| 224 | + | admission, ok := connection.SessionAdmission() | |
| 225 | + | require.True(t, ok) | |
| 226 | + | expected, tracked := want[connection.Ref().Client] | |
| 227 | + | require.True(t, tracked) | |
| 228 | + | require.Equal(t, expected.admission, admission, "stream %d", connection.Ref().Client) | |
| 229 | + | case <-time.After(p3cTestDeadline): | |
| 230 | + | t.Fatal("concurrent admissions did not all complete") | |
| 231 | + | } | |
| 232 | + | } | |
| 233 | + | } | |
M internal/adapters/daemonmux/binding.go +90 -58
| @@ -1,91 +1,123 @@ | |||
| 1 | 1 | package daemonmux | |
| 2 | 2 | ||
| 3 | - | // Server binding (P3.2a). | |
| 4 | - | // | |
| 5 | - | // ServerBinding is the daemon's validated, immutable authority for one | |
| 6 | - | // accepted daemonmux physical connection: the stable authenticated daemon | |
| 7 | - | // identity used for pooling, this process incarnation used to detect a | |
| 8 | - | // restart behind that identity, and the exact connection policy the daemon | |
| 9 | - | // requires. It is constructed once, validates its own invariants, exposes | |
| 10 | - | // only read accessors, and is never mutated, so a later change to a caller's | |
| 11 | - | // local value cannot weaken what the server handshake already enforced. | |
| 12 | - | // | |
| 13 | - | // The server handshake compares a client's requested policy against this | |
| 14 | - | // binding rather than echoing the request back: an accepted response carries | |
| 15 | - | // the binding's identity, incarnation, and policy. Identity is stable across | |
| 16 | - | // restarts; the incarnation is a freshly sampled nonzero 16-byte value. | |
| 17 | - | // | |
| 18 | - | // Authentication premise: the binding is only as trustworthy as the carrier | |
| 19 | - | // that carried the handshake. The carriage must already be authenticated | |
| 20 | - | // (for example an established QUIC channel with an exact peer pin) before a | |
| 21 | - | // connection reaches this handshake, and the expected endpoint binding is | |
| 22 | - | // authoritative. This package never authenticates a carrier and never treats | |
| 23 | - | // an in-band identity claim as proof. | |
| 24 | - | ||
| 25 | 3 | import ( | |
| 26 | 4 | "errors" | |
| 27 | 5 | ||
| 28 | 6 | "github.com/bnema/vev/internal/ports" | |
| 29 | 7 | ) | |
| 30 | 8 | ||
| 31 | - | // ErrInvalidBinding reports a daemon-side physical binding that is not | |
| 32 | - | // authoritative: an invalid or empty authenticated daemon identity, a zero | |
| 33 | - | // daemon incarnation, or an invalid connection policy. NewServerBinding | |
| 34 | - | // refuses it once, at construction, so the handshake never re-validates an | |
| 35 | - | // unusable binding. | |
| 36 | - | var ErrInvalidBinding = errors.New("daemonmux: invalid server binding") | |
| 9 | + | // ErrInvalidBinding reports an invalid daemon authority. An authority must | |
| 10 | + | // contain a valid identity, a non-zero incarnation, and a non-empty closed set | |
| 11 | + | // of distinct, valid provisioned admissions. | |
| 12 | + | var ErrInvalidBinding = errors.New("daemonmux: invalid server bindings") | |
| 13 | + | ||
| 14 | + | // ServerPolicyAdmission is one provisioned carriage shape the daemon accepts: | |
| 15 | + | // the exact connection policy for that shape and the locality it serves. A | |
| 16 | + | // daemon serves its own local carriage and each provisioned remote transport, so | |
| 17 | + | // one binding legitimately carries several entries; the origin makes the | |
| 18 | + | // accepted locality explicit rather than inferring it from the peer's Open. | |
| 19 | + | // | |
| 20 | + | // The origin is per policy, not globally unique: two remote transport policies | |
| 21 | + | // (for example quic and stdio) share the one remote locality, so admission | |
| 22 | + | // resolves origin from the accepted policy entry rather than a global origin | |
| 23 | + | // table. | |
| 24 | + | type ServerPolicyAdmission struct { | |
| 25 | + | Policy ports.BrokerPolicy | |
| 26 | + | Origin ports.SessionConnectionOrigin | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | // Validate enforces the closed provisioned-entry contract: a valid exact policy | |
| 30 | + | // and a known locality. A missing or invalid origin is refused, so an accepted | |
| 31 | + | // entry can never inherit an implicit locality. | |
| 32 | + | func (e ServerPolicyAdmission) Validate() error { | |
| 33 | + | if err := e.Policy.Validate(); err != nil { | |
| 34 | + | return err | |
| 35 | + | } | |
| 36 | + | return e.Origin.Validate() | |
| 37 | + | } | |
| 37 | 38 | ||
| 38 | - | // ServerBinding is the daemon's validated, immutable physical binding for one | |
| 39 | - | // accepted daemonmux connection. Its fields are unexported and reachable only | |
| 40 | - | // through the Identity, Incarnation, and Policy accessors, so a ServerBinding | |
| 41 | - | // value is immutable once constructed and safe to copy and share. | |
| 39 | + | // ServerBinding is an immutable daemon authority. The entry slice is cloned at | |
| 40 | + | // construction and is never exposed, making the admitted set safe to share | |
| 41 | + | // between concurrent handshakes. | |
| 42 | 42 | type ServerBinding struct { | |
| 43 | 43 | identity ports.BrokerDaemonIdentity | |
| 44 | 44 | incarnation ports.BrokerDaemonIncarnation | |
| 45 | - | policy ports.BrokerPolicy | |
| 45 | + | entries []ServerPolicyAdmission | |
| 46 | 46 | } | |
| 47 | 47 | ||
| 48 | - | // NewServerBinding validates and returns the daemon's immutable physical | |
| 49 | - | // binding. It refuses an invalid authenticated identity, a zero incarnation, | |
| 50 | - | // or an invalid policy with ErrInvalidBinding; the returned binding either | |
| 51 | - | // validates completely or is never handed out. | |
| 52 | - | func NewServerBinding(identity ports.BrokerDaemonIdentity, incarnation ports.BrokerDaemonIncarnation, policy ports.BrokerPolicy) (ServerBinding, error) { | |
| 53 | - | binding := ServerBinding{identity: identity, incarnation: incarnation, policy: policy} | |
| 48 | + | // NewServerBindings constructs a closed admission authority from provisioned | |
| 49 | + | // entries. An empty set, a duplicate policy, an invalid policy, and an unknown | |
| 50 | + | // origin are rejected. Entries are cloned, so a later change to the caller's | |
| 51 | + | // slice cannot weaken what the handshake already enforced. | |
| 52 | + | func NewServerBindings(identity ports.BrokerDaemonIdentity, incarnation ports.BrokerDaemonIncarnation, entries []ServerPolicyAdmission) (ServerBinding, error) { | |
| 53 | + | binding := ServerBinding{identity: identity, incarnation: incarnation, entries: append([]ServerPolicyAdmission(nil), entries...)} | |
| 54 | 54 | if err := binding.Validate(); err != nil { | |
| 55 | 55 | return ServerBinding{}, err | |
| 56 | 56 | } | |
| 57 | 57 | return binding, nil | |
| 58 | 58 | } | |
| 59 | 59 | ||
| 60 | - | // Identity returns the stable authenticated daemon identity the binding | |
| 61 | - | // advertises and pools on. | |
| 62 | - | func (b ServerBinding) Identity() ports.BrokerDaemonIdentity { return b.identity } | |
| 60 | + | // NewServerBinding is retained as a source-compatible shorthand for fixtures | |
| 61 | + | // that provision exactly one local policy. It is never used by production | |
| 62 | + | // composition, which builds the daemon's whole closed set with NewServerBindings. | |
| 63 | + | func NewServerBinding(identity ports.BrokerDaemonIdentity, incarnation ports.BrokerDaemonIncarnation, policy ports.BrokerPolicy) (ServerBinding, error) { | |
| 64 | + | return NewServerBindings(identity, incarnation, []ServerPolicyAdmission{{Policy: policy, Origin: ports.SessionOriginLocal}}) | |
| 65 | + | } | |
| 63 | 66 | ||
| 64 | - | // Incarnation returns the nonzero process incarnation the binding advertises | |
| 65 | - | // so a broker can detect a restart behind the stable identity. | |
| 67 | + | func (b ServerBinding) Identity() ports.BrokerDaemonIdentity { return b.identity } | |
| 66 | 68 | func (b ServerBinding) Incarnation() ports.BrokerDaemonIncarnation { return b.incarnation } | |
| 67 | 69 | ||
| 68 | - | // Policy returns the exact connection policy the binding requires. | |
| 69 | - | func (b ServerBinding) Policy() ports.BrokerPolicy { return b.policy } | |
| 70 | + | // Policy returns the sole entry's policy for legacy single-policy callers. It | |
| 71 | + | // returns the zero value for a multi-entry authority; admission never uses it. | |
| 72 | + | func (b ServerBinding) Policy() ports.BrokerPolicy { | |
| 73 | + | if len(b.entries) == 1 { | |
| 74 | + | return b.entries[0].Policy | |
| 75 | + | } | |
| 76 | + | return ports.BrokerPolicy{} | |
| 77 | + | } | |
| 70 | 78 | ||
| 71 | - | // Validate reports whether the binding is authoritative: a valid authenticated | |
| 72 | - | // identity, a nonzero incarnation, and a valid policy. | |
| 73 | - | func (b ServerBinding) Validate() error { | |
| 74 | - | if err := b.identity.Validate(); err != nil { | |
| 75 | - | return ErrInvalidBinding | |
| 79 | + | // Origin returns the sole entry's locality for legacy single-policy callers. It | |
| 80 | + | // returns the unknown origin for a multi-entry authority. | |
| 81 | + | func (b ServerBinding) Origin() ports.SessionConnectionOrigin { | |
| 82 | + | if len(b.entries) == 1 { | |
| 83 | + | return b.entries[0].Origin | |
| 76 | 84 | } | |
| 77 | - | if err := b.incarnation.Validate(); err != nil { | |
| 85 | + | return ports.SessionOriginUnknown | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | func (b ServerBinding) Validate() error { | |
| 89 | + | if b.identity.Validate() != nil || b.incarnation.Validate() != nil || len(b.entries) == 0 { | |
| 78 | 90 | return ErrInvalidBinding | |
| 79 | 91 | } | |
| 80 | - | if err := b.policy.Validate(); err != nil { | |
| 81 | - | return ErrInvalidBinding | |
| 92 | + | for i, entry := range b.entries { | |
| 93 | + | if entry.Validate() != nil { | |
| 94 | + | return ErrInvalidBinding | |
| 95 | + | } | |
| 96 | + | for j := 0; j < i; j++ { | |
| 97 | + | // Duplicate policy - and therefore a duplicated entry - is refused: | |
| 98 | + | // one policy is one carriage shape, so a second entry naming the | |
| 99 | + | // same policy would make the accepted locality and policy ambiguous. | |
| 100 | + | if entry.Policy.Compatible(b.entries[j].Policy) { | |
| 101 | + | return ErrInvalidBinding | |
| 102 | + | } | |
| 103 | + | } | |
| 82 | 104 | } | |
| 83 | 105 | return nil | |
| 84 | 106 | } | |
| 85 | 107 | ||
| 86 | - | // Accepts reports whether the requested policy exactly equals the binding's | |
| 87 | - | // policy. Pooling requires exact equality across every field, so a request | |
| 88 | - | // that differs in any field is refused rather than merged or inherited. | |
| 108 | + | // Accepted returns the exact provisioned entry matching policy, carrying that | |
| 109 | + | // entry's policy and origin. No merging, inheritance, echo, or fallback is | |
| 110 | + | // performed: a request differing in one field is refused. | |
| 111 | + | func (b ServerBinding) Accepted(policy ports.BrokerPolicy) (ServerPolicyAdmission, bool) { | |
| 112 | + | for _, provisioned := range b.entries { | |
| 113 | + | if provisioned.Policy.Compatible(policy) { | |
| 114 | + | return provisioned, true | |
| 115 | + | } | |
| 116 | + | } | |
| 117 | + | return ServerPolicyAdmission{}, false | |
| 118 | + | } | |
| 119 | + | ||
| 89 | 120 | func (b ServerBinding) Accepts(policy ports.BrokerPolicy) bool { | |
| 90 | - | return b.policy.Compatible(policy) | |
| 121 | + | _, ok := b.Accepted(policy) | |
| 122 | + | return ok | |
| 91 | 123 | } | |
A internal/adapters/daemonmux/binding_set_test.go +202 -0
| @@ -0,0 +1,202 @@ | |||
| 1 | + | package daemonmux | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "sync" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/vev/internal/ports" | |
| 8 | + | "github.com/stretchr/testify/require" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestServerBindingsClosedSetCloneAndDuplicates(t *testing.T) { | |
| 12 | + | first := testPolicy() | |
| 13 | + | second := first | |
| 14 | + | second.Transport = "ssh-stdio" | |
| 15 | + | entries := []ServerPolicyAdmission{localAcceptance(first), remoteAcceptance(second)} | |
| 16 | + | binding, err := NewServerBindings(testIdentity(), testIncarnation(), entries) | |
| 17 | + | require.NoError(t, err) | |
| 18 | + | entries[0] = localAcceptance(second) | |
| 19 | + | require.True(t, binding.Accepts(first), "constructor must clone its authority") | |
| 20 | + | require.True(t, binding.Accepts(second)) | |
| 21 | + | variation := second | |
| 22 | + | variation.Trust += "-other" | |
| 23 | + | require.False(t, binding.Accepts(variation)) | |
| 24 | + | _, err = NewServerBindings(testIdentity(), testIncarnation(), []ServerPolicyAdmission{localAcceptance(first), remoteAcceptance(first)}) | |
| 25 | + | require.ErrorIs(t, err, ErrInvalidBinding) | |
| 26 | + | _, err = NewServerBindings(testIdentity(), testIncarnation(), nil) | |
| 27 | + | require.ErrorIs(t, err, ErrInvalidBinding) | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | func TestServerBindingsConcurrentAdmission(t *testing.T) { | |
| 31 | + | policy := testPolicy() | |
| 32 | + | binding, err := NewServerBindings(testIdentity(), testIncarnation(), []ServerPolicyAdmission{localAcceptance(policy)}) | |
| 33 | + | require.NoError(t, err) | |
| 34 | + | var wg sync.WaitGroup | |
| 35 | + | for range 100 { | |
| 36 | + | wg.Add(1) | |
| 37 | + | go func() { defer wg.Done(); require.True(t, binding.Accepts(policy)) }() | |
| 38 | + | } | |
| 39 | + | wg.Wait() | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | // TestServerBindingsSinglePolicyShorthandMatchesSetConstructor proves the | |
| 43 | + | // fixtures-only shorthand is exactly the one-member closed set: same authority, | |
| 44 | + | // same decisions, same exposure through Policy(). | |
| 45 | + | func TestServerBindingsSinglePolicyShorthandMatchesSetConstructor(t *testing.T) { | |
| 46 | + | shorthand, err := NewServerBinding(testIdentity(), testIncarnation(), testPolicy()) | |
| 47 | + | require.NoError(t, err) | |
| 48 | + | set, err := NewServerBindings(testIdentity(), testIncarnation(), []ServerPolicyAdmission{localAcceptance(testPolicy())}) | |
| 49 | + | require.NoError(t, err) | |
| 50 | + | ||
| 51 | + | require.Equal(t, set.Identity(), shorthand.Identity()) | |
| 52 | + | require.Equal(t, set.Incarnation(), shorthand.Incarnation()) | |
| 53 | + | require.Equal(t, set.Policy(), shorthand.Policy()) | |
| 54 | + | require.NoError(t, shorthand.Validate()) | |
| 55 | + | for _, candidate := range []ports.BrokerPolicy{testPolicy(), func() ports.BrokerPolicy { p := testPolicy(); p.Transport = "other"; return p }()} { | |
| 56 | + | require.Equal(t, set.Accepts(candidate), shorthand.Accepts(candidate)) | |
| 57 | + | } | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | // TestServerBindingsAcceptedReturnsProvisionedMember proves admission answers | |
| 61 | + | // with the member the configuration provisioned and never with the request: no | |
| 62 | + | // merging, inheritance, echo, or fallback can widen the configured authority. | |
| 63 | + | // The answered entry carries the provisioned locality alongside its policy. | |
| 64 | + | func TestServerBindingsAcceptedReturnsProvisionedMember(t *testing.T) { | |
| 65 | + | first := testPolicy() | |
| 66 | + | second := first | |
| 67 | + | second.Transport = "ssh-stdio" | |
| 68 | + | second.Trust = "pinned" | |
| 69 | + | binding, err := NewServerBindings(testIdentity(), testIncarnation(), []ServerPolicyAdmission{localAcceptance(first), remoteAcceptance(second)}) | |
| 70 | + | require.NoError(t, err) | |
| 71 | + | ||
| 72 | + | accepted, ok := binding.Accepted(second) | |
| 73 | + | require.True(t, ok) | |
| 74 | + | require.Equal(t, remoteAcceptance(second), accepted, "admission answers the provisioned member, not the request") | |
| 75 | + | require.Equal(t, ports.SessionOriginRemote, accepted.Origin) | |
| 76 | + | ||
| 77 | + | local, ok := binding.Accepted(first) | |
| 78 | + | require.True(t, ok) | |
| 79 | + | require.Equal(t, localAcceptance(first), local) | |
| 80 | + | require.Equal(t, ports.SessionOriginLocal, local.Origin) | |
| 81 | + | ||
| 82 | + | // A request differing in exactly one field is refused outright: it can | |
| 83 | + | // never be answered by a member it was not provisioned as. | |
| 84 | + | variations := []func(*ports.BrokerPolicy){ | |
| 85 | + | func(p *ports.BrokerPolicy) { p.ProtocolVersion++ }, | |
| 86 | + | func(p *ports.BrokerPolicy) { p.CatalogSchemaVersion++ }, | |
| 87 | + | func(p *ports.BrokerPolicy) { p.Transport = "other" }, | |
| 88 | + | func(p *ports.BrokerPolicy) { p.Trust = "other" }, | |
| 89 | + | func(p *ports.BrokerPolicy) { p.Isolation = "other" }, | |
| 90 | + | } | |
| 91 | + | for _, mutate := range variations { | |
| 92 | + | requested := second | |
| 93 | + | mutate(&requested) | |
| 94 | + | accepted, ok := binding.Accepted(requested) | |
| 95 | + | require.False(t, ok, "a one-field variation must not be admitted") | |
| 96 | + | require.Equal(t, ServerPolicyAdmission{}, accepted) | |
| 97 | + | } | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | // TestServerBindingsPolicyExposesNoMultiMemberAuthority proves Policy() answers | |
| 101 | + | // only a genuine single-member authority: a multi-member binding exposes the | |
| 102 | + | // zero value rather than one of its members, so a caller can never mistake an | |
| 103 | + | // arbitrary member for the authority of the whole daemon. | |
| 104 | + | func TestServerBindingsPolicyExposesNoMultiMemberAuthority(t *testing.T) { | |
| 105 | + | first := testPolicy() | |
| 106 | + | second := first | |
| 107 | + | second.Transport = "ssh-stdio" | |
| 108 | + | binding, err := NewServerBindings(testIdentity(), testIncarnation(), []ServerPolicyAdmission{localAcceptance(first), remoteAcceptance(second)}) | |
| 109 | + | require.NoError(t, err) | |
| 110 | + | require.Equal(t, ports.BrokerPolicy{}, binding.Policy()) | |
| 111 | + | require.True(t, binding.Accepts(first)) | |
| 112 | + | require.True(t, binding.Accepts(second)) | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | // TestServerBindingsRefusesInvalidMemberAmongValid proves validation is | |
| 116 | + | // all-or-nothing: one invalid member refuses the whole closed set instead of | |
| 117 | + | // publishing a partially valid authority. An entry with an unknown or invalid | |
| 118 | + | // locality is refused the same way, so an accepted member always names a | |
| 119 | + | // provisioned origin. | |
| 120 | + | func TestServerBindingsRefusesInvalidMemberAmongValid(t *testing.T) { | |
| 121 | + | invalid := testPolicy() | |
| 122 | + | invalid.ProtocolVersion = 0 | |
| 123 | + | ||
| 124 | + | tests := []struct { | |
| 125 | + | name string | |
| 126 | + | entries []ServerPolicyAdmission | |
| 127 | + | }{ | |
| 128 | + | {name: "invalid member first", entries: []ServerPolicyAdmission{localAcceptance(invalid), remoteAcceptance(testPolicy())}}, | |
| 129 | + | {name: "invalid member last", entries: []ServerPolicyAdmission{localAcceptance(testPolicy()), remoteAcceptance(invalid)}}, | |
| 130 | + | {name: "zero policy member", entries: []ServerPolicyAdmission{localAcceptance(testPolicy()), remoteAcceptance(ports.BrokerPolicy{})}}, | |
| 131 | + | {name: "unknown origin", entries: []ServerPolicyAdmission{{Policy: testPolicy(), Origin: ports.SessionOriginUnknown}}}, | |
| 132 | + | {name: "invalid origin", entries: []ServerPolicyAdmission{{Policy: testPolicy(), Origin: ports.SessionConnectionOrigin(9)}}}, | |
| 133 | + | } | |
| 134 | + | for _, tt := range tests { | |
| 135 | + | t.Run(tt.name, func(t *testing.T) { | |
| 136 | + | _, err := NewServerBindings(testIdentity(), testIncarnation(), tt.entries) | |
| 137 | + | require.ErrorIs(t, err, ErrInvalidBinding) | |
| 138 | + | }) | |
| 139 | + | } | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | // TestServerBindingsRefusesInvalidAuthority proves an empty identity and a zero | |
| 143 | + | // incarnation are refused regardless of how many valid policies follow. | |
| 144 | + | func TestServerBindingsRefusesInvalidAuthority(t *testing.T) { | |
| 145 | + | tests := []struct { | |
| 146 | + | name string | |
| 147 | + | identity ports.BrokerDaemonIdentity | |
| 148 | + | incarnation ports.BrokerDaemonIncarnation | |
| 149 | + | }{ | |
| 150 | + | {name: "empty identity", identity: "", incarnation: testIncarnation()}, | |
| 151 | + | {name: "zero incarnation", identity: testIdentity(), incarnation: ports.BrokerDaemonIncarnation{}}, | |
| 152 | + | } | |
| 153 | + | for _, tt := range tests { | |
| 154 | + | t.Run(tt.name, func(t *testing.T) { | |
| 155 | + | _, err := NewServerBindings(tt.identity, tt.incarnation, []ServerPolicyAdmission{localAcceptance(testPolicy())}) | |
| 156 | + | require.ErrorIs(t, err, ErrInvalidBinding) | |
| 157 | + | }) | |
| 158 | + | } | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | // TestServerBindingsOriginShorthandMatchesSetConstructor proves Origin() | |
| 162 | + | // answers only a genuine single-member authority, while a multi-member binding | |
| 163 | + | // exposes the unknown origin rather than an arbitrary member's locality. | |
| 164 | + | func TestServerBindingsOriginShorthandMatchesSetConstructor(t *testing.T) { | |
| 165 | + | shorthand, err := NewServerBinding(testIdentity(), testIncarnation(), testPolicy()) | |
| 166 | + | require.NoError(t, err) | |
| 167 | + | require.Equal(t, ports.SessionOriginLocal, shorthand.Origin()) | |
| 168 | + | ||
| 169 | + | first := testPolicy() | |
| 170 | + | second := first | |
| 171 | + | second.Transport = "ssh-stdio" | |
| 172 | + | multi, err := NewServerBindings(testIdentity(), testIncarnation(), []ServerPolicyAdmission{localAcceptance(first), remoteAcceptance(second)}) | |
| 173 | + | require.NoError(t, err) | |
| 174 | + | require.Equal(t, ports.SessionOriginUnknown, multi.Origin()) | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | // TestServerBindingsConcurrentMultiMemberAdmission proves a shared closed set is | |
| 178 | + | // safe to admit against from many handshakes at once, resolving each concurrent | |
| 179 | + | // request to its own provisioned member. | |
| 180 | + | func TestServerBindingsConcurrentMultiMemberAdmission(t *testing.T) { | |
| 181 | + | first := testPolicy() | |
| 182 | + | second := first | |
| 183 | + | second.Transport = "ssh-stdio" | |
| 184 | + | third := first | |
| 185 | + | third.Isolation = "system" | |
| 186 | + | binding, err := NewServerBindings(testIdentity(), testIncarnation(), []ServerPolicyAdmission{localAcceptance(first), remoteAcceptance(second), remoteAcceptance(third)}) | |
| 187 | + | require.NoError(t, err) | |
| 188 | + | ||
| 189 | + | candidates := []ServerPolicyAdmission{localAcceptance(first), remoteAcceptance(second), remoteAcceptance(third)} | |
| 190 | + | var wg sync.WaitGroup | |
| 191 | + | for i := range 150 { | |
| 192 | + | wg.Add(1) | |
| 193 | + | go func(index int) { | |
| 194 | + | defer wg.Done() | |
| 195 | + | want := candidates[index%len(candidates)] | |
| 196 | + | accepted, ok := binding.Accepted(want.Policy) | |
| 197 | + | require.True(t, ok) | |
| 198 | + | require.Equal(t, want, accepted) | |
| 199 | + | }(i) | |
| 200 | + | } | |
| 201 | + | wg.Wait() | |
| 202 | + | } | |
M internal/adapters/daemonmux/codec.go +15 -1
| @@ -273,7 +273,7 @@ func openToWire(m Open) (*wire.MuxOpen, error) { | |||
| 273 | 273 | Epoch: m.Ref.Epoch, Purpose: m.Purpose, Admission: m.Admission, Name: m.Name, Local: m.Local, | |
| 274 | 274 | Connection: m.Ref.Connection, Stream: m.Ref.Client, | |
| 275 | 275 | Endpoint: m.Endpoint, Registration: m.Registration, | |
| 276 | - | Target: m.Target, Env: m.Env, Policy: m.Policy, | |
| 276 | + | Target: m.Target, Env: m.Env, Policy: m.Policy, StartMode: m.StartMode, | |
| 277 | 277 | } | |
| 278 | 278 | if err := request.Validate(); err != nil { | |
| 279 | 279 | // Distinguish bound refusals from semantic refusals. | |
| @@ -294,11 +294,19 @@ func openToWire(m Open) (*wire.MuxOpen, error) { | |||
| 294 | 294 | if err != nil { | |
| 295 | 295 | return nil, err | |
| 296 | 296 | } | |
| 297 | + | startMode, err := startModeToWire(m.StartMode) | |
| 298 | + | if err != nil { | |
| 299 | + | return nil, err | |
| 300 | + | } | |
| 297 | 301 | out := &wire.MuxOpen{ | |
| 298 | 302 | Ref: refToWire(m.Ref), | |
| 299 | 303 | Purpose: purpose, Local: m.Local, Endpoint: m.Endpoint, | |
| 300 | 304 | Env: append([]string(nil), m.Env...), | |
| 301 | 305 | Policy: policyToWire(m.Policy), | |
| 306 | + | // The daemon-start authorization always travels, including for | |
| 307 | + | // observation and control streams, so the daemon-side transport can | |
| 308 | + | // never infer a spawn from an absent field. | |
| 309 | + | StartMode: startMode, | |
| 302 | 310 | } | |
| 303 | 311 | if !m.Local { | |
| 304 | 312 | out.Registration = registrationToWire(m.Registration) | |
| @@ -336,6 +344,10 @@ func openFromWire(message *wire.MuxOpen) (Open, error) { | |||
| 336 | 344 | if purpose != ports.BrokerStreamAttachment && (admission != 0 || name != "") { | |
| 337 | 345 | return Open{}, ErrInvalidMessage | |
| 338 | 346 | } | |
| 347 | + | startMode, err := startModeFromWire(message.GetStartMode()) | |
| 348 | + | if err != nil { | |
| 349 | + | return Open{}, ErrInvalidMessage | |
| 350 | + | } | |
| 339 | 351 | local := message.GetLocal() | |
| 340 | 352 | var registration domain.RemoteRegistration | |
| 341 | 353 | if !local { | |
| @@ -374,12 +386,14 @@ func openFromWire(message *wire.MuxOpen) (Open, error) { | |||
| 374 | 386 | candidate := Open{ | |
| 375 | 387 | Ref: ref, Purpose: purpose, Admission: admission, Name: name, Local: local, Endpoint: endpoint, | |
| 376 | 388 | Registration: registration, Target: target, Env: env, Policy: policy, | |
| 389 | + | StartMode: startMode, | |
| 377 | 390 | } | |
| 378 | 391 | request := ports.BrokerOpenStreamRequest{ | |
| 379 | 392 | Epoch: candidate.Ref.Epoch, Purpose: candidate.Purpose, Admission: candidate.Admission, Name: candidate.Name, Local: candidate.Local, | |
| 380 | 393 | Connection: candidate.Ref.Connection, Stream: candidate.Ref.Client, | |
| 381 | 394 | Endpoint: candidate.Endpoint, Registration: candidate.Registration, | |
| 382 | 395 | Target: candidate.Target, Env: candidate.Env, Policy: candidate.Policy, | |
| 396 | + | StartMode: candidate.StartMode, | |
| 383 | 397 | } | |
| 384 | 398 | if err := request.Validate(); err != nil { | |
| 385 | 399 | return Open{}, ErrInvalidMessage | |
M internal/adapters/daemonmux/codec_test.go +76 -4
| @@ -43,6 +43,18 @@ func testPolicy() ports.BrokerPolicy { | |||
| 43 | 43 | } | |
| 44 | 44 | } | |
| 45 | 45 | ||
| 46 | + | // localAcceptance provisions one local carriage entry under policy, matching | |
| 47 | + | // the local Open requests the shared fixtures send. | |
| 48 | + | func localAcceptance(policy ports.BrokerPolicy) ServerPolicyAdmission { | |
| 49 | + | return ServerPolicyAdmission{Policy: policy, Origin: ports.SessionOriginLocal} | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | // remoteAcceptance provisions one remote carriage entry under policy, matching | |
| 53 | + | // a remote Open carrying a validated registration. | |
| 54 | + | func remoteAcceptance(policy ports.BrokerPolicy) ServerPolicyAdmission { | |
| 55 | + | return ServerPolicyAdmission{Policy: policy, Origin: ports.SessionOriginRemote} | |
| 56 | + | } | |
| 57 | + | ||
| 46 | 58 | func testTarget() protocol.ExactSessionTarget { | |
| 47 | 59 | return protocol.ExactSessionTarget{ | |
| 48 | 60 | LifecycleID: domain.SessionLifecycleID{1, 2, 3}, | |
| @@ -97,6 +109,7 @@ func testOpen() Open { | |||
| 97 | 109 | Ref: testRef(5), Purpose: ports.BrokerStreamAttachment, Admission: ports.BrokerAdmissionExact, Local: false, | |
| 98 | 110 | Endpoint: "dev@host:22", Registration: testRegistration(), Target: testTarget(), | |
| 99 | 111 | Env: []string{"TERM=xterm-256color"}, Policy: testPolicy(), | |
| 112 | + | StartMode: ports.BrokerDaemonStartIfNeeded, | |
| 100 | 113 | } | |
| 101 | 114 | } | |
| 102 | 115 | ||
| @@ -371,7 +384,7 @@ func TestMuxValidationNegatives(t *testing.T) { | |||
| 371 | 384 | _, err = EncodeServer(Opened{Ref: zeroClient}, testEnvelopeCeiling, testChunkCeiling) | |
| 372 | 385 | require.ErrorIs(t, err, ErrInvalidMessage) | |
| 373 | 386 | ||
| 374 | - | _, err = EncodeClient(Open{Ref: zeroClient, Purpose: ports.BrokerStreamControl, Local: true, Policy: testPolicy()}, testEnvelopeCeiling, testChunkCeiling) | |
| 387 | + | _, err = EncodeClient(Open{Ref: zeroClient, Purpose: ports.BrokerStreamControl, Local: true, Policy: testPolicy(), StartMode: ports.BrokerDaemonStartIfNeeded}, testEnvelopeCeiling, testChunkCeiling) | |
| 375 | 388 | require.ErrorIs(t, err, ErrInvalidMessage) | |
| 376 | 389 | }) | |
| 377 | 390 | t.Run("open taxonomy and shape", func(t *testing.T) { | |
| @@ -412,7 +425,7 @@ func TestMuxValidationNegatives(t *testing.T) { | |||
| 412 | 425 | require.ErrorIs(t, err, ErrInvalidMessage) | |
| 413 | 426 | ||
| 414 | 427 | // A local control open with no attachment state is legal. | |
| 415 | - | valid := Open{Ref: testRef(5), Purpose: ports.BrokerStreamControl, Local: true, Policy: testPolicy()} | |
| 428 | + | valid := Open{Ref: testRef(5), Purpose: ports.BrokerStreamControl, Local: true, Policy: testPolicy(), StartMode: ports.BrokerDaemonStartIfNeeded} | |
| 416 | 429 | raw, err := EncodeClient(valid, testEnvelopeCeiling, testChunkCeiling) | |
| 417 | 430 | require.NoError(t, err) | |
| 418 | 431 | decoded, err := DecodeClient(raw, testEnvelopeCeiling, testChunkCeiling) | |
| @@ -694,7 +707,7 @@ func TestMuxAdmissionVariants(t *testing.T) { | |||
| 694 | 707 | require.Equal(t, ephemeral, roundTrip(t, ephemeral)) | |
| 695 | 708 | }) | |
| 696 | 709 | t.Run("control never carries admission", func(t *testing.T) { | |
| 697 | - | control := Open{Ref: testRef(6), Purpose: ports.BrokerStreamControl, Local: true, Policy: testPolicy()} | |
| 710 | + | control := Open{Ref: testRef(6), Purpose: ports.BrokerStreamControl, Local: true, Policy: testPolicy(), StartMode: ports.BrokerDaemonStartIfNeeded} | |
| 698 | 711 | require.Equal(t, control, roundTrip(t, control)) | |
| 699 | 712 | mutated := mustEncodeClient(t, control) | |
| 700 | 713 | envelope := &wire.MuxClientEnvelope{} | |
| @@ -731,13 +744,72 @@ func TestMuxAdmissionVariants(t *testing.T) { | |||
| 731 | 744 | }) | |
| 732 | 745 | } | |
| 733 | 746 | ||
| 747 | + | // TestMuxStartModeRoundTrips proves the daemon-start authorization travels the | |
| 748 | + | // mux wire losslessly for every stream purpose, that the daemon-side decoder | |
| 749 | + | // never widens an existing-only authorization, and that the zero and unknown | |
| 750 | + | // wire codes are refused rather than defaulted. | |
| 751 | + | func TestMuxStartModeRoundTrips(t *testing.T) { | |
| 752 | + | for _, tc := range []struct { | |
| 753 | + | name string | |
| 754 | + | mode ports.BrokerDaemonStartMode | |
| 755 | + | purpose ports.BrokerStreamPurpose | |
| 756 | + | admission ports.BrokerStreamAdmission | |
| 757 | + | }{ | |
| 758 | + | {"existing only control", ports.BrokerDaemonExistingOnly, ports.BrokerStreamControl, 0}, | |
| 759 | + | {"start if needed control", ports.BrokerDaemonStartIfNeeded, ports.BrokerStreamControl, 0}, | |
| 760 | + | {"existing only observation", ports.BrokerDaemonExistingOnly, ports.BrokerStreamObservation, 0}, | |
| 761 | + | {"start if needed exact attach", ports.BrokerDaemonStartIfNeeded, ports.BrokerStreamAttachment, ports.BrokerAdmissionExact}, | |
| 762 | + | } { | |
| 763 | + | t.Run(tc.name, func(t *testing.T) { | |
| 764 | + | open := Open{Ref: testRef(5), Purpose: tc.purpose, Admission: tc.admission, Local: true, Policy: testPolicy(), StartMode: tc.mode} | |
| 765 | + | if tc.admission == ports.BrokerAdmissionExact { | |
| 766 | + | open.Target = testTarget() | |
| 767 | + | } | |
| 768 | + | raw, err := EncodeClient(open, testEnvelopeCeiling, testChunkCeiling) | |
| 769 | + | require.NoError(t, err) | |
| 770 | + | decoded, err := DecodeClient(raw, testEnvelopeCeiling, testChunkCeiling) | |
| 771 | + | require.NoError(t, err) | |
| 772 | + | admitting, ok := decoded.(Open) | |
| 773 | + | require.True(t, ok) | |
| 774 | + | require.Equal(t, tc.mode, admitting.StartMode) | |
| 775 | + | }) | |
| 776 | + | } | |
| 777 | + | ||
| 778 | + | t.Run("zero wire code is refused", func(t *testing.T) { | |
| 779 | + | raw := mustEncodeClient(t, Open{Ref: testRef(6), Purpose: ports.BrokerStreamControl, Local: true, Policy: testPolicy(), StartMode: ports.BrokerDaemonExistingOnly}) | |
| 780 | + | envelope := &wire.MuxClientEnvelope{} | |
| 781 | + | require.NoError(t, proto.Unmarshal(raw, envelope)) | |
| 782 | + | envelope.GetOpen().StartMode = 0 | |
| 783 | + | mutated, err := proto.Marshal(envelope) | |
| 784 | + | require.NoError(t, err) | |
| 785 | + | _, err = DecodeClient(mutated, testEnvelopeCeiling, testChunkCeiling) | |
| 786 | + | require.ErrorIs(t, err, ErrInvalidMessage, "an absent start mode is never defaulted") | |
| 787 | + | }) | |
| 788 | + | ||
| 789 | + | t.Run("unknown wire code is refused", func(t *testing.T) { | |
| 790 | + | raw := mustEncodeClient(t, Open{Ref: testRef(6), Purpose: ports.BrokerStreamControl, Local: true, Policy: testPolicy(), StartMode: ports.BrokerDaemonExistingOnly}) | |
| 791 | + | envelope := &wire.MuxClientEnvelope{} | |
| 792 | + | require.NoError(t, proto.Unmarshal(raw, envelope)) | |
| 793 | + | envelope.GetOpen().StartMode = 3 | |
| 794 | + | mutated, err := proto.Marshal(envelope) | |
| 795 | + | require.NoError(t, err) | |
| 796 | + | _, err = DecodeClient(mutated, testEnvelopeCeiling, testChunkCeiling) | |
| 797 | + | require.ErrorIs(t, err, ErrInvalidMessage) | |
| 798 | + | }) | |
| 799 | + | ||
| 800 | + | t.Run("encoding a zero start mode is refused", func(t *testing.T) { | |
| 801 | + | _, err := EncodeClient(Open{Ref: testRef(6), Purpose: ports.BrokerStreamControl, Local: true, Policy: testPolicy()}, testEnvelopeCeiling, testChunkCeiling) | |
| 802 | + | require.ErrorIs(t, err, ErrInvalidMessage) | |
| 803 | + | }) | |
| 804 | + | } | |
| 805 | + | ||
| 734 | 806 | // TestMuxAdmissionWireRange proves the MuxOpen decoder narrows the wire | |
| 735 | 807 | // admission field before any cast: the accepted semantic set (no admission, or | |
| 736 | 808 | // one of the three closed admission codes) is unchanged, while every wire value | |
| 737 | 809 | // whose high bits would alias a valid code is refused instead of silently | |
| 738 | 810 | // truncating onto that code. | |
| 739 | 811 | func TestMuxAdmissionWireRange(t *testing.T) { | |
| 740 | - | control := Open{Ref: testRef(6), Purpose: ports.BrokerStreamControl, Local: true, Policy: testPolicy()} | |
| 812 | + | control := Open{Ref: testRef(6), Purpose: ports.BrokerStreamControl, Local: true, Policy: testPolicy(), StartMode: ports.BrokerDaemonStartIfNeeded} | |
| 741 | 813 | named := testOpen() | |
| 742 | 814 | named.Admission = ports.BrokerAdmissionCreateNamed | |
| 743 | 815 | named.Name = "work" | |
M internal/adapters/daemonmux/connector.go +20 -10
| @@ -49,11 +49,18 @@ import ( | |||
| 49 | 49 | var ErrConnectorConfig = errors.New("daemonmux: invalid endpoint connector configuration") | |
| 50 | 50 | ||
| 51 | 51 | // RawCarrierDialer establishes one already authenticated raw framed carriage | |
| 52 | - | // for an opaque endpoint address. It returns a carriage the daemonmux | |
| 52 | + | // for one resolved dial target. It returns a carriage the daemonmux | |
| 53 | 53 | // FramedCarrierBridge can adapt; it returns promptly once ctx is done. The | |
| 54 | 54 | // carriage's authentication and transport selection live entirely outside this | |
| 55 | 55 | // package, so the connector carries no dial policy of its own. | |
| 56 | - | type RawCarrierDialer func(ctx context.Context, address string) (RawFramedTransport, error) | |
| 56 | + | // | |
| 57 | + | // The whole target travels, not only its opaque address: the resolved start | |
| 58 | + | // authorization, policy, and endpoint fence are exactly what a carriage owner | |
| 59 | + | // needs to decide whether the transport may start the target daemon. They are | |
| 60 | + | // passed as one value so a dial can never observe an address that belongs to a | |
| 61 | + | // different authorization than the one the connector validated and will verify | |
| 62 | + | // in the physical preamble. | |
| 63 | + | type RawCarrierDialer func(ctx context.Context, target ports.BrokerDialTarget) (RawFramedTransport, error) | |
| 57 | 64 | ||
| 58 | 65 | // EndpointConnector implements ports.BrokerEndpointConnector over daemonmux. It | |
| 59 | 66 | // owns no carriage state itself: every Connect produces one independent | |
| @@ -83,12 +90,15 @@ func NewEndpointConnector(dial RawCarrierDialer, ceilings MuxCeilings) (*Endpoin | |||
| 83 | 90 | ||
| 84 | 91 | // Connect dials the resolved endpoint, completes the daemonmux physical | |
| 85 | 92 | // preamble against the endpoint's authoritative identity and policy, and | |
| 86 | - | // returns a live ports.BrokerPhysicalConnection. The caller's ctx (or its | |
| 87 | - | // deadline) bounds dial and handshake together; every failure closes the | |
| 88 | - | // carriage, so no half-open physical connection survives. A successful | |
| 89 | - | // connection is detached from ctx: cancelling or expiring ctx afterwards never | |
| 90 | - | // stops the pooled physical connection, which Close alone owns. | |
| 91 | - | func (c *EndpointConnector) Connect(ctx context.Context, endpoint ports.BrokerResolvedEndpoint) (ports.BrokerPhysicalConnection, error) { | |
| 93 | + | // returns a live ports.BrokerPhysicalConnection. The resolved target is handed | |
| 94 | + | // to the dial function whole, after validation, so the carriage owner sees the | |
| 95 | + | // exact start authorization and fence the connector is about to verify. The | |
| 96 | + | // caller's ctx (or its deadline) bounds dial and handshake together; every | |
| 97 | + | // failure closes the carriage, so no half-open physical connection survives. A | |
| 98 | + | // successful connection is detached from ctx: cancelling or expiring ctx | |
| 99 | + | // afterwards never stops the pooled physical connection, which Close alone | |
| 100 | + | // owns. | |
| 101 | + | func (c *EndpointConnector) Connect(ctx context.Context, endpoint ports.BrokerDialTarget) (ports.BrokerPhysicalConnection, error) { | |
| 92 | 102 | if c == nil || c.dial == nil { | |
| 93 | 103 | return nil, ErrConnectorConfig | |
| 94 | 104 | } | |
| @@ -99,7 +109,7 @@ func (c *EndpointConnector) Connect(ctx context.Context, endpoint ports.BrokerRe | |||
| 99 | 109 | return nil, ports.BrokerError{Code: ports.BrokerErrorIncompatible, Cause: err} | |
| 100 | 110 | } | |
| 101 | 111 | ||
| 102 | - | raw, err := c.dial(ctx, endpoint.Address) | |
| 112 | + | raw, err := c.dial(ctx, endpoint) | |
| 103 | 113 | if err != nil { | |
| 104 | 114 | if raw != nil { | |
| 105 | 115 | _ = raw.Close() | |
| @@ -125,7 +135,7 @@ func (c *EndpointConnector) Connect(ctx context.Context, endpoint ports.BrokerRe | |||
| 125 | 135 | // The endpoint stays authoritative: re-verify the accepted authority even | |
| 126 | 136 | // though the handshake already checked it, so a future handshake change can | |
| 127 | 137 | // never weaken what the connector publishes. | |
| 128 | - | if result.Identity != endpoint.Identity || !result.Policy.Compatible(endpoint.Policy) || result.Incarnation.Validate() != nil { | |
| 138 | + | if (endpoint.ExpectedIdentity.Bound && result.Identity != endpoint.ExpectedIdentity.Identity) || !result.Policy.Compatible(endpoint.Policy) || result.Incarnation.Validate() != nil { | |
| 129 | 139 | _ = bridge.Close() | |
| 130 | 140 | return nil, ports.BrokerError{Code: ports.BrokerErrorIncompatible} | |
| 131 | 141 | } | |
M internal/adapters/daemonmux/connector_test.go +7 -6
| @@ -64,12 +64,13 @@ func muxOpenRequest(stream uint64, policy ports.BrokerPolicy) ports.BrokerOpenSt | |||
| 64 | 64 | Connection: ports.BrokerConnectionID{1}, | |
| 65 | 65 | Stream: ports.BrokerStreamID(stream), | |
| 66 | 66 | Policy: policy, | |
| 67 | + | StartMode: ports.BrokerDaemonStartIfNeeded, | |
| 67 | 68 | } | |
| 68 | 69 | } | |
| 69 | 70 | ||
| 70 | 71 | // muxEndpoint builds the resolved endpoint the broker verifies against. | |
| 71 | - | func muxEndpoint(identity ports.BrokerDaemonIdentity, policy ports.BrokerPolicy, address string) ports.BrokerResolvedEndpoint { | |
| 72 | - | return ports.BrokerResolvedEndpoint{Identity: identity, Policy: policy, Address: address} | |
| 72 | + | func muxEndpoint(identity ports.BrokerDaemonIdentity, policy ports.BrokerPolicy, address string) ports.BrokerDialTarget { | |
| 73 | + | return ports.BrokerDialTarget{Fence: ports.BrokerEndpointFence{Local: true}, Policy: policy, Address: address, StartMode: ports.BrokerDaemonExistingOnly, ExpectedIdentity: ports.BrokerExpectedIdentity{Identity: identity, Bound: true}} | |
| 73 | 74 | } | |
| 74 | 75 | ||
| 75 | 76 | // superviseServer is a test daemon: an AggregateListener plus a | |
| @@ -114,7 +115,7 @@ func newSuperviseServer(t *testing.T, binding ServerBinding, ceilings MuxCeiling | |||
| 114 | 115 | // dial returns a RawCarrierDialer that produces one fresh real carriage per | |
| 115 | 116 | // call and adopts its daemon end. | |
| 116 | 117 | func (s *superviseServer) dial() RawCarrierDialer { | |
| 117 | - | return func(_ context.Context, _ string) (RawFramedTransport, error) { | |
| 118 | + | return func(_ context.Context, _ ports.BrokerDialTarget) (RawFramedTransport, error) { | |
| 118 | 119 | s.dials.Add(1) | |
| 119 | 120 | client, server := rawCarriagePair(s.t) | |
| 120 | 121 | go func() { s.adoptions <- s.supervisor.Adopt(context.Background(), server) }() | |
| @@ -179,16 +180,16 @@ func TestNewEndpointConnectorValidation(t *testing.T) { | |||
| 179 | 180 | _, err := NewEndpointConnector(nil, DefaultMuxCeilings()) | |
| 180 | 181 | require.ErrorIs(t, err, ErrConnectorConfig) | |
| 181 | 182 | ||
| 182 | - | _, err = NewEndpointConnector(func(context.Context, string) (RawFramedTransport, error) { return nil, nil }, MuxCeilings{}) | |
| 183 | + | _, err = NewEndpointConnector(func(context.Context, ports.BrokerDialTarget) (RawFramedTransport, error) { return nil, nil }, MuxCeilings{}) | |
| 183 | 184 | require.ErrorIs(t, err, ErrConnectorConfig) | |
| 184 | 185 | ||
| 185 | - | connector, err := NewEndpointConnector(func(context.Context, string) (RawFramedTransport, error) { | |
| 186 | + | connector, err := NewEndpointConnector(func(context.Context, ports.BrokerDialTarget) (RawFramedTransport, error) { | |
| 186 | 187 | t.Fatal("dial must not run for an invalid endpoint") | |
| 187 | 188 | return nil, nil | |
| 188 | 189 | }, DefaultMuxCeilings()) | |
| 189 | 190 | require.NoError(t, err) | |
| 190 | 191 | ||
| 191 | - | _, err = connector.Connect(context.Background(), ports.BrokerResolvedEndpoint{}) | |
| 192 | + | _, err = connector.Connect(context.Background(), ports.BrokerDialTarget{}) | |
| 192 | 193 | require.Error(t, err) | |
| 193 | 194 | var typed ports.BrokerError | |
| 194 | 195 | require.ErrorAs(t, err, &typed) | |
M internal/adapters/daemonmux/convert.go +26 -0
| @@ -44,6 +44,32 @@ func muxEnum16[T ~uint16](value uint32) (T, error) { | |||
| 44 | 44 | return T(value), nil | |
| 45 | 45 | } | |
| 46 | 46 | ||
| 47 | + | // startModeToWire maps the closed daemon-start taxonomy onto its wire code. | |
| 48 | + | // The zero value is refused rather than encoded: a peer must never have to | |
| 49 | + | // guess whether an absent mode authorized a spawn. | |
| 50 | + | func startModeToWire(mode ports.BrokerDaemonStartMode) (uint32, error) { | |
| 51 | + | switch mode { | |
| 52 | + | case ports.BrokerDaemonExistingOnly: | |
| 53 | + | return 1, nil | |
| 54 | + | case ports.BrokerDaemonStartIfNeeded: | |
| 55 | + | return 2, nil | |
| 56 | + | default: | |
| 57 | + | return 0, errConvertRange | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | // startModeFromWire maps a wire daemon-start code onto the closed taxonomy. | |
| 62 | + | func startModeFromWire(value uint32) (ports.BrokerDaemonStartMode, error) { | |
| 63 | + | switch value { | |
| 64 | + | case 1: | |
| 65 | + | return ports.BrokerDaemonExistingOnly, nil | |
| 66 | + | case 2: | |
| 67 | + | return ports.BrokerDaemonStartIfNeeded, nil | |
| 68 | + | default: | |
| 69 | + | return 0, errConvertRange | |
| 70 | + | } | |
| 71 | + | } | |
| 72 | + | ||
| 47 | 73 | func physicalToWire(id PhysicalStreamID) uint64 { return uint64(id) } | |
| 48 | 74 | ||
| 49 | 75 | func physicalFromWire(value uint64) (PhysicalStreamID, error) { | |
M internal/adapters/daemonmux/doc.go +19 -4
| @@ -24,11 +24,14 @@ | |||
| 24 | 24 | // | |
| 25 | 25 | // The daemon-side physical binding (binding.go) and the stateful physical | |
| 26 | 26 | // preamble handshake (handshake.go) are the connection's authority layer. A | |
| 27 | - | // ServerBinding is a validated, immutable (identity, incarnation, policy) | |
| 28 | - | // value. The broker-side handshake verifies the accepted response against the | |
| 27 | + | // ServerBinding is a validated, immutable (identity, incarnation) value over a | |
| 28 | + | // non-empty closed set of distinct provisioned policy admissions, each naming | |
| 29 | + | // the exact policy for one carriage shape and its locality (ServerPolicyAdmission). | |
| 30 | + | // The broker-side handshake verifies the accepted response against the | |
| 29 | 31 | // independently resolved endpoint identity and policy, the nonzero | |
| 30 | 32 | // incarnation, and the ceilings it offered; the daemon-side handshake enforces | |
| 31 | - | // the immutable binding instead of echoing the requested policy, negotiates | |
| 33 | + | // the immutable binding instead of echoing the requested policy, resolves the | |
| 34 | + | // accepted member (policy plus origin), negotiates | |
| 32 | 35 | // the element-wise minima, and refuses with the precise shared rejection code. | |
| 33 | 36 | // One caller-supplied context or deadline bounds the single request/response | |
| 34 | 37 | // exchange, and every failure closes the carrier; a success leaves it open for | |
| @@ -101,7 +104,14 @@ | |||
| 101 | 104 | // (protocol.HandshakeTimeout) that queue delay and the session handshake share; | |
| 102 | 105 | // the deadline and the handshake completion signal are exposed to the daemon | |
| 103 | 106 | // through the narrow optional HandshakePlumbing interface, so the daemon adopts | |
| 104 | - | // this one deadline instead of starting a second one. A stream that reaches its | |
| 107 | + | // this one deadline instead of starting a second one. Every admitted connection | |
| 108 | + | // is stamped with the provisioned admission its listener accepted - the exact | |
| 109 | + | // accepted policy and carriage origin plus the peer's declared purpose, closed | |
| 110 | + | // admission variant, name, exact target, and bounded environment - and exposes | |
| 111 | + | // it through the optional ports.SessionAdmissionProvider so a daemon use case | |
| 112 | + | // consumes the accepting side's authority rather than the peer's Open. An Open | |
| 113 | + | // whose declared locality contradicts the accepted origin (a liar Local) or that | |
| 114 | + | // fails the closed admission contract is refused on its own stream. A stream that reaches its | |
| 105 | 115 | // deadline, is refused because the accept queue is full, or is reset by the | |
| 106 | 116 | // peer is settled on its own: exactly one mux Reset, its admission slot | |
| 107 | 117 | // released, and no effect on a sibling or on the physical connection. An | |
| @@ -212,3 +222,8 @@ | |||
| 212 | 222 | // raw remote stderr. daemonmux gains no process, pipe, or framing code from | |
| 213 | 223 | // this slice. | |
| 214 | 224 | package daemonmux | |
| 225 | + | ||
| 226 | + | import "path/filepath" | |
| 227 | + | ||
| 228 | + | // SocketPath is the canonical production daemonmux endpoint beneath ipc.SocketDir(). | |
| 229 | + | func SocketPath(socketDir string) string { return filepath.Join(socketDir, "daemonmux.sock") } | |
M internal/adapters/daemonmux/handshake.go +18 -7
| @@ -16,7 +16,7 @@ package daemonmux | |||
| 16 | 16 | // | |
| 17 | 17 | // The client verifies the accepted response against the independently resolved | |
| 18 | 18 | // endpoint it dialed: the response identity must exactly equal | |
| 19 | - | // ports.BrokerResolvedEndpoint.Identity, the accepted policy must exactly equal | |
| 19 | + | // ports.BrokerDialTarget.ExpectedIdentity.Identity, the accepted policy must exactly equal | |
| 20 | 20 | // the endpoint policy, the incarnation must be nonzero, and the effective | |
| 21 | 21 | // ceilings must never exceed what the client offered. The response is the | |
| 22 | 22 | // server's authority, never a value the client supplied and got echoed back. | |
| @@ -97,6 +97,13 @@ type ClientHandshakeResult struct { | |||
| 97 | 97 | type ServerHandshakeResult struct { | |
| 98 | 98 | Ceilings MuxCeilings | |
| 99 | 99 | Binding ServerBinding | |
| 100 | + | // Policy is the exact immutable authority member admitted for this peer. | |
| 101 | + | Policy ports.BrokerPolicy | |
| 102 | + | // Origin is the provisioned locality of the admitted member, carried from | |
| 103 | + | // the same provisioned entry Policy was resolved from. A downstream listener | |
| 104 | + | // stamps it on every admitted session connection so a daemon use case can | |
| 105 | + | // enforce the local/remote locality bound without re-deriving it. | |
| 106 | + | Origin ports.SessionConnectionOrigin | |
| 100 | 107 | } | |
| 101 | 108 | ||
| 102 | 109 | // RunClientHandshake performs the broker side of the daemonmux physical | |
| @@ -108,7 +115,7 @@ type ServerHandshakeResult struct { | |||
| 108 | 115 | // authority mismatch. The caller's ctx (or its deadline) bounds both the send | |
| 109 | 116 | // and the receive. Every failure closes carrier; success leaves it open and | |
| 110 | 117 | // does not start a pump. | |
| 111 | - | func RunClientHandshake(ctx context.Context, carrier FramedCarrier, endpoint ports.BrokerResolvedEndpoint, offered MuxCeilings) (ClientHandshakeResult, error) { | |
| 118 | + | func RunClientHandshake(ctx context.Context, carrier FramedCarrier, endpoint ports.BrokerDialTarget, offered MuxCeilings) (ClientHandshakeResult, error) { | |
| 112 | 119 | if ctx == nil { | |
| 113 | 120 | ctx = context.Background() | |
| 114 | 121 | } | |
| @@ -168,11 +175,14 @@ func RunClientHandshake(ctx context.Context, carrier FramedCarrier, endpoint por | |||
| 168 | 175 | // incarnation is nonzero, and the effective ceilings never exceed the offer. | |
| 169 | 176 | // A non-acceptance is refused too, so a caller can never treat a refusal as an | |
| 170 | 177 | // accepted empty response. | |
| 171 | - | func validateClientResponse(response MuxPreambleResponse, endpoint ports.BrokerResolvedEndpoint, offered MuxCeilings) error { | |
| 178 | + | func validateClientResponse(response MuxPreambleResponse, endpoint ports.BrokerDialTarget, offered MuxCeilings) error { | |
| 172 | 179 | if !response.Accepted { | |
| 173 | 180 | return ErrHandshakeRejected | |
| 174 | 181 | } | |
| 175 | - | if response.Identity != endpoint.Identity { | |
| 182 | + | if endpoint.ExpectedIdentity.Bound && response.Identity != endpoint.ExpectedIdentity.Identity { | |
| 183 | + | return ErrHandshakeRejected | |
| 184 | + | } | |
| 185 | + | if err := response.Identity.Validate(); err != nil { | |
| 176 | 186 | return ErrHandshakeRejected | |
| 177 | 187 | } | |
| 178 | 188 | if !response.Policy.Compatible(endpoint.Policy) { | |
| @@ -222,7 +232,8 @@ func RunServerHandshake(ctx context.Context, carrier FramedCarrier, binding Serv | |||
| 222 | 232 | closeFailedHandshake(carrier) | |
| 223 | 233 | return ServerHandshakeResult{}, &HandshakeRefusal{Code: code} | |
| 224 | 234 | } | |
| 225 | - | if !binding.Accepts(request.Policy) { | |
| 235 | + | accepted, ok := binding.Accepted(request.Policy) | |
| 236 | + | if !ok { | |
| 226 | 237 | code := uint32(RejectionLimitRefused) | |
| 227 | 238 | _ = sendHandshakeRefusal(ctx, carrier, offered, code) | |
| 228 | 239 | closeFailedHandshake(carrier) | |
| @@ -230,7 +241,7 @@ func RunServerHandshake(ctx context.Context, carrier FramedCarrier, binding Serv | |||
| 230 | 241 | } | |
| 231 | 242 | ||
| 232 | 243 | effective := EffectiveMuxCeilings(offered, request.Ceilings) | |
| 233 | - | response, err := EncodePreambleResponse(true, effective, binding.Policy(), binding.Identity(), binding.Incarnation(), 0) | |
| 244 | + | response, err := EncodePreambleResponse(true, effective, accepted.Policy, binding.Identity(), binding.Incarnation(), 0) | |
| 234 | 245 | if err != nil { | |
| 235 | 246 | closeFailedHandshake(carrier) | |
| 236 | 247 | return ServerHandshakeResult{}, ErrHandshakeConfig | |
| @@ -244,7 +255,7 @@ func RunServerHandshake(ctx context.Context, carrier FramedCarrier, binding Serv | |||
| 244 | 255 | closeFailedHandshake(carrier) | |
| 245 | 256 | return ServerHandshakeResult{}, err | |
| 246 | 257 | } | |
| 247 | - | return ServerHandshakeResult{Ceilings: effective, Binding: binding}, nil | |
| 258 | + | return ServerHandshakeResult{Ceilings: effective, Binding: binding, Policy: accepted.Policy, Origin: accepted.Origin}, nil | |
| 248 | 259 | } | |
| 249 | 260 | ||
| 250 | 261 | // decodePreambleRequestWithCode is the server's strict request decode paired | |
M internal/adapters/daemonmux/handshake_test.go +14 -2
| @@ -15,8 +15,8 @@ import ( | |||
| 15 | 15 | ||
| 16 | 16 | // handshakeEndpoint is the independently resolved endpoint the client verifies | |
| 17 | 17 | // the daemon response against. | |
| 18 | - | func handshakeEndpoint(identity ports.BrokerDaemonIdentity, policy ports.BrokerPolicy) ports.BrokerResolvedEndpoint { | |
| 19 | - | return ports.BrokerResolvedEndpoint{Identity: identity, Policy: policy, Address: "quic://127.0.0.1:7777"} | |
| 18 | + | func handshakeEndpoint(identity ports.BrokerDaemonIdentity, policy ports.BrokerPolicy) ports.BrokerDialTarget { | |
| 19 | + | return ports.BrokerDialTarget{Fence: ports.BrokerEndpointFence{Local: true}, Policy: policy, Address: "quic://127.0.0.1:7777", StartMode: ports.BrokerDaemonExistingOnly, ExpectedIdentity: ports.BrokerExpectedIdentity{Identity: identity, Bound: true}} | |
| 20 | 20 | } | |
| 21 | 21 | ||
| 22 | 22 | func mustServerBinding(t *testing.T) ServerBinding { | |
| @@ -26,6 +26,16 @@ func mustServerBinding(t *testing.T) ServerBinding { | |||
| 26 | 26 | return binding | |
| 27 | 27 | } | |
| 28 | 28 | ||
| 29 | + | // mustRemoteServerBinding provisions the same authority with the remote | |
| 30 | + | // locality, for tests whose Open requests carry a validated remote | |
| 31 | + | // registration rather than a local endpoint. | |
| 32 | + | func mustRemoteServerBinding(t *testing.T) ServerBinding { | |
| 33 | + | t.Helper() | |
| 34 | + | binding, err := NewServerBindings(testIdentity(), testIncarnation(), []ServerPolicyAdmission{remoteAcceptance(testPolicy())}) | |
| 35 | + | require.NoError(t, err) | |
| 36 | + | return binding | |
| 37 | + | } | |
| 38 | + | ||
| 29 | 39 | // encodedAcceptedResponse builds one valid acceptance and lets the caller | |
| 30 | 40 | // corrupt exactly one field before marshaling. | |
| 31 | 41 | func encodedAcceptedResponse(t *testing.T, mutate func(*wire.MuxPreambleResponse)) []byte { | |
| @@ -274,6 +284,8 @@ func TestServerHandshakeAcceptsAndNegotiates(t *testing.T) { | |||
| 274 | 284 | require.NoError(t, err) | |
| 275 | 285 | require.Equal(t, binding, result.Binding) | |
| 276 | 286 | require.Equal(t, EffectiveMuxCeilings(clientCeilings, serverCeilings), result.Ceilings) | |
| 287 | + | require.Equal(t, testPolicy(), result.Policy, "the accepted policy is the provisioned member") | |
| 288 | + | require.Equal(t, ports.SessionOriginLocal, result.Origin, "the accepted origin is the provisioned locality") | |
| 277 | 289 | require.Equal(t, 0, carrier.closeCalls()) | |
| 278 | 290 | ||
| 279 | 291 | decoded, err := DecodePreambleResponseBytes(awaitSentFrame(t, carrier)) | |
M internal/adapters/daemonmux/listener.go +107 -27
| @@ -103,6 +103,13 @@ const ( | |||
| 103 | 103 | listenerClosedText = "listener closed" | |
| 104 | 104 | handshakeDeadlineText = "handshake deadline exceeded" | |
| 105 | 105 | ||
| 106 | + | // invalidAdmissionText is the bounded, presentation-safe text of the | |
| 107 | + | // stream-local Reset a listener sends when an admitted Open contradicts the | |
| 108 | + | // accepted carriage origin (a liar Local) or fails the closed admission | |
| 109 | + | // contract. The stream is refused rather than stamped with a provenance the | |
| 110 | + | // accepting side did not provision. | |
| 111 | + | invalidAdmissionText = "invalid session admission" | |
| 112 | + | ||
| 106 | 113 | // sessionCarriageClosedText is the bounded, presentation-safe text carried | |
| 107 | 114 | // by the stream-local Reset a listener connection sends when its inner | |
| 108 | 115 | // session carriage closed on its own during the handshake (a sessionwire | |
| @@ -137,6 +144,11 @@ type Listener struct { | |||
| 137 | 144 | clock ports.Clock | |
| 138 | 145 | budget time.Duration | |
| 139 | 146 | queueLimit int | |
| 147 | + | // accepted is the provisioned carriage shape the physical handshake | |
| 148 | + | // admitted. Every admitted session connection is stamped with it, so the | |
| 149 | + | // daemon consumes the accepting side's authority rather than the peer's | |
| 150 | + | // Open. | |
| 151 | + | accepted ServerPolicyAdmission | |
| 140 | 152 | ||
| 141 | 153 | // mu guards the accept queue, the deadline registry, and the closed flag. | |
| 142 | 154 | // change is the closed-and-replaced broadcast channel every blocked Accept | |
| @@ -154,46 +166,56 @@ type Listener struct { | |||
| 154 | 166 | } | |
| 155 | 167 | ||
| 156 | 168 | var ( | |
| 157 | - | _ ports.ServerListener = (*Listener)(nil) | |
| 158 | - | _ ports.ServerConnection = (*ListenerConnection)(nil) | |
| 159 | - | _ HandshakePlumbing = (*ListenerConnection)(nil) | |
| 169 | + | _ ports.ServerListener = (*Listener)(nil) | |
| 170 | + | _ ports.ServerConnection = (*ListenerConnection)(nil) | |
| 171 | + | _ HandshakePlumbing = (*ListenerConnection)(nil) | |
| 172 | + | _ ports.SessionAdmissionProvider = (*ListenerConnection)(nil) | |
| 160 | 173 | ) | |
| 161 | 174 | ||
| 162 | 175 | // admittedStream is one inbound logical stream the engine admitted and the | |
| 163 | 176 | // listener tracks until it is settled: delivered with a completed handshake, or | |
| 164 | - | // terminal (expired, peer-settled, refused, or released). | |
| 177 | + | // terminal (expired, peer-settled, refused, or released). admission is the | |
| 178 | + | // cloned provisioned admission stamped when the stream was admitted. | |
| 165 | 179 | type admittedStream struct { | |
| 166 | - | ref StreamRef | |
| 167 | - | deadline time.Time | |
| 168 | - | done <-chan struct{} | |
| 169 | - | queued bool | |
| 170 | - | settled bool | |
| 180 | + | ref StreamRef | |
| 181 | + | deadline time.Time | |
| 182 | + | done <-chan struct{} | |
| 183 | + | admission ports.SessionAdmission | |
| 184 | + | queued bool | |
| 185 | + | settled bool | |
| 171 | 186 | } | |
| 172 | 187 | ||
| 173 | 188 | // NewListener returns a listener over one daemon-side pump under the package | |
| 174 | 189 | // policy: the accepted absolute handshake deadline budget is | |
| 175 | 190 | // protocol.HandshakeTimeout, and the accept queue is bounded by | |
| 176 | - | // MaxAcceptQueue. The pump must decode broker-to-daemon frames | |
| 177 | - | // (Inbound == DirectionClient) and emit daemon-to-broker frames, as the daemon | |
| 178 | - | // side of a pooled physical connection does; a nil, engine-less, or | |
| 179 | - | // broker-side pump is refused. The pump must not have started: the listener | |
| 180 | - | // registers the pump's admission observer, and a pump that already started | |
| 181 | - | // refuses the registration with ErrAdmissionObserverLate (wrapped in | |
| 182 | - | // ErrListenerConfig), because an observer registered after Start could miss an | |
| 183 | - | // already-admitted Open. | |
| 184 | - | func NewListener(pump *Pump) (*Listener, error) { | |
| 185 | - | return newListener(pump, protocol.HandshakeTimeout, MaxAcceptQueue, clock.New()) | |
| 191 | + | // MaxAcceptQueue. accepted is the provisioned carriage shape the physical | |
| 192 | + | // handshake admitted: its exact policy and its locality origin. Every admitted | |
| 193 | + | // session connection is stamped with it. The pump must decode broker-to-daemon | |
| 194 | + | // frames (Inbound == DirectionClient) and emit daemon-to-broker frames, as the | |
| 195 | + | // daemon side of a pooled physical connection does; a nil, engine-less, or | |
| 196 | + | // broker-side pump, or an invalid accepted entry, is refused. The pump must not | |
| 197 | + | // have started: the listener registers the pump's admission observer, and a | |
| 198 | + | // pump that already started refuses the registration with | |
| 199 | + | // ErrAdmissionObserverLate (wrapped in ErrListenerConfig), because an observer | |
| 200 | + | // registered after Start could miss an already-admitted Open. | |
| 201 | + | func NewListener(pump *Pump, accepted ServerPolicyAdmission) (*Listener, error) { | |
| 202 | + | return newListener(pump, protocol.HandshakeTimeout, MaxAcceptQueue, clock.New(), accepted) | |
| 186 | 203 | } | |
| 187 | 204 | ||
| 188 | 205 | // newListener builds a listener from explicit policy. budget is the handshake | |
| 189 | 206 | // deadline budget each stream receives at admission, limit is the accept-queue | |
| 190 | - | // bound, and clock supplies the admission time and the deadline timer. The | |
| 191 | - | // exported constructor delegates with the package policy; a test tightens the | |
| 192 | - | // budget, the queue bound, or the clock without changing live behavior. | |
| 193 | - | func newListener(pump *Pump, budget time.Duration, limit int, timeSource ports.Clock) (*Listener, error) { | |
| 207 | + | // bound, clock supplies the admission time and the deadline timer, and accepted | |
| 208 | + | // is the provisioned carriage shape stamped on every admitted session | |
| 209 | + | // connection. The exported constructor delegates with the package policy; a | |
| 210 | + | // test tightens the budget, the queue bound, or the clock without changing live | |
| 211 | + | // behavior. | |
| 212 | + | func newListener(pump *Pump, budget time.Duration, limit int, timeSource ports.Clock, accepted ServerPolicyAdmission) (*Listener, error) { | |
| 194 | 213 | if pump == nil || pump.Engine() == nil || pump.Inbound() != DirectionClient { | |
| 195 | 214 | return nil, ErrListenerConfig | |
| 196 | 215 | } | |
| 216 | + | if err := accepted.Validate(); err != nil { | |
| 217 | + | return nil, fmt.Errorf("%w: %w", ErrListenerConfig, err) | |
| 218 | + | } | |
| 197 | 219 | if budget <= 0 { | |
| 198 | 220 | budget = protocol.HandshakeTimeout | |
| 199 | 221 | } | |
| @@ -208,6 +230,7 @@ func newListener(pump *Pump, budget time.Duration, limit int, timeSource ports.C | |||
| 208 | 230 | clock: timeSource, | |
| 209 | 231 | budget: budget, | |
| 210 | 232 | queueLimit: limit, | |
| 233 | + | accepted: accepted, | |
| 211 | 234 | active: make(map[PhysicalStreamID]*admittedStream), | |
| 212 | 235 | change: make(chan struct{}), | |
| 213 | 236 | done: make(chan struct{}), | |
| @@ -294,16 +317,60 @@ func (l *Listener) Close() error { | |||
| 294 | 317 | return nil | |
| 295 | 318 | } | |
| 296 | 319 | ||
| 320 | + | // sessionAdmissionFor stamps the provisioned admission metadata onto one | |
| 321 | + | // admitted Open: the accepted entry's exact policy and origin, plus the peer's | |
| 322 | + | // closed purpose, attachment variant, name, target, and bounded environment. It | |
| 323 | + | // refuses an Open whose declared locality contradicts the accepted origin (a | |
| 324 | + | // liar Local must never be stamped as the provisioned locality), whose offered | |
| 325 | + | // policy is not the accepted member, or whose shape the closed admission | |
| 326 | + | // contract rejects, so a consumer only ever receives a validated admission. The | |
| 327 | + | // policy and locality checks are repeated here rather than relying on the | |
| 328 | + | // engine's prior restriction, so a listener built without one still enforces | |
| 329 | + | // the accepted member. | |
| 330 | + | func sessionAdmissionFor(accepted ServerPolicyAdmission, open Open) (ports.SessionAdmission, error) { | |
| 331 | + | if !accepted.Policy.Compatible(open.Policy) { | |
| 332 | + | return ports.SessionAdmission{}, ErrInvalidMessage | |
| 333 | + | } | |
| 334 | + | origin := ports.SessionOriginRemote | |
| 335 | + | if open.Local { | |
| 336 | + | origin = ports.SessionOriginLocal | |
| 337 | + | } | |
| 338 | + | if accepted.Origin != origin { | |
| 339 | + | return ports.SessionAdmission{}, ErrInvalidMessage | |
| 340 | + | } | |
| 341 | + | admission := ports.SessionAdmission{ | |
| 342 | + | Origin: accepted.Origin, | |
| 343 | + | Policy: accepted.Policy, | |
| 344 | + | Purpose: open.Purpose, | |
| 345 | + | Admission: open.Admission, | |
| 346 | + | Name: open.Name, | |
| 347 | + | Target: open.Target, | |
| 348 | + | Env: append([]string(nil), open.Env...), | |
| 349 | + | } | |
| 350 | + | if err := admission.Validate(); err != nil { | |
| 351 | + | return ports.SessionAdmission{}, ErrInvalidMessage | |
| 352 | + | } | |
| 353 | + | return admission, nil | |
| 354 | + | } | |
| 355 | + | ||
| 297 | 356 | // onAdmitted is the pump's admission observer. It runs on the pump's reader | |
| 298 | - | // goroutine immediately after one inbound Open was admitted: it queues the | |
| 357 | + | // goroutine immediately after one inbound Open was admitted: it stamps the | |
| 358 | + | // provisioned admission (refusing exactly that stream when the Open contradicts | |
| 359 | + | // the accepted origin or fails the closed admission contract) and queues the | |
| 299 | 360 | // stream under its admission deadline, or refuses exactly that stream when the | |
| 300 | 361 | // accept queue is full or the listener is closed. It never blocks - the reader | |
| 301 | 362 | // is the one goroutine that applies every inbound frame of the connection. | |
| 302 | 363 | func (l *Listener) onAdmitted(open Open) { | |
| 303 | 364 | admitted := l.clock.Now() | |
| 365 | + | admission, err := sessionAdmissionFor(l.accepted, open) | |
| 366 | + | if err != nil { | |
| 367 | + | l.abort(open.Ref.Physical, invalidAdmissionText) | |
| 368 | + | return | |
| 369 | + | } | |
| 304 | 370 | entry := &admittedStream{ | |
| 305 | - | ref: open.Ref, | |
| 306 | - | deadline: admitted.Add(l.budget), | |
| 371 | + | ref: open.Ref, | |
| 372 | + | deadline: admitted.Add(l.budget), | |
| 373 | + | admission: admission, | |
| 307 | 374 | } | |
| 308 | 375 | if status, ok := l.pump.Engine().Status(open.Ref.Physical); ok { | |
| 309 | 376 | entry.done = status.Done | |
| @@ -620,6 +687,10 @@ type ListenerConnection struct { | |||
| 620 | 687 | // admission, resolved once at construction. | |
| 621 | 688 | deadline time.Time | |
| 622 | 689 | ||
| 690 | + | // admission is the cloned provisioned admission stamped at admission. It is | |
| 691 | + | // resolved once at construction and exposed only through a defensive copy. | |
| 692 | + | admission ports.SessionAdmission | |
| 693 | + | ||
| 623 | 694 | terminal *terminalState | |
| 624 | 695 | watchDone chan struct{} | |
| 625 | 696 | closeOnce sync.Once | |
| @@ -636,7 +707,7 @@ func newListenerConnection(l *Listener, entry *admittedStream) *ListenerConnecti | |||
| 636 | 707 | pipe := newMuxStreamPipe(l.pump, entry.ref.Physical, limit, entry.done) | |
| 637 | 708 | raw := newMuxTransport(pipe) | |
| 638 | 709 | connection := &ListenerConnection{ | |
| 639 | - | ServerConnection: sessionwire.NewServerConnectionWithDeadline(raw, entry.deadline), | |
| 710 | + | ServerConnection: sessionwire.NewServerConnectionWithAdmission(raw, entry.deadline, entry.admission), | |
| 640 | 711 | listener: l, | |
| 641 | 712 | pump: l.pump, | |
| 642 | 713 | ref: entry.ref, | |
| @@ -645,6 +716,7 @@ func newListenerConnection(l *Listener, entry *admittedStream) *ListenerConnecti | |||
| 645 | 716 | stream: entry.done, | |
| 646 | 717 | cause: l.pump.cause(entry.ref.Physical), | |
| 647 | 718 | deadline: entry.deadline, | |
| 719 | + | admission: entry.admission.Clone(), | |
| 648 | 720 | terminal: newTerminalState(), | |
| 649 | 721 | watchDone: make(chan struct{}), | |
| 650 | 722 | } | |
| @@ -660,6 +732,14 @@ func newListenerConnection(l *Listener, entry *admittedStream) *ListenerConnecti | |||
| 660 | 732 | // Ref returns the complete stream reference recorded at admission. | |
| 661 | 733 | func (c *ListenerConnection) Ref() StreamRef { return c.ref } | |
| 662 | 734 | ||
| 735 | + | // SessionAdmission returns a defensive copy of the provisioned admission | |
| 736 | + | // stamped at acceptance, implementing ports.SessionAdmissionProvider. It always | |
| 737 | + | // reports ok=true: a ListenerConnection is built only from an already-admitted | |
| 738 | + | // stream whose provenance a listener stamped and validated. | |
| 739 | + | func (c *ListenerConnection) SessionAdmission() (ports.SessionAdmission, bool) { | |
| 740 | + | return c.admission.Clone(), true | |
| 741 | + | } | |
| 742 | + | ||
| 663 | 743 | // HandshakeDeadline returns the accepted absolute local deadline of the | |
| 664 | 744 | // session handshake, fixed at Open admission and plumbing the exact deadline | |
| 665 | 745 | // the wrapped session connection started with. Queue delay and the handshake | |
M internal/adapters/daemonmux/listener_test.go +12 -5
| @@ -179,7 +179,7 @@ func newListenerHarnessWithCeilings(t *testing.T, budget time.Duration, queueLim | |||
| 179 | 179 | t.Helper() | |
| 180 | 180 | brokerPump, daemonPump, start := newPairedPumpsUnstarted(t, ceilings) | |
| 181 | 181 | clock := newListenerClock(time.Now()) | |
| 182 | - | listener, err := newListener(daemonPump, budget, queueLimit, clock) | |
| 182 | + | listener, err := newListener(daemonPump, budget, queueLimit, clock, localAcceptance(logicalTestPolicy())) | |
| 183 | 183 | require.NoError(t, err) | |
| 184 | 184 | t.Cleanup(func() { _ = listener.Close() }) | |
| 185 | 185 | start() | |
| @@ -351,20 +351,27 @@ func (d *listenerDaemon) await(t *testing.T, count int) { | |||
| 351 | 351 | } | |
| 352 | 352 | ||
| 353 | 353 | func TestNewListenerValidation(t *testing.T) { | |
| 354 | + | local := localAcceptance(testPolicy()) | |
| 354 | 355 | t.Run("nil pump", func(t *testing.T) { | |
| 355 | - | listener, err := NewListener(nil) | |
| 356 | + | listener, err := NewListener(nil, local) | |
| 356 | 357 | require.ErrorIs(t, err, ErrListenerConfig) | |
| 357 | 358 | require.Nil(t, listener) | |
| 358 | 359 | }) | |
| 359 | 360 | t.Run("broker-side pump is refused", func(t *testing.T) { | |
| 360 | 361 | pump, _ := newTestPump(t, DirectionServer) | |
| 361 | - | listener, err := NewListener(pump) | |
| 362 | + | listener, err := NewListener(pump, local) | |
| 363 | + | require.ErrorIs(t, err, ErrListenerConfig) | |
| 364 | + | require.Nil(t, listener) | |
| 365 | + | }) | |
| 366 | + | t.Run("invalid accepted entry is refused", func(t *testing.T) { | |
| 367 | + | pump, _ := newTestPump(t, DirectionClient) | |
| 368 | + | listener, err := NewListener(pump, ServerPolicyAdmission{}) | |
| 362 | 369 | require.ErrorIs(t, err, ErrListenerConfig) | |
| 363 | 370 | require.Nil(t, listener) | |
| 364 | 371 | }) | |
| 365 | 372 | t.Run("daemon-side pump is accepted", func(t *testing.T) { | |
| 366 | 373 | pump, _ := newTestPump(t, DirectionClient) | |
| 367 | - | listener, err := NewListener(pump) | |
| 374 | + | listener, err := NewListener(pump, local) | |
| 368 | 375 | require.NoError(t, err) | |
| 369 | 376 | require.NotNil(t, listener) | |
| 370 | 377 | require.Equal(t, "daemonmux", listener.Addr()) | |
| @@ -1211,7 +1218,7 @@ func TestNewListenerRefusesStartedPump(t *testing.T) { | |||
| 1211 | 1218 | pump, _ := newTestPump(t, DirectionClient) | |
| 1212 | 1219 | pump.Start(context.Background()) | |
| 1213 | 1220 | ||
| 1214 | - | listener, err := NewListener(pump) | |
| 1221 | + | listener, err := NewListener(pump, localAcceptance(testPolicy())) | |
| 1215 | 1222 | require.ErrorIs(t, err, ErrListenerConfig) | |
| 1216 | 1223 | require.ErrorIs(t, err, ErrAdmissionObserverLate) | |
| 1217 | 1224 | require.Nil(t, listener) | |
M internal/adapters/daemonmux/logical_conn.go +4 -0
| @@ -175,6 +175,10 @@ func openMessage(physical PhysicalStreamID, request ports.BrokerOpenStreamReques | |||
| 175 | 175 | Target: request.Target, | |
| 176 | 176 | Env: append([]string(nil), request.Env...), | |
| 177 | 177 | Policy: request.Policy, | |
| 178 | + | // The daemon-start authorization travels with the mux Open, so the | |
| 179 | + | // daemon-side transport receives exactly the authority the broker | |
| 180 | + | // resolved and can never widen it into a spawn. | |
| 181 | + | StartMode: request.StartMode, | |
| 178 | 182 | } | |
| 179 | 183 | } | |
| 180 | 184 | ||
M internal/adapters/daemonmux/logical_conn_test.go +1 -0
| @@ -134,6 +134,7 @@ func controlRequest(stream int) ports.BrokerOpenStreamRequest { | |||
| 134 | 134 | Connection: ports.BrokerConnectionID{1}, | |
| 135 | 135 | Stream: ports.BrokerStreamID(stream), | |
| 136 | 136 | Policy: logicalTestPolicy(), | |
| 137 | + | StartMode: ports.BrokerDaemonStartIfNeeded, | |
| 137 | 138 | } | |
| 138 | 139 | } | |
| 139 | 140 | ||
M internal/adapters/daemonmux/messages.go +4 -1
| @@ -76,7 +76,9 @@ func (r StreamRef) Validate() error { | |||
| 76 | 76 | ||
| 77 | 77 | // Open asks the daemon to open one independently cancellable logical stream | |
| 78 | 78 | // over the physical connection. Env is the per-request session environment | |
| 79 | - | // and is never inherited from the broker process environment. | |
| 79 | + | // and is never inherited from the broker process environment. StartMode is | |
| 80 | + | // the broker's explicit daemon-start authorization, propagated unchanged so a | |
| 81 | + | // transport can never widen it into a spawn. | |
| 80 | 82 | type Open struct { | |
| 81 | 83 | Ref StreamRef | |
| 82 | 84 | Purpose ports.BrokerStreamPurpose | |
| @@ -88,6 +90,7 @@ type Open struct { | |||
| 88 | 90 | Target protocol.ExactSessionTarget | |
| 89 | 91 | Env []string | |
| 90 | 92 | Policy ports.BrokerPolicy | |
| 93 | + | StartMode ports.BrokerDaemonStartMode | |
| 91 | 94 | } | |
| 92 | 95 | ||
| 93 | 96 | func (Open) muxClientMessage() {} | |
M internal/adapters/daemonmux/pool_integration_test.go +18 -11
| @@ -13,16 +13,22 @@ import ( | |||
| 13 | 13 | "github.com/bnema/vev/internal/usecase/broker" | |
| 14 | 14 | ) | |
| 15 | 15 | ||
| 16 | - | // p3cResolver is a test BrokerEndpointResolver: it returns one authenticated | |
| 16 | + | // p3cResolver is a test BrokerRouteAuthority: it returns one authenticated | |
| 17 | 17 | // endpoint per request, keyed on the request's registration endpoint so two | |
| 18 | 18 | // compatible aliases resolve to the same (identity, policy) physical key with | |
| 19 | 19 | // distinct adapter routes. | |
| 20 | - | type p3cResolver func(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerResolvedEndpoint, error) | |
| 20 | + | type p3cResolver func(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerDialTarget, error) | |
| 21 | 21 | ||
| 22 | - | func (f p3cResolver) Resolve(ctx context.Context, request ports.BrokerOpenStreamRequest) (ports.BrokerResolvedEndpoint, error) { | |
| 22 | + | func (f p3cResolver) ResolveDialTarget(ctx context.Context, request ports.BrokerOpenStreamRequest) (ports.BrokerDialTarget, error) { | |
| 23 | 23 | return f(ctx, request) | |
| 24 | 24 | } | |
| 25 | 25 | ||
| 26 | + | type p3cBinder struct{} | |
| 27 | + | ||
| 28 | + | func (p3cBinder) BindAuthenticatedIdentity(_ context.Context, request ports.BrokerIdentityBindingRequest) (ports.BrokerDaemonIdentity, error) { | |
| 29 | + | return request.Identity, nil | |
| 30 | + | } | |
| 31 | + | ||
| 26 | 32 | // p3cRemoteRequest builds one valid remote control-purpose request. | |
| 27 | 33 | func p3cRemoteRequest(connection ports.BrokerConnectionID, endpoint string, stream uint64, policy ports.BrokerPolicy) ports.BrokerOpenStreamRequest { | |
| 28 | 34 | registration := domain.RemoteRegistration{ | |
| @@ -39,6 +45,7 @@ func p3cRemoteRequest(connection ports.BrokerConnectionID, endpoint string, stre | |||
| 39 | 45 | Endpoint: endpoint, | |
| 40 | 46 | Registration: registration, | |
| 41 | 47 | Policy: policy, | |
| 48 | + | StartMode: ports.BrokerDaemonStartIfNeeded, | |
| 42 | 49 | } | |
| 43 | 50 | } | |
| 44 | 51 | ||
| @@ -51,17 +58,17 @@ func p3cPoolLimits() broker.PoolLimits { | |||
| 51 | 58 | // compatible aliases over exactly one daemonmux physical connection: only one | |
| 52 | 59 | // carriage is dialed, and both logical streams flow over it. | |
| 53 | 60 | func TestPoolSharesCompatibleAliasesOverOnePhysical(t *testing.T) { | |
| 54 | - | binding := mustServerBinding(t) | |
| 61 | + | binding := mustRemoteServerBinding(t) | |
| 55 | 62 | policy := binding.Policy() | |
| 56 | 63 | server := newSuperviseServer(t, binding, DefaultMuxCeilings(), 0, nil) | |
| 57 | 64 | server.serve() | |
| 58 | - | resolver := p3cResolver(func(_ context.Context, request ports.BrokerOpenStreamRequest) (ports.BrokerResolvedEndpoint, error) { | |
| 65 | + | resolver := p3cResolver(func(_ context.Context, request ports.BrokerOpenStreamRequest) (ports.BrokerDialTarget, error) { | |
| 59 | 66 | return muxEndpoint(binding.Identity(), policy, "raw://"+request.Endpoint), nil | |
| 60 | 67 | }) | |
| 61 | 68 | ||
| 62 | 69 | connector, err := NewEndpointConnector(server.dial(), DefaultMuxCeilings()) | |
| 63 | 70 | require.NoError(t, err) | |
| 64 | - | pool, err := broker.NewPool(1, resolver, connector, newListenerClock(time.Now()), p3cPoolLimits()) | |
| 71 | + | pool, err := broker.NewPool(1, resolver, p3cBinder{}, connector, newListenerClock(time.Now()), p3cPoolLimits()) | |
| 65 | 72 | require.NoError(t, err) | |
| 66 | 73 | t.Cleanup(func() { _ = pool.Close() }) | |
| 67 | 74 | ||
| @@ -95,13 +102,13 @@ func TestPoolSharesCompatibleAliasesOverOnePhysical(t *testing.T) { | |||
| 95 | 102 | func TestPoolRefusesConflictingPolicyWithoutDial(t *testing.T) { | |
| 96 | 103 | binding := mustServerBinding(t) | |
| 97 | 104 | server := newSuperviseServer(t, binding, DefaultMuxCeilings(), 0, nil) | |
| 98 | - | resolver := p3cResolver(func(_ context.Context, _ ports.BrokerOpenStreamRequest) (ports.BrokerResolvedEndpoint, error) { | |
| 105 | + | resolver := p3cResolver(func(_ context.Context, _ ports.BrokerOpenStreamRequest) (ports.BrokerDialTarget, error) { | |
| 99 | 106 | return muxEndpoint(binding.Identity(), binding.Policy(), "raw://conflict"), nil | |
| 100 | 107 | }) | |
| 101 | 108 | ||
| 102 | 109 | connector, err := NewEndpointConnector(server.dial(), DefaultMuxCeilings()) | |
| 103 | 110 | require.NoError(t, err) | |
| 104 | - | pool, err := broker.NewPool(1, resolver, connector, newListenerClock(time.Now()), p3cPoolLimits()) | |
| 111 | + | pool, err := broker.NewPool(1, resolver, p3cBinder{}, connector, newListenerClock(time.Now()), p3cPoolLimits()) | |
| 105 | 112 | require.NoError(t, err) | |
| 106 | 113 | t.Cleanup(func() { _ = pool.Close() }) | |
| 107 | 114 | ||
| @@ -119,17 +126,17 @@ func TestPoolRefusesConflictingPolicyWithoutDial(t *testing.T) { | |||
| 119 | 126 | // per affected logical stream through the pool: the pooled stream terminates | |
| 120 | 127 | // with a broker stream-lost error once the accepted physical carriage dies. | |
| 121 | 128 | func TestPoolSurfacesPhysicalLossPerStream(t *testing.T) { | |
| 122 | - | binding := mustServerBinding(t) | |
| 129 | + | binding := mustRemoteServerBinding(t) | |
| 123 | 130 | policy := binding.Policy() | |
| 124 | 131 | server := newSuperviseServer(t, binding, DefaultMuxCeilings(), 0, nil) | |
| 125 | 132 | server.serve() | |
| 126 | - | resolver := p3cResolver(func(_ context.Context, request ports.BrokerOpenStreamRequest) (ports.BrokerResolvedEndpoint, error) { | |
| 133 | + | resolver := p3cResolver(func(_ context.Context, request ports.BrokerOpenStreamRequest) (ports.BrokerDialTarget, error) { | |
| 127 | 134 | return muxEndpoint(binding.Identity(), policy, "raw://"+request.Endpoint), nil | |
| 128 | 135 | }) | |
| 129 | 136 | ||
| 130 | 137 | connector, err := NewEndpointConnector(server.dial(), DefaultMuxCeilings()) | |
| 131 | 138 | require.NoError(t, err) | |
| 132 | - | pool, err := broker.NewPool(1, resolver, connector, newListenerClock(time.Now()), p3cPoolLimits()) | |
| 139 | + | pool, err := broker.NewPool(1, resolver, p3cBinder{}, connector, newListenerClock(time.Now()), p3cPoolLimits()) | |
| 133 | 140 | require.NoError(t, err) | |
| 134 | 141 | t.Cleanup(func() { _ = pool.Close() }) | |
| 135 | 142 | ||
M internal/adapters/daemonmux/quic_carriage_test.go +3 -3
| @@ -54,8 +54,8 @@ func quicCarriageEndpoint(t *testing.T) (*quic.Server, quic.Readiness, string) { | |||
| 54 | 54 | // single-use token/nonce - and hands the raw bounded transport to the connector | |
| 55 | 55 | // unmodified. | |
| 56 | 56 | func quicDialer(readiness quic.Readiness) RawCarrierDialer { | |
| 57 | - | return func(ctx context.Context, address string) (RawFramedTransport, error) { | |
| 58 | - | raw, err := quic.DialMuxContext(ctx, address, readiness, quic.Config{}, quicCarriageDeadline) | |
| 57 | + | return func(ctx context.Context, target ports.BrokerDialTarget) (RawFramedTransport, error) { | |
| 58 | + | raw, err := quic.DialMuxContext(ctx, target.Address, readiness, quic.Config{}, quicCarriageDeadline) | |
| 59 | 59 | if err != nil { | |
| 60 | 60 | return nil, err | |
| 61 | 61 | } | |
| @@ -66,7 +66,7 @@ func quicDialer(readiness quic.Readiness) RawCarrierDialer { | |||
| 66 | 66 | // quicDialCarriage establishes the broker end of one real QUIC mux carriage. | |
| 67 | 67 | func quicDialCarriage(t *testing.T, ctx context.Context, addr string, readiness quic.Readiness) RawFramedTransport { | |
| 68 | 68 | t.Helper() | |
| 69 | - | raw, err := quicDialer(readiness)(ctx, addr) | |
| 69 | + | raw, err := quicDialer(readiness)(ctx, ports.BrokerDialTarget{Address: addr}) | |
| 70 | 70 | require.NoError(t, err) | |
| 71 | 71 | t.Cleanup(func() { _ = raw.Close() }) | |
| 72 | 72 | return raw | |
M internal/adapters/daemonmux/ssh_carriage_test.go +1 -1
| @@ -183,7 +183,7 @@ func sshMuxHelperCommand(mode string, extra ...string) sshstdio.CommandSpec { | |||
| 183 | 183 | // specification and a counter of how many physical carriages it opened. | |
| 184 | 184 | func sshMuxDialer(spec sshstdio.CommandSpec) (RawCarrierDialer, *atomic.Int32) { | |
| 185 | 185 | dials := &atomic.Int32{} | |
| 186 | - | return func(ctx context.Context, _ string) (RawFramedTransport, error) { | |
| 186 | + | return func(ctx context.Context, _ ports.BrokerDialTarget) (RawFramedTransport, error) { | |
| 187 | 187 | dials.Add(1) | |
| 188 | 188 | return sshstdio.DialMuxContext(ctx, spec, nil) | |
| 189 | 189 | }, dials | |
M internal/adapters/daemonmux/supervisor.go +6 -3
| @@ -177,13 +177,16 @@ func (s *ServerSupervisor) Adopt(ctx context.Context, raw RawFramedTransport) er | |||
| 177 | 177 | } | |
| 178 | 178 | // Enforce the accepted physical policy before any fresh inbound Open can be | |
| 179 | 179 | // admitted: the pump's engine must be restricted before the pump starts, | |
| 180 | - | // and the listener below is built before that start too. | |
| 181 | - | if err := pump.Engine().RestrictAdmissions(result.Binding.Policy()); err != nil { | |
| 180 | + | // and the listener below is built before that start too. The accepted entry | |
| 181 | + | // carries both the exact policy the engine enforces and the locality origin | |
| 182 | + | // the listener stamps on every admitted session connection. | |
| 183 | + | accepted := ServerPolicyAdmission{Policy: result.Policy, Origin: result.Origin} | |
| 184 | + | if err := pump.Engine().RestrictAdmissions(accepted.Policy); err != nil { | |
| 182 | 185 | _ = pump.Close() | |
| 183 | 186 | return err | |
| 184 | 187 | } | |
| 185 | 188 | ||
| 186 | - | listener, err := newListener(pump, protocol.HandshakeTimeout, MaxAcceptQueue, s.clock) | |
| 189 | + | listener, err := newListener(pump, protocol.HandshakeTimeout, MaxAcceptQueue, s.clock, accepted) | |
| 187 | 190 | if err != nil { | |
| 188 | 191 | _ = pump.Close() | |
| 189 | 192 | return err | |
M internal/adapters/daemonmux/supervisor_test.go +159 -3
| @@ -7,15 +7,16 @@ import ( | |||
| 7 | 7 | ||
| 8 | 8 | "github.com/stretchr/testify/require" | |
| 9 | 9 | ||
| 10 | + | "github.com/bnema/vev/internal/domain" | |
| 10 | 11 | "github.com/bnema/vev/internal/ports" | |
| 11 | 12 | "github.com/bnema/vev/internal/protocol" | |
| 12 | 13 | ) | |
| 13 | 14 | ||
| 14 | 15 | // connectRaw builds one broker PhysicalConnection over an already established | |
| 15 | 16 | // raw carriage whose daemon end was handed to a supervisor. | |
| 16 | - | func connectRaw(t *testing.T, raw RawFramedTransport, endpoint ports.BrokerResolvedEndpoint) ports.BrokerPhysicalConnection { | |
| 17 | + | func connectRaw(t *testing.T, raw RawFramedTransport, endpoint ports.BrokerDialTarget) ports.BrokerPhysicalConnection { | |
| 17 | 18 | t.Helper() | |
| 18 | - | connector, err := NewEndpointConnector(func(context.Context, string) (RawFramedTransport, error) { | |
| 19 | + | connector, err := NewEndpointConnector(func(context.Context, ports.BrokerDialTarget) (RawFramedTransport, error) { | |
| 19 | 20 | return raw, nil | |
| 20 | 21 | }, DefaultMuxCeilings()) | |
| 21 | 22 | require.NoError(t, err) | |
| @@ -28,7 +29,7 @@ func connectRaw(t *testing.T, raw RawFramedTransport, endpoint ports.BrokerResol | |||
| 28 | 29 | ||
| 29 | 30 | // awaitHandshakeBridge performs the broker side of the daemonmux physical | |
| 30 | 31 | // preamble over raw and returns the negotiated carrier, ready for a pump. | |
| 31 | - | func awaitHandshakeBridge(t *testing.T, raw RawFramedTransport, endpoint ports.BrokerResolvedEndpoint) (FramedCarrier, ClientHandshakeResult) { | |
| 32 | + | func awaitHandshakeBridge(t *testing.T, raw RawFramedTransport, endpoint ports.BrokerDialTarget) (FramedCarrier, ClientHandshakeResult) { | |
| 32 | 33 | t.Helper() | |
| 33 | 34 | bridge, err := NewPreambleCarrier(raw) | |
| 34 | 35 | require.NoError(t, err) | |
| @@ -117,6 +118,161 @@ func TestServerSupervisorEnforcesAcceptedPolicyBeforeAdmission(t *testing.T) { | |||
| 117 | 118 | require.Equal(t, protocol.Pong{}, message) | |
| 118 | 119 | } | |
| 119 | 120 | ||
| 121 | + | // TestServerSupervisorDeliversFirstHelloDataFrame proves the complete first | |
| 122 | + | // application-frame path without timing probes: LogicalConnection.SendClient | |
| 123 | + | // writes Hello through sessionwire, the physical pump emits Data, the | |
| 124 | + | // supervisor-owned daemon pump dispatches it, and the accepted logical server | |
| 125 | + | // connection's sessionwire ReceiveClient decodes that same Hello. This guards | |
| 126 | + | // the Opened-to-first-Data boundary where losing a wakeup or using the wrong | |
| 127 | + | // direction/physical ID would otherwise leave the daemon blocked forever. | |
| 128 | + | func TestServerSupervisorDeliversFirstHelloDataFrame(t *testing.T) { | |
| 129 | + | binding := mustServerBinding(t) | |
| 130 | + | aggregate := NewAggregateListener() | |
| 131 | + | supervisor, err := NewServerSupervisor(aggregate, binding, DefaultMuxCeilings(), 1) | |
| 132 | + | require.NoError(t, err) | |
| 133 | + | t.Cleanup(func() { | |
| 134 | + | _ = supervisor.Close() | |
| 135 | + | _ = aggregate.Close() | |
| 136 | + | }) | |
| 137 | + | ||
| 138 | + | clientRaw, serverRaw := rawCarriagePair(t) | |
| 139 | + | adopted := make(chan error, 1) | |
| 140 | + | go func() { adopted <- supervisor.Adopt(context.Background(), serverRaw) }() | |
| 141 | + | physical := connectRaw(t, clientRaw, muxEndpoint(binding.Identity(), binding.Policy(), "raw://first-hello")) | |
| 142 | + | require.NoError(t, <-adopted) | |
| 143 | + | ||
| 144 | + | accepted := make(chan ports.ServerConnection, 1) | |
| 145 | + | acceptErr := make(chan error, 1) | |
| 146 | + | go func() { | |
| 147 | + | connection, err := aggregate.Accept() | |
| 148 | + | if err != nil { | |
| 149 | + | acceptErr <- err | |
| 150 | + | return | |
| 151 | + | } | |
| 152 | + | accepted <- connection | |
| 153 | + | }() | |
| 154 | + | ||
| 155 | + | logical, err := physical.OpenStream(context.Background(), muxOpenRequest(1, binding.Policy())) | |
| 156 | + | require.NoError(t, err) | |
| 157 | + | var server ports.ServerConnection | |
| 158 | + | select { | |
| 159 | + | case server = <-accepted: | |
| 160 | + | case err := <-acceptErr: | |
| 161 | + | require.NoError(t, err) | |
| 162 | + | case <-time.After(p3cTestDeadline): | |
| 163 | + | t.Fatal("supervisor did not dispatch the admitted logical stream") | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | hello := protocol.Hello{Version: protocol.Version, Intent: protocol.IntentEphemeral, Size: domain.Size{Cols: 80, Rows: 24}, EnvironmentPolicy: protocol.EnvironmentPolicyClientOwned} | |
| 167 | + | sent := make(chan error, 1) | |
| 168 | + | received := make(chan protocol.ClientMessage, 1) | |
| 169 | + | receiveErr := make(chan error, 1) | |
| 170 | + | go func() { sent <- logical.SendClient(hello) }() | |
| 171 | + | go func() { | |
| 172 | + | message, err := server.ReceiveClient() | |
| 173 | + | if err != nil { | |
| 174 | + | receiveErr <- err | |
| 175 | + | return | |
| 176 | + | } | |
| 177 | + | received <- message | |
| 178 | + | }() | |
| 179 | + | select { | |
| 180 | + | case message := <-received: | |
| 181 | + | require.Equal(t, hello, message) | |
| 182 | + | case err := <-receiveErr: | |
| 183 | + | require.NoError(t, err) | |
| 184 | + | case <-time.After(p3cTestDeadline): | |
| 185 | + | t.Fatal("first Hello Data frame did not reach sessionwire ReceiveClient") | |
| 186 | + | } | |
| 187 | + | require.NoError(t, <-sent) | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | // TestServerSupervisorStampsAcceptedOriginThroughAggregate proves the accepted | |
| 191 | + | // physical origin the supervisor resolved from the binding's provisioned member | |
| 192 | + | // reaches the daemon end-to-end: the aggregate hands back the same typed | |
| 193 | + | // connection the child listener produced, and its admission provider reports the | |
| 194 | + | // exact accepted policy and origin. It guards the wrapper chain so a later | |
| 195 | + | // wrapper can never strip a child's admission metadata. | |
| 196 | + | func TestServerSupervisorStampsAcceptedOriginThroughAggregate(t *testing.T) { | |
| 197 | + | binding := mustServerBinding(t) | |
| 198 | + | aggregate := NewAggregateListener() | |
| 199 | + | supervisor, err := NewServerSupervisor(aggregate, binding, DefaultMuxCeilings(), 1) | |
| 200 | + | require.NoError(t, err) | |
| 201 | + | t.Cleanup(func() { | |
| 202 | + | _ = supervisor.Close() | |
| 203 | + | _ = aggregate.Close() | |
| 204 | + | }) | |
| 205 | + | ||
| 206 | + | clientRaw, serverRaw := rawCarriagePair(t) | |
| 207 | + | adopted := make(chan error, 1) | |
| 208 | + | go func() { adopted <- supervisor.Adopt(context.Background(), serverRaw) }() | |
| 209 | + | physical := connectRaw(t, clientRaw, muxEndpoint(binding.Identity(), binding.Policy(), "raw://origin")) | |
| 210 | + | require.NoError(t, <-adopted) | |
| 211 | + | ||
| 212 | + | accepted := make(chan ports.ServerConnection, 1) | |
| 213 | + | acceptErr := make(chan error, 1) | |
| 214 | + | go func() { | |
| 215 | + | connection, err := aggregate.Accept() | |
| 216 | + | if err != nil { | |
| 217 | + | acceptErr <- err | |
| 218 | + | return | |
| 219 | + | } | |
| 220 | + | accepted <- connection | |
| 221 | + | }() | |
| 222 | + | ||
| 223 | + | logical, err := physical.OpenStream(context.Background(), muxOpenRequest(1, binding.Policy())) | |
| 224 | + | require.NoError(t, err) | |
| 225 | + | t.Cleanup(func() { _ = logical.Close() }) | |
| 226 | + | ||
| 227 | + | var server ports.ServerConnection | |
| 228 | + | select { | |
| 229 | + | case server = <-accepted: | |
| 230 | + | case err := <-acceptErr: | |
| 231 | + | require.NoError(t, err) | |
| 232 | + | case <-time.After(p3cTestDeadline): | |
| 233 | + | t.Fatal("supervisor did not dispatch the admitted logical stream") | |
| 234 | + | } | |
| 235 | + | ||
| 236 | + | provider, ok := server.(ports.SessionAdmissionProvider) | |
| 237 | + | require.True(t, ok, "the accepted connection preserves the admission provider through the aggregate") | |
| 238 | + | admission, ok := provider.SessionAdmission() | |
| 239 | + | require.True(t, ok) | |
| 240 | + | require.Equal(t, ports.SessionOriginLocal, admission.Origin) | |
| 241 | + | require.Equal(t, binding.Policy(), admission.Policy) | |
| 242 | + | require.Equal(t, ports.BrokerStreamControl, admission.Purpose) | |
| 243 | + | require.NoError(t, admission.Validate()) | |
| 244 | + | } | |
| 245 | + | ||
| 246 | + | // TestServerSupervisorRefusesLiarRegistration proves the supervisor's accepted | |
| 247 | + | // origin reaches the listener's admission check: a client that lies about its | |
| 248 | + | // locality - a local registration on a local carriage - is refused on its own | |
| 249 | + | // stream and never delivered to the daemon. | |
| 250 | + | func TestServerSupervisorRefusesLiarRegistration(t *testing.T) { | |
| 251 | + | binding := mustServerBinding(t) | |
| 252 | + | server := newSuperviseServer(t, binding, DefaultMuxCeilings(), 0, nil) | |
| 253 | + | server.serve() | |
| 254 | + | ||
| 255 | + | clientRaw, serverRaw := rawCarriagePair(t) | |
| 256 | + | server.adopt(context.Background(), serverRaw) | |
| 257 | + | carrier, result := awaitHandshakeBridge(t, clientRaw, muxEndpoint(binding.Identity(), binding.Policy(), "raw://liar")) | |
| 258 | + | require.NoError(t, server.awaitAdoption(t)) | |
| 259 | + | ||
| 260 | + | pump, err := NewPump(carrier, DirectionServer, result.Ceilings) | |
| 261 | + | require.NoError(t, err) | |
| 262 | + | t.Cleanup(func() { _ = pump.Close() }) | |
| 263 | + | pump.Start(context.Background()) | |
| 264 | + | logical, err := NewLogicalConnector(pump) | |
| 265 | + | require.NoError(t, err) | |
| 266 | + | ||
| 267 | + | // A remote-shaped Open (a local carriage with a registration) contradicts | |
| 268 | + | // the accepted local origin: the listener refuses it rather than stamping a | |
| 269 | + | // provenance it did not provision. | |
| 270 | + | liar := p3cRemoteRequest(ports.BrokerConnectionID{1}, "dev@host:22", 1, binding.Policy()) | |
| 271 | + | _, err = logical.Open(context.Background(), liar) | |
| 272 | + | require.Error(t, err) | |
| 273 | + | require.Zero(t, server.acceptedCount(), "a locality-contradicting open never reaches the accept stream") | |
| 274 | + | } | |
| 275 | + | ||
| 120 | 276 | // TestServerSupervisorServerLossIsolated proves one lost physical child never | |
| 121 | 277 | // stops the aggregate: a surviving child keeps accepting while the lost child | |
| 122 | 278 | // is reaped and removed. | |
M internal/adapters/daemonmux/unix_carriage_test.go +1 -1
| @@ -82,7 +82,7 @@ func unixRawCarriagePair(t *testing.T) (RawFramedTransport, RawFramedTransport) | |||
| 82 | 82 | // at path through ipc.DialMuxContext (which also verifies same-user peer | |
| 83 | 83 | // credentials). | |
| 84 | 84 | func unixDialer(path string) RawCarrierDialer { | |
| 85 | - | return func(ctx context.Context, _ string) (RawFramedTransport, error) { | |
| 85 | + | return func(ctx context.Context, _ ports.BrokerDialTarget) (RawFramedTransport, error) { | |
| 86 | 86 | raw, err := ipc.DialMuxContext(ctx, path) | |
| 87 | 87 | if err != nil { | |
| 88 | 88 | return nil, err | |
D internal/adapters/quicnettest/reconnect_resume_test.go +0 -459
| @@ -1,459 +0,0 @@ | |||
| 1 | - | package quicnettest_test | |
| 2 | - | ||
| 3 | - | // This file proves the application-level QUIC reconnect path end to end with | |
| 4 | - | // no fake carriage anywhere: | |
| 5 | - | // | |
| 6 | - | // - the real thin-client use case (internal/usecase/client.Run) drives the | |
| 7 | - | // attach lifecycle, including its reconnect loop and resume-token handling; | |
| 8 | - | // - the real sessionwire typed adapter wraps both ends; | |
| 9 | - | // - two successive, genuinely separate QUIC connections run over the real | |
| 10 | - | // internal/adapters/quic transport through the real UDP proxy; | |
| 11 | - | // - the first connection is killed irreversibly (QUIC has no resumption here, | |
| 12 | - | // 0-RTT is disabled) by closing the accepted server transport; | |
| 13 | - | // - the resumed connection must carry a second preamble+handshake, a Hello | |
| 14 | - | // with IntentResume, the same ClientID and the previous resume token, a | |
| 15 | - | // Welcome with a renewed token, and a fresh full Output that the client | |
| 16 | - | // accepts and ACKs. | |
| 17 | - | // | |
| 18 | - | // The daemon side is a scripted typed responder: it speaks only the real | |
| 19 | - | // sessionwire protocol over the real QUIC carriage. No sshd, no netem, no root, | |
| 20 | - | // and no fake QUIC transport is involved. | |
| 21 | - | ||
| 22 | - | import ( | |
| 23 | - | "bytes" | |
| 24 | - | "context" | |
| 25 | - | "errors" | |
| 26 | - | "fmt" | |
| 27 | - | "io" | |
| 28 | - | "log/slog" | |
| 29 | - | "net" | |
| 30 | - | "sync" | |
| 31 | - | "sync/atomic" | |
| 32 | - | "testing" | |
| 33 | - | "time" | |
| 34 | - | ||
| 35 | - | "github.com/bnema/vev/internal/adapters/clock" | |
| 36 | - | quicadapter "github.com/bnema/vev/internal/adapters/quic" | |
| 37 | - | "github.com/bnema/vev/internal/adapters/quicnettest" | |
| 38 | - | "github.com/bnema/vev/internal/adapters/sessionwire" | |
| 39 | - | "github.com/bnema/vev/internal/domain" | |
| 40 | - | "github.com/bnema/vev/internal/ports" | |
| 41 | - | "github.com/bnema/vev/internal/protocol" | |
| 42 | - | "github.com/bnema/vev/internal/usecase/client" | |
| 43 | - | ) | |
| 44 | - | ||
| 45 | - | const ( | |
| 46 | - | reconnectProofTimeout = 30 * time.Second | |
| 47 | - | reconnectSessionName = "work" | |
| 48 | - | ) | |
| 49 | - | ||
| 50 | - | func TestRealQUICApplicationReconnectResumesTypedSession(t *testing.T) { | |
| 51 | - | cert, fingerprint, err := quicadapter.GenerateEphemeralCert() | |
| 52 | - | if err != nil { | |
| 53 | - | t.Fatalf("generate cert: %v", err) | |
| 54 | - | } | |
| 55 | - | listener, err := quicadapter.ListenConfig("127.0.0.1:0", cert, quicadapter.Config{}, 4) | |
| 56 | - | if err != nil { | |
| 57 | - | t.Fatalf("listen quic: %v", err) | |
| 58 | - | } | |
| 59 | - | t.Cleanup(func() { _ = listener.Close() }) | |
| 60 | - | ||
| 61 | - | serverAddr, err := net.ResolveUDPAddr("udp", listener.Addr()) | |
| 62 | - | if err != nil { | |
| 63 | - | t.Fatalf("resolve listener address %q: %v", listener.Addr(), err) | |
| 64 | - | } | |
| 65 | - | proxy, err := quicnettest.New(quicnettest.Config{ | |
| 66 | - | ServerAddr: serverAddr, | |
| 67 | - | Seed: 20260915, | |
| 68 | - | }) | |
| 69 | - | if err != nil { | |
| 70 | - | t.Fatalf("new proxy: %v", err) | |
| 71 | - | } | |
| 72 | - | t.Cleanup(func() { _ = proxy.Close() }) | |
| 73 | - | ||
| 74 | - | // The dialer is the production composition: a real QUIC dialer with the | |
| 75 | - | // pinned certificate fingerprint, wrapped by the typed sessionwire dialer. | |
| 76 | - | // The counter is observational only; every dial still opens a real QUIC | |
| 77 | - | // connection through the proxy. | |
| 78 | - | rawDialer := quicadapter.DialConfig(proxy.Addr().String(), "vev-reconnect", fingerprint, quicadapter.Config{}, 15*time.Second) | |
| 79 | - | dialer := &reconnectCountingDialer{inner: sessionwire.NewClientDialer(rawDialer)} | |
| 80 | - | ||
| 81 | - | term := newReconnectProofTerminal() | |
| 82 | - | t.Cleanup(term.unblock) | |
| 83 | - | ||
| 84 | - | deps := client.Dependencies{ | |
| 85 | - | Dialer: dialer, | |
| 86 | - | Terminal: term, | |
| 87 | - | Clock: clock.New(), | |
| 88 | - | DisableCapabilityProbe: true, | |
| 89 | - | Logger: slog.New(slog.DiscardHandler), | |
| 90 | - | } | |
| 91 | - | ||
| 92 | - | obs := &reconnectProofObservations{} | |
| 93 | - | var obsMu sync.Mutex | |
| 94 | - | daemonDone := make(chan error, 1) | |
| 95 | - | go func() { daemonDone <- serveReconnectProof(listener, obs, &obsMu) }() | |
| 96 | - | ||
| 97 | - | ctx, cancel := context.WithTimeout(context.Background(), reconnectProofTimeout) | |
| 98 | - | defer cancel() | |
| 99 | - | runResult := make(chan error, 1) | |
| 100 | - | go func() { | |
| 101 | - | runResult <- client.NewRunner(deps).Run(ctx, client.AttachRequest{ | |
| 102 | - | Intent: protocol.IntentAttach, | |
| 103 | - | SessionName: reconnectSessionName, | |
| 104 | - | }) | |
| 105 | - | }() | |
| 106 | - | ||
| 107 | - | select { | |
| 108 | - | case err := <-daemonDone: | |
| 109 | - | if err != nil { | |
| 110 | - | t.Fatalf("scripted resume daemon: %v", err) | |
| 111 | - | } | |
| 112 | - | case <-time.After(reconnectProofTimeout): | |
| 113 | - | t.Fatal("timed out waiting for the resumed session to complete") | |
| 114 | - | } | |
| 115 | - | select { | |
| 116 | - | case err := <-runResult: | |
| 117 | - | if err != nil { | |
| 118 | - | t.Fatalf("client run: %v", err) | |
| 119 | - | } | |
| 120 | - | case <-time.After(reconnectProofTimeout): | |
| 121 | - | t.Fatal("timed out waiting for the client run to return") | |
| 122 | - | } | |
| 123 | - | ||
| 124 | - | obsMu.Lock() | |
| 125 | - | got := *obs | |
| 126 | - | obsMu.Unlock() | |
| 127 | - | ||
| 128 | - | if got.accepts != 2 { | |
| 129 | - | t.Fatalf("listener accepted %d QUIC connections, want 2 (first must die irreversibly)", got.accepts) | |
| 130 | - | } | |
| 131 | - | if dialer.calls.Load() != 2 { | |
| 132 | - | t.Fatalf("client dialed %d times, want exactly 2 real QUIC connections", dialer.calls.Load()) | |
| 133 | - | } | |
| 134 | - | ||
| 135 | - | // First connection: a fresh attach, no resume token. | |
| 136 | - | if got.firstHello.Intent != protocol.IntentAttach { | |
| 137 | - | t.Fatalf("first Hello.Intent = %d, want IntentAttach(%d)", got.firstHello.Intent, protocol.IntentAttach) | |
| 138 | - | } | |
| 139 | - | if got.firstHello.ResumeToken != 0 { | |
| 140 | - | t.Fatalf("first Hello.ResumeToken = %d, want 0", got.firstHello.ResumeToken) | |
| 141 | - | } | |
| 142 | - | if got.firstHello.Name != reconnectSessionName { | |
| 143 | - | t.Fatalf("first Hello.Name = %q, want %q", got.firstHello.Name, reconnectSessionName) | |
| 144 | - | } | |
| 145 | - | ||
| 146 | - | // Second connection: the client must resume, not start over. | |
| 147 | - | if got.secondHello.Intent != protocol.IntentResume { | |
| 148 | - | t.Fatalf("second Hello.Intent = %d, want IntentResume(%d)", got.secondHello.Intent, protocol.IntentResume) | |
| 149 | - | } | |
| 150 | - | if got.secondHello.ClientID != got.firstHello.ClientID { | |
| 151 | - | t.Fatalf("second Hello.ClientID = %x, want the first %x", got.secondHello.ClientID, got.firstHello.ClientID) | |
| 152 | - | } | |
| 153 | - | if got.secondHello.ResumeToken != got.firstWelcome.ResumeToken { | |
| 154 | - | t.Fatalf("second Hello.ResumeToken = %d, want the first Welcome token %d", got.secondHello.ResumeToken, got.firstWelcome.ResumeToken) | |
| 155 | - | } | |
| 156 | - | if got.secondHello.Name != reconnectSessionName { | |
| 157 | - | t.Fatalf("second Hello.Name = %q, want %q", got.secondHello.Name, reconnectSessionName) | |
| 158 | - | } | |
| 159 | - | if got.secondWelcome.ResumeToken == got.firstWelcome.ResumeToken || got.secondWelcome.ResumeToken == 0 { | |
| 160 | - | t.Fatalf("second Welcome.ResumeToken = %d, want a renewal distinct from %d", got.secondWelcome.ResumeToken, got.firstWelcome.ResumeToken) | |
| 161 | - | } | |
| 162 | - | ||
| 163 | - | // Fresh, resynchronised first Output: the resumed attachment starts from a | |
| 164 | - | // full publication, the client accepts it (no reset request), and ACKs it. | |
| 165 | - | if got.secondOutputResetRequested { | |
| 166 | - | t.Fatal("client requested an output reset instead of accepting the fresh resumed Output") | |
| 167 | - | } | |
| 168 | - | if got.secondAck.Epoch != got.secondOutputEpoch || got.secondAck.State != got.secondOutputState { | |
| 169 | - | t.Fatalf("second Ack = {%d,%d}, want {%d,%d}", got.secondAck.Epoch, got.secondAck.State, got.secondOutputEpoch, got.secondOutputState) | |
| 170 | - | } | |
| 171 | - | if !bytes.Contains([]byte(term.output()), []byte(reconnectResumedFrame)) { | |
| 172 | - | t.Fatalf("client terminal never received the resumed fresh Output %q", reconnectResumedFrame) | |
| 173 | - | } | |
| 174 | - | ||
| 175 | - | stats := waitForAccounting(t, proxy, 10*time.Second) | |
| 176 | - | if stats.ToServer.Received == 0 || stats.ToClient.Received == 0 { | |
| 177 | - | t.Fatalf("proxy carried no QUIC traffic: %s", stats) | |
| 178 | - | } | |
| 179 | - | if stats.ToServer.OverflowDrops != 0 || stats.ToClient.OverflowDrops != 0 { | |
| 180 | - | t.Fatalf("bounded proxy queue overflowed: %s", stats) | |
| 181 | - | } | |
| 182 | - | if stats.ToServer.DeliveryErrors != 0 || stats.ToClient.DeliveryErrors != 0 { | |
| 183 | - | t.Fatalf("proxy delivery failed: %s", stats) | |
| 184 | - | } | |
| 185 | - | if stats.StrayPackets != 0 { | |
| 186 | - | t.Fatalf("proxy observed %d stray packets, want 0", stats.StrayPackets) | |
| 187 | - | } | |
| 188 | - | } | |
| 189 | - | ||
| 190 | - | const ( | |
| 191 | - | reconnectInitialToken = uint64(0x1111) | |
| 192 | - | reconnectRenewedToken = uint64(0x2222) | |
| 193 | - | reconnectFirstFrame = "first frame" | |
| 194 | - | reconnectResumedFrame = "resumed frame" | |
| 195 | - | ) | |
| 196 | - | ||
| 197 | - | // reconnectProofObservations is the daemon-side record, written by the daemon | |
| 198 | - | // goroutine and read by the test goroutine after daemonDone closes. | |
| 199 | - | type reconnectProofObservations struct { | |
| 200 | - | accepts int | |
| 201 | - | ||
| 202 | - | firstHello protocol.Hello | |
| 203 | - | firstWelcome protocol.Welcome | |
| 204 | - | firstAck protocol.Ack | |
| 205 | - | ||
| 206 | - | secondHello protocol.Hello | |
| 207 | - | secondWelcome protocol.Welcome | |
| 208 | - | secondAck protocol.Ack | |
| 209 | - | // secondOutputEpoch/State are the boundary of the fresh resumed Output the | |
| 210 | - | // daemon sent; the Ack must match them. | |
| 211 | - | secondOutputEpoch uint64 | |
| 212 | - | secondOutputState uint64 | |
| 213 | - | secondOutputResetRequested bool | |
| 214 | - | } | |
| 215 | - | ||
| 216 | - | // serveReconnectProof drives two successive real QUIC connections as a typed | |
| 217 | - | // sessionwire server: a first attach that publishes output and is then killed | |
| 218 | - | // irreversibly, and a second resume that must carry the old token. | |
| 219 | - | func serveReconnectProof(listener *quicadapter.Listener, obs *reconnectProofObservations, mu *sync.Mutex) error { | |
| 220 | - | // --- First connection: fresh attach, then irreversible death. --- | |
| 221 | - | firstRaw, err := listener.Accept() | |
| 222 | - | if err != nil { | |
| 223 | - | return fmt.Errorf("accept first QUIC connection: %w", err) | |
| 224 | - | } | |
| 225 | - | first := sessionwire.NewServerConnection(firstRaw) | |
| 226 | - | firstHello, err := receiveProofHello(first) | |
| 227 | - | if err != nil { | |
| 228 | - | _ = first.Close() | |
| 229 | - | return fmt.Errorf("first connection: %w", err) | |
| 230 | - | } | |
| 231 | - | mu.Lock() | |
| 232 | - | obs.accepts++ | |
| 233 | - | obs.firstHello = firstHello | |
| 234 | - | mu.Unlock() | |
| 235 | - | ||
| 236 | - | firstWelcome := protocol.Welcome{ | |
| 237 | - | SessionID: "work-session", | |
| 238 | - | SessionName: reconnectSessionName, | |
| 239 | - | ResumeToken: reconnectInitialToken, | |
| 240 | - | Capabilities: protocol.CapabilityResume, | |
| 241 | - | } | |
| 242 | - | if err := first.SendServer(firstWelcome); err != nil { | |
| 243 | - | _ = first.Close() | |
| 244 | - | return fmt.Errorf("first connection: send welcome: %w", err) | |
| 245 | - | } | |
| 246 | - | if err := first.SendOutput(reconnectProofOutput(1, []byte(reconnectFirstFrame))); err != nil { | |
| 247 | - | _ = first.Close() | |
| 248 | - | return fmt.Errorf("first connection: send output: %w", err) | |
| 249 | - | } | |
| 250 | - | firstAck, requestedReset, err := awaitProofAck(first) | |
| 251 | - | if err != nil { | |
| 252 | - | _ = first.Close() | |
| 253 | - | return fmt.Errorf("first connection: %w", err) | |
| 254 | - | } | |
| 255 | - | mu.Lock() | |
| 256 | - | obs.firstWelcome = firstWelcome | |
| 257 | - | obs.firstAck = firstAck | |
| 258 | - | mu.Unlock() | |
| 259 | - | if requestedReset { | |
| 260 | - | _ = first.Close() | |
| 261 | - | return errors.New("first connection: client requested output reset for a fresh publication") | |
| 262 | - | } | |
| 263 | - | // Irreversible death: QUIC resumption/0-RTT is disabled, so closing the | |
| 264 | - | // accepted connection can never be undone by a later packet. | |
| 265 | - | if err := first.Close(); err != nil { | |
| 266 | - | return fmt.Errorf("first connection: irreversible close: %w", err) | |
| 267 | - | } | |
| 268 | - | ||
| 269 | - | // --- Second connection: resume with the previous token. --- | |
| 270 | - | secondRaw, err := listener.Accept() | |
| 271 | - | if err != nil { | |
| 272 | - | return fmt.Errorf("accept second QUIC connection: %w", err) | |
| 273 | - | } | |
| 274 | - | second := sessionwire.NewServerConnection(secondRaw) | |
| 275 | - | secondHello, err := receiveProofHello(second) | |
| 276 | - | if err != nil { | |
| 277 | - | _ = second.Close() | |
| 278 | - | return fmt.Errorf("second connection: %w", err) | |
| 279 | - | } | |
| 280 | - | ||
| 281 | - | secondWelcome := protocol.Welcome{ | |
| 282 | - | SessionID: "work-session", | |
| 283 | - | SessionName: reconnectSessionName, | |
| 284 | - | ResumeToken: reconnectRenewedToken, | |
| 285 | - | Capabilities: protocol.CapabilityResume, | |
| 286 | - | } | |
| 287 | - | if err := second.SendServer(secondWelcome); err != nil { | |
| 288 | - | _ = second.Close() | |
| 289 | - | return fmt.Errorf("second connection: send welcome: %w", err) | |
| 290 | - | } | |
| 291 | - | resumed := reconnectProofOutput(7, []byte(reconnectResumedFrame)) | |
| 292 | - | if err := second.SendOutput(resumed); err != nil { | |
| 293 | - | _ = second.Close() | |
| 294 | - | return fmt.Errorf("second connection: send resumed output: %w", err) | |
| 295 | - | } | |
| 296 | - | secondAck, requestedReset, err := awaitProofAck(second) | |
| 297 | - | if err != nil { | |
| 298 | - | _ = second.Close() | |
| 299 | - | return fmt.Errorf("second connection: %w", err) | |
| 300 | - | } | |
| 301 | - | ||
| 302 | - | mu.Lock() | |
| 303 | - | obs.accepts++ | |
| 304 | - | obs.secondHello = secondHello | |
| 305 | - | obs.secondWelcome = secondWelcome | |
| 306 | - | obs.secondAck = secondAck | |
| 307 | - | obs.secondOutputEpoch = resumed.Epoch | |
| 308 | - | obs.secondOutputState = resumed.New | |
| 309 | - | obs.secondOutputResetRequested = requestedReset | |
| 310 | - | mu.Unlock() | |
| 311 | - | ||
| 312 | - | // End the run cleanly so the client use case returns nil. The connection | |
| 313 | - | // must stay open until the client has consumed the Detached and closed its | |
| 314 | - | // own side: closing first can drop the frame, which the client would read as | |
| 315 | - | // an unrecoverable link loss and answer with yet another resume dial. | |
| 316 | - | if err := second.SendServer(protocol.Detached{Reason: protocol.ReasonDetach}); err != nil { | |
| 317 | - | _ = second.Close() | |
| 318 | - | return fmt.Errorf("second connection: send detached: %w", err) | |
| 319 | - | } | |
| 320 | - | for { | |
| 321 | - | if _, err := second.ReceiveClient(); err != nil { | |
| 322 | - | break | |
| 323 | - | } | |
| 324 | - | } | |
| 325 | - | return second.Close() | |
| 326 | - | } | |
| 327 | - | ||
| 328 | - | func receiveProofHello(conn ports.ServerConnection) (protocol.Hello, error) { | |
| 329 | - | message, err := conn.ReceiveClient() | |
| 330 | - | if err != nil { | |
| 331 | - | return protocol.Hello{}, fmt.Errorf("receive hello: %w", err) | |
| 332 | - | } | |
| 333 | - | hello, ok := message.(protocol.Hello) | |
| 334 | - | if !ok { | |
| 335 | - | return protocol.Hello{}, fmt.Errorf("first message is %T, want protocol.Hello", message) | |
| 336 | - | } | |
| 337 | - | return hello, nil | |
| 338 | - | } | |
| 339 | - | ||
| 340 | - | // awaitProofAck reads client messages until the first Ack, reporting whether a | |
| 341 | - | // reset request was seen first. The bounded skip count keeps a misbehaving peer | |
| 342 | - | // from hiding the Ack behind an unbounded control stream. | |
| 343 | - | func awaitProofAck(conn ports.ServerConnection) (ack protocol.Ack, resetRequested bool, err error) { | |
| 344 | - | for skipped := 0; skipped < 32; skipped++ { | |
| 345 | - | message, recvErr := conn.ReceiveClient() | |
| 346 | - | if recvErr != nil { | |
| 347 | - | return protocol.Ack{}, false, fmt.Errorf("receive ack: %w", recvErr) | |
| 348 | - | } | |
| 349 | - | switch typed := message.(type) { | |
| 350 | - | case protocol.Ack: | |
| 351 | - | return typed, false, nil | |
| 352 | - | case protocol.OutputResetRequest: | |
| 353 | - | return protocol.Ack{}, true, nil | |
| 354 | - | } | |
| 355 | - | } | |
| 356 | - | return protocol.Ack{}, false, errors.New("no ack within 32 client messages") | |
| 357 | - | } | |
| 358 | - | ||
| 359 | - | // reconnectProofOutput builds a valid fresh full publication for the scripted | |
| 360 | - | // daemon. Its data is unique per connection so the client terminal proves which | |
| 361 | - | // frame was applied. | |
| 362 | - | func reconnectProofOutput(publication uint64, data []byte) protocol.Output { | |
| 363 | - | return protocol.Output{ | |
| 364 | - | Epoch: 1, | |
| 365 | - | Base: 0, | |
| 366 | - | New: 1, | |
| 367 | - | ViewRevision: 1, | |
| 368 | - | Size: domain.Size{Cols: 80, Rows: 24}, | |
| 369 | - | Full: true, | |
| 370 | - | Context: &protocol.ViewContext{ | |
| 371 | - | Publication: publication, | |
| 372 | - | Route: protocol.CommittedRouteIdentity{Target: protocol.ExactSessionTarget{ | |
| 373 | - | LifecycleID: domain.SessionLifecycleID{ | |
| 374 | - | 0x52, 0x45, 0x53, 0x55, 0x4d, 0x45, 0x00, 0x01, | |
| 375 | - | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, | |
| 376 | - | }, | |
| 377 | - | SessionName: reconnectSessionName, | |
| 378 | - | }}, | |
| 379 | - | TabID: domain.TabStableID("tab-1"), | |
| 380 | - | FocusedPaneID: domain.PaneStableID("pane-1"), | |
| 381 | - | }, | |
| 382 | - | Data: data, | |
| 383 | - | } | |
| 384 | - | } | |
| 385 | - | ||
| 386 | - | // reconnectCountingDialer counts real dials without replacing the carriage. | |
| 387 | - | type reconnectCountingDialer struct { | |
| 388 | - | inner ports.ClientDialer | |
| 389 | - | calls atomic.Int64 | |
| 390 | - | } | |
| 391 | - | ||
| 392 | - | func (d *reconnectCountingDialer) Dial(ctx context.Context) (ports.ClientConnection, error) { | |
| 393 | - | d.calls.Add(1) | |
| 394 | - | return d.inner.Dial(ctx) | |
| 395 | - | } | |
| 396 | - | ||
| 397 | - | // reconnectProofTerminal is a headless ports.Terminal. Its input never | |
| 398 | - | // produces bytes, so the client's input pump stays parked for the run. | |
| 399 | - | type reconnectProofTerminal struct { | |
| 400 | - | in *reconnectProofInput | |
| 401 | - | mu sync.Mutex | |
| 402 | - | out bytes.Buffer | |
| 403 | - | resize chan domain.Geometry | |
| 404 | - | } | |
| 405 | - | ||
| 406 | - | func newReconnectProofTerminal() *reconnectProofTerminal { | |
| 407 | - | return &reconnectProofTerminal{ | |
| 408 | - | in: &reconnectProofInput{closed: make(chan struct{})}, | |
| 409 | - | resize: make(chan domain.Geometry), | |
| 410 | - | } | |
| 411 | - | } | |
| 412 | - | ||
| 413 | - | func (t *reconnectProofTerminal) EnterRaw() (func() error, error) { | |
| 414 | - | return func() error { return nil }, nil | |
| 415 | - | } | |
| 416 | - | ||
| 417 | - | func (t *reconnectProofTerminal) Geometry() (domain.Geometry, error) { | |
| 418 | - | return domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}, nil | |
| 419 | - | } | |
| 420 | - | ||
| 421 | - | func (t *reconnectProofTerminal) ResizeEvents() <-chan domain.Geometry { return t.resize } | |
| 422 | - | func (t *reconnectProofTerminal) In() io.Reader { return t.in } | |
| 423 | - | func (t *reconnectProofTerminal) Out() io.Writer { | |
| 424 | - | return reconnectProofWriter{mu: &t.mu, buf: &t.out} | |
| 425 | - | } | |
| 426 | - | func (t *reconnectProofTerminal) Flush() error { return nil } | |
| 427 | - | ||
| 428 | - | func (t *reconnectProofTerminal) output() string { | |
| 429 | - | t.mu.Lock() | |
| 430 | - | defer t.mu.Unlock() | |
| 431 | - | return t.out.String() | |
| 432 | - | } | |
| 433 | - | ||
| 434 | - | func (t *reconnectProofTerminal) unblock() { t.in.unblock() } | |
| 435 | - | ||
| 436 | - | type reconnectProofWriter struct { | |
| 437 | - | mu *sync.Mutex | |
| 438 | - | buf *bytes.Buffer | |
| 439 | - | } | |
| 440 | - | ||
| 441 | - | func (w reconnectProofWriter) Write(p []byte) (int, error) { | |
| 442 | - | w.mu.Lock() | |
| 443 | - | defer w.mu.Unlock() | |
| 444 | - | return w.buf.Write(p) | |
| 445 | - | } | |
| 446 | - | ||
| 447 | - | type reconnectProofInput struct { | |
| 448 | - | closed chan struct{} | |
| 449 | - | once sync.Once | |
| 450 | - | } | |
| 451 | - | ||
| 452 | - | func (r *reconnectProofInput) Read([]byte) (int, error) { | |
| 453 | - | <-r.closed | |
| 454 | - | return 0, io.EOF | |
| 455 | - | } | |
| 456 | - | ||
| 457 | - | func (r *reconnectProofInput) unblock() { | |
| 458 | - | r.once.Do(func() { close(r.closed) }) | |
| 459 | - | } | |
D internal/adapters/remote/catalog.go +0 -459
| @@ -1,459 +0,0 @@ | |||
| 1 | - | package remote | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "bytes" | |
| 5 | - | "context" | |
| 6 | - | "encoding/json" | |
| 7 | - | "errors" | |
| 8 | - | "fmt" | |
| 9 | - | "io" | |
| 10 | - | "log/slog" | |
| 11 | - | "os" | |
| 12 | - | "os/exec" | |
| 13 | - | "strings" | |
| 14 | - | "time" | |
| 15 | - | "unicode" | |
| 16 | - | "unicode/utf8" | |
| 17 | - | ||
| 18 | - | "github.com/bnema/vev/internal/adapters/sshstdio" | |
| 19 | - | "github.com/bnema/vev/internal/domain" | |
| 20 | - | appports "github.com/bnema/vev/internal/ports" | |
| 21 | - | "github.com/bnema/vev/internal/protocol" | |
| 22 | - | "github.com/bnema/vev/internal/protocol/catalogue" | |
| 23 | - | ) | |
| 24 | - | ||
| 25 | - | const ( | |
| 26 | - | maxCatalogBytes = 1 << 20 | |
| 27 | - | maxCatalogDiagnosticBytes = 4 << 10 | |
| 28 | - | catalogCommandTimeout = 10 * time.Second | |
| 29 | - | catalogSSHConnectTimeout = 5 * time.Second | |
| 30 | - | catalogCommandWaitDelay = 500 * time.Millisecond | |
| 31 | - | ) | |
| 32 | - | ||
| 33 | - | var ( | |
| 34 | - | errCatalogDecode = errors.New("remote catalog: invalid JSON") | |
| 35 | - | errCatalogTrailing = errors.New("remote catalog: trailing bytes after JSON") | |
| 36 | - | errCatalogRequired = errors.New("remote catalog: missing required fields") | |
| 37 | - | errCatalogSSH = errors.New("remote catalog: ssh command failed") | |
| 38 | - | errCatalogTooLarge = errors.New("remote catalog: output exceeds size limit") | |
| 39 | - | ) | |
| 40 | - | ||
| 41 | - | // sshClientTrustMarkers are OpenSSH-client-originated diagnostics for host-key | |
| 42 | - | // trust failures. They are only consulted when the ssh client itself exited | |
| 43 | - | // 255, which separates client-side failures from remote command output. | |
| 44 | - | var sshClientTrustMarkers = []string{ | |
| 45 | - | "Host key verification failed.", | |
| 46 | - | "REMOTE HOST IDENTIFICATION HAS CHANGED", | |
| 47 | - | "Host key for ", | |
| 48 | - | "No host key is known ", | |
| 49 | - | "No ECDSA host key is known ", | |
| 50 | - | } | |
| 51 | - | ||
| 52 | - | // sshClientAuthMarkers are OpenSSH-client diagnostics for explicit | |
| 53 | - | // authentication failures. Unlike host-key text, these phrases can also be | |
| 54 | - | // emitted by a remote command that exits 255 (ssh propagates the remote | |
| 55 | - | // status), so they only classify when ssh-client framing proves client | |
| 56 | - | // provenance: an `ssh:` line prefix or a `host:` label prefix on the same | |
| 57 | - | // line. Bare matches stay generic transport errors. | |
| 58 | - | var sshClientAuthMarkers = []string{ | |
| 59 | - | "Permission denied ", | |
| 60 | - | "Permission denied (", | |
| 61 | - | "Too many authentication failures", | |
| 62 | - | "Authentication failed", | |
| 63 | - | } | |
| 64 | - | ||
| 65 | - | // sshClientTimeoutMarkers are OpenSSH-client diagnostics for bounded | |
| 66 | - | // connect/command timeouts. As with authentication text, they require | |
| 67 | - | // ssh-client framing; unframed matches stay generic transport errors. | |
| 68 | - | var sshClientTimeoutMarkers = []string{ | |
| 69 | - | "Connection timed out", | |
| 70 | - | "timed out", | |
| 71 | - | "Timeout, server not responding", | |
| 72 | - | } | |
| 73 | - | ||
| 74 | - | // ClassifyCatalogError maps an observation failure to its sanitized failure | |
| 75 | - | // kind without leaking raw stderr, keys, endpoints or terminal contents. | |
| 76 | - | // Unrecognized SSH errors stay generic transport errors: authentication is | |
| 77 | - | // never inferred from arbitrary remote stderr, only from explicit ssh-client | |
| 78 | - | // diagnostics on ssh client exit 255. | |
| 79 | - | func ClassifyCatalogError(err error) domain.RemoteFailureKind { | |
| 80 | - | if err == nil { | |
| 81 | - | return domain.RemoteFailureNone | |
| 82 | - | } | |
| 83 | - | if errors.Is(err, context.DeadlineExceeded) { | |
| 84 | - | return domain.RemoteFailureTimeout | |
| 85 | - | } | |
| 86 | - | var mismatch *catalogue.RemoteCatalogVersionMismatchError | |
| 87 | - | if errors.As(err, &mismatch) { | |
| 88 | - | return domain.RemoteFailureIncompatible | |
| 89 | - | } | |
| 90 | - | if errors.Is(err, errCatalogTooLarge) || | |
| 91 | - | errors.Is(err, errCatalogDecode) || | |
| 92 | - | errors.Is(err, errCatalogTrailing) || | |
| 93 | - | errors.Is(err, errCatalogRequired) || | |
| 94 | - | errors.Is(err, catalogue.ErrInvalidRemoteCatalog) { | |
| 95 | - | return domain.RemoteFailureInvalidResponse | |
| 96 | - | } | |
| 97 | - | if errors.Is(err, errCatalogSSH) && sshClientExit(err) { | |
| 98 | - | text := err.Error() | |
| 99 | - | for _, marker := range sshClientTrustMarkers { | |
| 100 | - | if strings.Contains(text, marker) { | |
| 101 | - | return domain.RemoteFailureTrust | |
| 102 | - | } | |
| 103 | - | } | |
| 104 | - | for _, marker := range sshClientAuthMarkers { | |
| 105 | - | if hasClientFramedMarker(text, marker) { | |
| 106 | - | return domain.RemoteFailureAuthentication | |
| 107 | - | } | |
| 108 | - | } | |
| 109 | - | for _, marker := range sshClientTimeoutMarkers { | |
| 110 | - | if hasClientFramedMarker(text, marker) { | |
| 111 | - | return domain.RemoteFailureTimeout | |
| 112 | - | } | |
| 113 | - | } | |
| 114 | - | } | |
| 115 | - | if timeoutError(err) { | |
| 116 | - | return domain.RemoteFailureTimeout | |
| 117 | - | } | |
| 118 | - | return domain.RemoteFailureTransport | |
| 119 | - | } | |
| 120 | - | ||
| 121 | - | // hasClientFramedMarker reports whether marker appears with ssh-client | |
| 122 | - | // framing: on a line starting with `ssh:`, or after a `host:` label prefix | |
| 123 | - | // on the same line (the `destination: message` form ssh uses for its own | |
| 124 | - | // errors). Only the stderr tail past the client's exit status is examined | |
| 125 | - | // so the wrapper's own colons never count as framing. A remote command | |
| 126 | - | // exiting 255 can emit the same phrases without framing, so unframed | |
| 127 | - | // matches prove nothing and stay transport errors. | |
| 128 | - | func hasClientFramedMarker(text, marker string) bool { | |
| 129 | - | diag := text | |
| 130 | - | if _, after, ok := strings.Cut(text, "exit status 255: "); ok { | |
| 131 | - | diag = after | |
| 132 | - | } | |
| 133 | - | for _, line := range strings.Split(diag, "\n") { | |
| 134 | - | if !strings.Contains(line, marker) { | |
| 135 | - | continue | |
| 136 | - | } | |
| 137 | - | if strings.HasPrefix(line, "ssh:") { | |
| 138 | - | return true | |
| 139 | - | } | |
| 140 | - | if label, _, ok := strings.Cut(line, ": "); ok && !strings.ContainsAny(label, " \t:") { | |
| 141 | - | return true | |
| 142 | - | } | |
| 143 | - | } | |
| 144 | - | return false | |
| 145 | - | } | |
| 146 | - | ||
| 147 | - | // sshClientExit reports whether the ssh client itself failed (exit 255), | |
| 148 | - | // separating client-side transport/auth/trust failures from remote command | |
| 149 | - | // output carried in the same wrapped error. | |
| 150 | - | func sshClientExit(err error) bool { | |
| 151 | - | var exit *exec.ExitError | |
| 152 | - | if !errors.As(err, &exit) { | |
| 153 | - | return false | |
| 154 | - | } | |
| 155 | - | return exit.ExitCode() == 255 | |
| 156 | - | } | |
| 157 | - | ||
| 158 | - | func timeoutError(err error) bool { | |
| 159 | - | var timeout interface{ Timeout() bool } | |
| 160 | - | if errors.As(err, &timeout) && timeout.Timeout() { | |
| 161 | - | return true | |
| 162 | - | } | |
| 163 | - | return errors.Is(err, os.ErrDeadlineExceeded) | |
| 164 | - | } | |
| 165 | - | ||
| 166 | - | // CatalogClient fetches remote session catalogs over SSH. | |
| 167 | - | type CatalogClient struct { | |
| 168 | - | command func(ctx context.Context, name string, args ...string) *exec.Cmd | |
| 169 | - | // timeout bounds each List when > 0; otherwise catalogCommandTimeout is used. | |
| 170 | - | timeout time.Duration | |
| 171 | - | } | |
| 172 | - | ||
| 173 | - | var _ appports.RemoteCatalogClient = (*CatalogClient)(nil) | |
| 174 | - | ||
| 175 | - | // NewCatalogClient returns a RemoteCatalogClient that shells out to ssh. | |
| 176 | - | func NewCatalogClient() appports.RemoteCatalogClient { | |
| 177 | - | return &CatalogClient{command: exec.CommandContext} | |
| 178 | - | } | |
| 179 | - | ||
| 180 | - | func (c *CatalogClient) listTimeout() time.Duration { | |
| 181 | - | if c != nil && c.timeout > 0 { | |
| 182 | - | return c.timeout | |
| 183 | - | } | |
| 184 | - | return catalogCommandTimeout | |
| 185 | - | } | |
| 186 | - | ||
| 187 | - | // List runs a non-interactive `ssh` observation of `vev cmd remote-catalog | |
| 188 | - | // --json` and decodes exactly one versioned catalog envelope from stdout. | |
| 189 | - | // The observation allocates no TTY, invokes no askpass helper, and executes | |
| 190 | - | // no host strings as shell fragments. Host authentication and trust remain | |
| 191 | - | // owned by effective OpenSSH policy. List always applies a bounded command | |
| 192 | - | // timeout derived from ctx so a caller with no deadline cannot hang | |
| 193 | - | // indefinitely, while still honoring caller cancellation; owned SSH | |
| 194 | - | // processes are killed and reaped on cancellation with a bounded wait. | |
| 195 | - | func (c *CatalogClient) List(ctx context.Context, target string) (catalogue.RemoteCatalog, error) { | |
| 196 | - | if err := ctx.Err(); err != nil { | |
| 197 | - | return catalogue.RemoteCatalog{}, err | |
| 198 | - | } | |
| 199 | - | if err := domain.ValidateRemoteHostTarget(target); err != nil { | |
| 200 | - | slog.Debug("remote catalog rejected target", "target", target, "err", err) | |
| 201 | - | return catalogue.RemoteCatalog{}, err | |
| 202 | - | } | |
| 203 | - | ||
| 204 | - | runCtx, cancel := context.WithTimeout(ctx, c.listTimeout()) | |
| 205 | - | defer cancel() | |
| 206 | - | ||
| 207 | - | command := c.command | |
| 208 | - | if command == nil { | |
| 209 | - | command = exec.CommandContext | |
| 210 | - | } | |
| 211 | - | spec := sshstdio.BuildCommandForObservation(target, catalogSSHConnectTimeout, "vev", "cmd", "remote-catalog", "--json") | |
| 212 | - | cmd := command(runCtx, spec.Path, spec.Args...) | |
| 213 | - | // Stdin stays detached and askpass is explicitly disabled. This preserves | |
| 214 | - | // host-specific authentication policy without allowing GUI prompt helpers. | |
| 215 | - | cmd.Env = append(cmd.Environ(), "SSH_ASKPASS_REQUIRE=never") | |
| 216 | - | stdout := boundedBuffer{limit: maxCatalogBytes} | |
| 217 | - | stderr := boundedBuffer{limit: maxCatalogDiagnosticBytes} | |
| 218 | - | cmd.Stdout = &stdout | |
| 219 | - | cmd.Stderr = &stderr | |
| 220 | - | cmd.WaitDelay = catalogCommandWaitDelay | |
| 221 | - | err := cmd.Run() | |
| 222 | - | if ctxErr := runCtx.Err(); ctxErr != nil { | |
| 223 | - | return catalogue.RemoteCatalog{}, ctxErr | |
| 224 | - | } | |
| 225 | - | if err != nil { | |
| 226 | - | if stdout.overflow || stderr.overflow { | |
| 227 | - | slog.Debug("remote catalog output too large", "target", target, "stdout_limit", maxCatalogBytes, "stderr_limit", maxCatalogDiagnosticBytes) | |
| 228 | - | return catalogue.RemoteCatalog{}, errCatalogTooLarge | |
| 229 | - | } | |
| 230 | - | stderrText := sanitizeCatalogDiagnostic(string(stderr.Bytes())) | |
| 231 | - | slog.Debug("remote catalog ssh failed", "target", target, "err", err, "stderr", stderrText) | |
| 232 | - | if stderrText != "" { | |
| 233 | - | return catalogue.RemoteCatalog{}, fmt.Errorf("%w: %w: %s", errCatalogSSH, err, stderrText) | |
| 234 | - | } | |
| 235 | - | return catalogue.RemoteCatalog{}, fmt.Errorf("%w: %w", errCatalogSSH, err) | |
| 236 | - | } | |
| 237 | - | if stdout.overflow || stderr.overflow { | |
| 238 | - | slog.Debug("remote catalog output too large", "target", target, "stdout_limit", maxCatalogBytes, "stderr_limit", maxCatalogDiagnosticBytes) | |
| 239 | - | return catalogue.RemoteCatalog{}, errCatalogTooLarge | |
| 240 | - | } | |
| 241 | - | ||
| 242 | - | catalog, err := decodeRemoteCatalog(stdout.Bytes()) | |
| 243 | - | if err != nil { | |
| 244 | - | slog.Debug("remote catalog decode failed", "target", target, "err", err) | |
| 245 | - | return catalogue.RemoteCatalog{}, err | |
| 246 | - | } | |
| 247 | - | return catalog, nil | |
| 248 | - | } | |
| 249 | - | ||
| 250 | - | type catalogVersionEnvelope struct { | |
| 251 | - | ProtocolVersion *uint16 `json:"protocol_version"` | |
| 252 | - | SchemaVersion *uint16 `json:"schema_version"` | |
| 253 | - | Sessions json.RawMessage `json:"sessions"` | |
| 254 | - | } | |
| 255 | - | ||
| 256 | - | type catalogEnvelope struct { | |
| 257 | - | ProtocolVersion *uint16 `json:"protocol_version"` | |
| 258 | - | SchemaVersion *uint16 `json:"schema_version"` | |
| 259 | - | Sessions *[]catalogSessionEnvelope `json:"sessions"` | |
| 260 | - | } | |
| 261 | - | ||
| 262 | - | type catalogSessionEnvelope struct { | |
| 263 | - | LifecycleID *domain.SessionLifecycleID `json:"lifecycle_id"` | |
| 264 | - | Name *string `json:"name"` | |
| 265 | - | State *catalogue.RemoteCatalogSessionState `json:"state"` | |
| 266 | - | Ephemeral *bool `json:"ephemeral"` | |
| 267 | - | Tabs *[]catalogTabEnvelope `json:"tabs"` | |
| 268 | - | Attached *bool `json:"attached"` | |
| 269 | - | LastUsedSeq uint64 `json:"last_used_seq,omitempty"` | |
| 270 | - | ActiveTabID string `json:"active_tab_id,omitempty"` | |
| 271 | - | Reason string `json:"reason,omitempty"` | |
| 272 | - | } | |
| 273 | - | ||
| 274 | - | type catalogTabEnvelope struct { | |
| 275 | - | ID *string `json:"id"` | |
| 276 | - | Index *uint16 `json:"index"` | |
| 277 | - | Name *string `json:"name"` | |
| 278 | - | Detail string `json:"detail,omitempty"` | |
| 279 | - | Attention bool `json:"attention,omitempty"` | |
| 280 | - | } | |
| 281 | - | ||
| 282 | - | func decodeRemoteCatalog(raw []byte) (catalogue.RemoteCatalog, error) { | |
| 283 | - | var version catalogVersionEnvelope | |
| 284 | - | if err := decodeCatalogEnvelope(raw, false, &version); err != nil { | |
| 285 | - | return catalogue.RemoteCatalog{}, err | |
| 286 | - | } | |
| 287 | - | if version.ProtocolVersion == nil { | |
| 288 | - | return catalogue.RemoteCatalog{}, errCatalogRequired | |
| 289 | - | } | |
| 290 | - | if version.SchemaVersion == nil { | |
| 291 | - | return catalogue.RemoteCatalog{}, &catalogue.RemoteCatalogVersionMismatchError{ | |
| 292 | - | Got: 0, | |
| 293 | - | Want: catalogue.RemoteCatalogSchemaVersion, | |
| 294 | - | Kind: "catalog", | |
| 295 | - | } | |
| 296 | - | } | |
| 297 | - | if *version.ProtocolVersion != protocol.Version { | |
| 298 | - | return catalogue.RemoteCatalog{}, &catalogue.RemoteCatalogVersionMismatchError{ | |
| 299 | - | Got: *version.ProtocolVersion, | |
| 300 | - | Want: protocol.Version, | |
| 301 | - | Kind: "protocol", | |
| 302 | - | } | |
| 303 | - | } | |
| 304 | - | if *version.SchemaVersion != catalogue.RemoteCatalogSchemaVersion { | |
| 305 | - | return catalogue.RemoteCatalog{}, &catalogue.RemoteCatalogVersionMismatchError{ | |
| 306 | - | Got: *version.SchemaVersion, | |
| 307 | - | Want: catalogue.RemoteCatalogSchemaVersion, | |
| 308 | - | Kind: "catalog", | |
| 309 | - | } | |
| 310 | - | } | |
| 311 | - | ||
| 312 | - | var envelope catalogEnvelope | |
| 313 | - | if err := decodeCatalogEnvelope(raw, true, &envelope); err != nil { | |
| 314 | - | return catalogue.RemoteCatalog{}, err | |
| 315 | - | } | |
| 316 | - | if envelope.ProtocolVersion == nil || envelope.SchemaVersion == nil || envelope.Sessions == nil { | |
| 317 | - | return catalogue.RemoteCatalog{}, errCatalogRequired | |
| 318 | - | } | |
| 319 | - | sessions := make([]catalogue.RemoteCatalogSession, 0, len(*envelope.Sessions)) | |
| 320 | - | for _, session := range *envelope.Sessions { | |
| 321 | - | if session.LifecycleID == nil || session.Name == nil || session.State == nil || session.Ephemeral == nil || session.Tabs == nil || session.Attached == nil { | |
| 322 | - | return catalogue.RemoteCatalog{}, errCatalogRequired | |
| 323 | - | } | |
| 324 | - | tabs := make([]catalogue.RemoteCatalogTab, 0, len(*session.Tabs)) | |
| 325 | - | for _, tab := range *session.Tabs { | |
| 326 | - | if tab.ID == nil || tab.Index == nil || tab.Name == nil { | |
| 327 | - | return catalogue.RemoteCatalog{}, errCatalogRequired | |
| 328 | - | } | |
| 329 | - | tabs = append(tabs, catalogue.RemoteCatalogTab{ | |
| 330 | - | ID: *tab.ID, Index: *tab.Index, Name: *tab.Name, | |
| 331 | - | Detail: tab.Detail, Attention: tab.Attention, | |
| 332 | - | }) | |
| 333 | - | } | |
| 334 | - | sessions = append(sessions, catalogue.RemoteCatalogSession{ | |
| 335 | - | LifecycleID: *session.LifecycleID, Name: *session.Name, State: *session.State, | |
| 336 | - | Ephemeral: *session.Ephemeral, Tabs: tabs, Attached: *session.Attached, | |
| 337 | - | LastUsedSeq: session.LastUsedSeq, ActiveTabID: session.ActiveTabID, Reason: session.Reason, | |
| 338 | - | }) | |
| 339 | - | } | |
| 340 | - | catalog := catalogue.RemoteCatalog{ | |
| 341 | - | ProtocolVersion: *envelope.ProtocolVersion, | |
| 342 | - | SchemaVersion: *envelope.SchemaVersion, | |
| 343 | - | Sessions: sessions, | |
| 344 | - | } | |
| 345 | - | if err := catalogue.ValidateRemoteCatalog(catalog); err != nil { | |
| 346 | - | return catalogue.RemoteCatalog{}, err | |
| 347 | - | } | |
| 348 | - | return catalog, nil | |
| 349 | - | } | |
| 350 | - | ||
| 351 | - | func decodeCatalogEnvelope(raw []byte, strict bool, dst any) error { | |
| 352 | - | dec := json.NewDecoder(bytes.NewReader(raw)) | |
| 353 | - | if strict { | |
| 354 | - | dec.DisallowUnknownFields() | |
| 355 | - | } | |
| 356 | - | if err := dec.Decode(dst); err != nil { | |
| 357 | - | return fmt.Errorf("%w: %w", errCatalogDecode, err) | |
| 358 | - | } | |
| 359 | - | return rejectTrailingJSON(dec) | |
| 360 | - | } | |
| 361 | - | ||
| 362 | - | func rejectTrailingJSON(dec *json.Decoder) error { | |
| 363 | - | var extra json.RawMessage | |
| 364 | - | err := dec.Decode(&extra) | |
| 365 | - | if errors.Is(err, io.EOF) { | |
| 366 | - | return nil | |
| 367 | - | } | |
| 368 | - | if err == nil { | |
| 369 | - | return errCatalogTrailing | |
| 370 | - | } | |
| 371 | - | return fmt.Errorf("%w: %w", errCatalogTrailing, err) | |
| 372 | - | } | |
| 373 | - | ||
| 374 | - | // sanitizeCatalogDiagnostic strips terminal escapes and control bytes while | |
| 375 | - | // preserving useful printable diagnostic text for errors and logs. | |
| 376 | - | func sanitizeCatalogDiagnostic(raw string) string { | |
| 377 | - | var b strings.Builder | |
| 378 | - | b.Grow(len(raw)) | |
| 379 | - | for i := 0; i < len(raw); { | |
| 380 | - | if raw[i] == 0x1b { | |
| 381 | - | i = skipTerminalEscape(raw, i+1) | |
| 382 | - | continue | |
| 383 | - | } | |
| 384 | - | r, size := utf8.DecodeRuneInString(raw[i:]) | |
| 385 | - | if r == utf8.RuneError && size == 1 { | |
| 386 | - | i++ | |
| 387 | - | continue | |
| 388 | - | } | |
| 389 | - | if unicode.IsPrint(r) || r == '\t' || r == '\n' { | |
| 390 | - | b.WriteRune(r) | |
| 391 | - | } | |
| 392 | - | i += size | |
| 393 | - | } | |
| 394 | - | return strings.TrimSpace(b.String()) | |
| 395 | - | } | |
| 396 | - | ||
| 397 | - | func skipTerminalEscape(s string, i int) int { | |
| 398 | - | if i >= len(s) { | |
| 399 | - | return i | |
| 400 | - | } | |
| 401 | - | switch s[i] { | |
| 402 | - | case '[': // CSI: ESC [ ... final byte in 0x40-0x7E | |
| 403 | - | i++ | |
| 404 | - | for i < len(s) { | |
| 405 | - | ch := s[i] | |
| 406 | - | i++ | |
| 407 | - | if ch >= 0x40 && ch <= 0x7e { | |
| 408 | - | break | |
| 409 | - | } | |
| 410 | - | } | |
| 411 | - | return i | |
| 412 | - | case ']': // OSC: ESC ] ... BEL or ST (ESC \) | |
| 413 | - | i++ | |
| 414 | - | for i < len(s) { | |
| 415 | - | if s[i] == 0x07 { | |
| 416 | - | return i + 1 | |
| 417 | - | } | |
| 418 | - | if s[i] == 0x1b && i+1 < len(s) && s[i+1] == '\\' { | |
| 419 | - | return i + 2 | |
| 420 | - | } | |
| 421 | - | i++ | |
| 422 | - | } | |
| 423 | - | return i | |
| 424 | - | default: | |
| 425 | - | // Two-byte Fe escape or unknown: drop ESC and the next byte. | |
| 426 | - | return i + 1 | |
| 427 | - | } | |
| 428 | - | } | |
| 429 | - | ||
| 430 | - | // boundedBuffer captures stream output up to an explicit byte limit and records overflow. | |
| 431 | - | type boundedBuffer struct { | |
| 432 | - | buf bytes.Buffer | |
| 433 | - | limit int | |
| 434 | - | overflow bool | |
| 435 | - | } | |
| 436 | - | ||
| 437 | - | func (b *boundedBuffer) Write(p []byte) (int, error) { | |
| 438 | - | if b.overflow { | |
| 439 | - | return len(p), nil | |
| 440 | - | } | |
| 441 | - | remaining := b.limit - b.buf.Len() | |
| 442 | - | if remaining <= 0 { | |
| 443 | - | b.overflow = true | |
| 444 | - | return len(p), nil | |
| 445 | - | } | |
| 446 | - | if len(p) <= remaining { | |
| 447 | - | return b.buf.Write(p) | |
| 448 | - | } | |
| 449 | - | n, err := b.buf.Write(p[:remaining]) | |
| 450 | - | b.overflow = true | |
| 451 | - | if err != nil { | |
| 452 | - | return n, err | |
| 453 | - | } | |
| 454 | - | return len(p), nil | |
| 455 | - | } | |
| 456 | - | ||
| 457 | - | func (b *boundedBuffer) Bytes() []byte { | |
| 458 | - | return b.buf.Bytes() | |
| 459 | - | } | |
D internal/adapters/remote/catalog_cache.go +0 -352
| @@ -1,352 +0,0 @@ | |||
| 1 | - | package remote | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "bytes" | |
| 5 | - | "encoding/hex" | |
| 6 | - | "encoding/json" | |
| 7 | - | "errors" | |
| 8 | - | "fmt" | |
| 9 | - | "os" | |
| 10 | - | "path/filepath" | |
| 11 | - | "sort" | |
| 12 | - | "time" | |
| 13 | - | "unicode/utf8" | |
| 14 | - | ||
| 15 | - | "github.com/bnema/vev/internal/domain" | |
| 16 | - | appports "github.com/bnema/vev/internal/ports" | |
| 17 | - | "github.com/bnema/vev/internal/protocol/catalogue" | |
| 18 | - | ) | |
| 19 | - | ||
| 20 | - | const ( | |
| 21 | - | catalogCacheFileName = "remote-catalog-cache.json" | |
| 22 | - | catalogCacheExactFileVersion = 2 | |
| 23 | - | catalogCacheLegacyFileVersion = 3 | |
| 24 | - | catalogCacheFileVersion = 4 | |
| 25 | - | ) | |
| 26 | - | ||
| 27 | - | // CatalogCachePath returns the canonical location of the remote catalog cache in stateDir. | |
| 28 | - | func CatalogCachePath(stateDir string) string { return filepath.Join(stateDir, catalogCacheFileName) } | |
| 29 | - | ||
| 30 | - | type catalogCacheFile struct { | |
| 31 | - | Version *int `json:"version"` | |
| 32 | - | Hosts *[]catalogCacheHost `json:"hosts"` | |
| 33 | - | } | |
| 34 | - | ||
| 35 | - | type catalogCacheVersion struct { | |
| 36 | - | Version *int `json:"version"` | |
| 37 | - | } | |
| 38 | - | ||
| 39 | - | type catalogCacheHost struct { | |
| 40 | - | Target *string `json:"target"` | |
| 41 | - | FetchedAtUnixNano *int64 `json:"fetched_at_unix_nano"` | |
| 42 | - | Incarnation *string `json:"incarnation,omitempty"` | |
| 43 | - | Sessions *[]catalogCacheSession `json:"sessions"` | |
| 44 | - | } | |
| 45 | - | ||
| 46 | - | type catalogCacheSession struct { | |
| 47 | - | LifecycleID *domain.SessionLifecycleID `json:"lifecycle_id"` | |
| 48 | - | Name *string `json:"name"` | |
| 49 | - | State *catalogue.RemoteCatalogSessionState `json:"state"` | |
| 50 | - | Ephemeral *bool `json:"ephemeral"` | |
| 51 | - | LastUsedSeq *uint64 `json:"last_used_seq,omitempty"` | |
| 52 | - | Tabs *[]catalogue.RemoteCatalogTab `json:"tabs"` | |
| 53 | - | ActiveTabID *string `json:"active_tab_id,omitempty"` | |
| 54 | - | Attached *bool `json:"attached"` | |
| 55 | - | } | |
| 56 | - | ||
| 57 | - | type catalogCacheFileV2 struct { | |
| 58 | - | Version *int `json:"version"` | |
| 59 | - | Hosts *[]catalogCacheHostV2 `json:"hosts"` | |
| 60 | - | } | |
| 61 | - | ||
| 62 | - | type catalogCacheHostV2 struct { | |
| 63 | - | Target *string `json:"target"` | |
| 64 | - | FetchedAtUnixNano *int64 `json:"fetched_at_unix_nano"` | |
| 65 | - | Sessions *[]catalogCacheSessionV2 `json:"sessions"` | |
| 66 | - | } | |
| 67 | - | ||
| 68 | - | type catalogCacheSessionV2 struct { | |
| 69 | - | LifecycleID *domain.SessionLifecycleID `json:"lifecycle_id,omitempty"` | |
| 70 | - | Name *string `json:"name"` | |
| 71 | - | State *catalogue.RemoteCatalogSessionState `json:"state"` | |
| 72 | - | Ephemeral *bool `json:"ephemeral"` | |
| 73 | - | LastUsedSeq *uint64 `json:"last_used_seq,omitempty"` | |
| 74 | - | Tabs *uint16 `json:"tabs,omitempty"` | |
| 75 | - | TabList *[]catalogue.RemoteCatalogTab `json:"tab_list,omitempty"` | |
| 76 | - | ActiveTabID *string `json:"active_tab_id,omitempty"` | |
| 77 | - | Attached *bool `json:"attached"` | |
| 78 | - | } | |
| 79 | - | ||
| 80 | - | type fileCatalogCache struct { | |
| 81 | - | path string | |
| 82 | - | } | |
| 83 | - | ||
| 84 | - | // NewFileCatalogCache returns a durable remote catalog cache backed by path. | |
| 85 | - | func NewFileCatalogCache(path string) appports.RemoteCatalogCache { | |
| 86 | - | return &fileCatalogCache{path: path} | |
| 87 | - | } | |
| 88 | - | ||
| 89 | - | var _ appports.RemoteCatalogCache = (*fileCatalogCache)(nil) | |
| 90 | - | ||
| 91 | - | // Load reads and validates one complete cache snapshot. Exact v2 tab-list | |
| 92 | - | // snapshots are upgraded in memory; ambiguous count-only v2 data is rejected. | |
| 93 | - | // A missing cache is an empty snapshot, and Load never changes cache data. | |
| 94 | - | func (c *fileCatalogCache) Load() ([]catalogue.RemoteCatalogCacheEntry, error) { | |
| 95 | - | raw, err := os.ReadFile(c.path) | |
| 96 | - | if errors.Is(err, os.ErrNotExist) { | |
| 97 | - | return []catalogue.RemoteCatalogCacheEntry{}, nil | |
| 98 | - | } | |
| 99 | - | if err != nil { | |
| 100 | - | return nil, err | |
| 101 | - | } | |
| 102 | - | if !utf8.Valid(raw) { | |
| 103 | - | return nil, fmt.Errorf("remote catalog cache: malformed cache file: invalid UTF-8") | |
| 104 | - | } | |
| 105 | - | ||
| 106 | - | var version catalogCacheVersion | |
| 107 | - | if err := decodeCatalogCacheFile(raw, false, &version); err != nil { | |
| 108 | - | return nil, err | |
| 109 | - | } | |
| 110 | - | if version.Version == nil { | |
| 111 | - | return nil, fmt.Errorf("remote catalog cache: malformed cache file: missing version") | |
| 112 | - | } | |
| 113 | - | ||
| 114 | - | var file catalogCacheFile | |
| 115 | - | requireIncarnation := false | |
| 116 | - | switch *version.Version { | |
| 117 | - | case catalogCacheFileVersion: | |
| 118 | - | requireIncarnation = true | |
| 119 | - | if err := decodeCatalogCacheFile(raw, true, &file); err != nil { | |
| 120 | - | return nil, err | |
| 121 | - | } | |
| 122 | - | case catalogCacheLegacyFileVersion: | |
| 123 | - | if err := decodeCatalogCacheFile(raw, true, &file); err != nil { | |
| 124 | - | return nil, err | |
| 125 | - | } | |
| 126 | - | case catalogCacheExactFileVersion: | |
| 127 | - | var legacy catalogCacheFileV2 | |
| 128 | - | if err := decodeCatalogCacheFile(raw, true, &legacy); err != nil { | |
| 129 | - | return nil, err | |
| 130 | - | } | |
| 131 | - | migrated, err := migrateExactCatalogCacheV2(legacy) | |
| 132 | - | if err != nil { | |
| 133 | - | return nil, err | |
| 134 | - | } | |
| 135 | - | file = migrated | |
| 136 | - | default: | |
| 137 | - | return nil, fmt.Errorf("remote catalog cache: unsupported cache file version %d", *version.Version) | |
| 138 | - | } | |
| 139 | - | if file.Hosts == nil { | |
| 140 | - | return nil, fmt.Errorf("remote catalog cache: malformed cache file: missing hosts") | |
| 141 | - | } | |
| 142 | - | ||
| 143 | - | entries := make([]catalogue.RemoteCatalogCacheEntry, 0, len(*file.Hosts)) | |
| 144 | - | for _, host := range *file.Hosts { | |
| 145 | - | if host.Target == nil || host.FetchedAtUnixNano == nil || host.Sessions == nil { | |
| 146 | - | return nil, fmt.Errorf("remote catalog cache: malformed cache file: missing host fields") | |
| 147 | - | } | |
| 148 | - | if !utf8.ValidString(*host.Target) { | |
| 149 | - | return nil, fmt.Errorf("remote catalog cache: malformed cache file: invalid UTF-8") | |
| 150 | - | } | |
| 151 | - | if *host.FetchedAtUnixNano <= 0 { | |
| 152 | - | return nil, fmt.Errorf("remote catalog cache: malformed cache file: non-positive fetched time") | |
| 153 | - | } | |
| 154 | - | entry := catalogue.RemoteCatalogCacheEntry{ | |
| 155 | - | Host: *host.Target, | |
| 156 | - | FetchedAt: time.Unix(0, *host.FetchedAtUnixNano), | |
| 157 | - | Sessions: make([]catalogue.RemoteCatalogSession, 0, len(*host.Sessions)), | |
| 158 | - | } | |
| 159 | - | if host.Incarnation != nil { | |
| 160 | - | raw, err := hex.DecodeString(*host.Incarnation) | |
| 161 | - | if err != nil || len(raw) != 16 { | |
| 162 | - | return nil, fmt.Errorf("remote catalog cache: malformed cache file: invalid incarnation for %q", *host.Target) | |
| 163 | - | } | |
| 164 | - | copy(entry.Incarnation[:], raw) | |
| 165 | - | } else if requireIncarnation { | |
| 166 | - | return nil, fmt.Errorf("remote catalog cache: malformed cache file: missing incarnation for %q", *host.Target) | |
| 167 | - | } | |
| 168 | - | // A zero incarnation is unbound advisory data: it loads but must | |
| 169 | - | // never seed a registration. | |
| 170 | - | for _, session := range *host.Sessions { | |
| 171 | - | if session.LifecycleID == nil || session.Name == nil || session.State == nil || session.Ephemeral == nil || session.Attached == nil || session.Tabs == nil { | |
| 172 | - | return nil, fmt.Errorf("remote catalog cache: malformed cache file: missing session fields") | |
| 173 | - | } | |
| 174 | - | if !utf8.ValidString(*session.Name) || !utf8.ValidString(string(*session.State)) { | |
| 175 | - | return nil, fmt.Errorf("remote catalog cache: malformed cache file: invalid UTF-8") | |
| 176 | - | } | |
| 177 | - | tabs := make([]catalogue.RemoteCatalogTab, len(*session.Tabs)) | |
| 178 | - | copy(tabs, *session.Tabs) | |
| 179 | - | decoded := catalogue.RemoteCatalogSession{ | |
| 180 | - | LifecycleID: *session.LifecycleID, | |
| 181 | - | Name: *session.Name, | |
| 182 | - | State: *session.State, | |
| 183 | - | Ephemeral: *session.Ephemeral, | |
| 184 | - | Tabs: tabs, | |
| 185 | - | Attached: *session.Attached, | |
| 186 | - | } | |
| 187 | - | if session.LastUsedSeq != nil { | |
| 188 | - | decoded.LastUsedSeq = *session.LastUsedSeq | |
| 189 | - | } | |
| 190 | - | if session.ActiveTabID != nil { | |
| 191 | - | decoded.ActiveTabID = *session.ActiveTabID | |
| 192 | - | } | |
| 193 | - | entry.Sessions = append(entry.Sessions, decoded) | |
| 194 | - | } | |
| 195 | - | entries = append(entries, entry) | |
| 196 | - | } | |
| 197 | - | normalized, err := normalizeCatalogCacheEntries(entries) | |
| 198 | - | if err != nil { | |
| 199 | - | return nil, err | |
| 200 | - | } | |
| 201 | - | return normalized, nil | |
| 202 | - | } | |
| 203 | - | ||
| 204 | - | func decodeCatalogCacheFile(raw []byte, strict bool, dst any) error { | |
| 205 | - | dec := json.NewDecoder(bytes.NewReader(raw)) | |
| 206 | - | if strict { | |
| 207 | - | dec.DisallowUnknownFields() | |
| 208 | - | } | |
| 209 | - | if err := dec.Decode(dst); err != nil { | |
| 210 | - | return fmt.Errorf("remote catalog cache: malformed cache file: %w", err) | |
| 211 | - | } | |
| 212 | - | if err := rejectTrailingJSON(dec); err != nil { | |
| 213 | - | return fmt.Errorf("remote catalog cache: malformed cache file: %w", err) | |
| 214 | - | } | |
| 215 | - | return nil | |
| 216 | - | } | |
| 217 | - | ||
| 218 | - | func migrateExactCatalogCacheV2(legacy catalogCacheFileV2) (catalogCacheFile, error) { | |
| 219 | - | if legacy.Version == nil || *legacy.Version != catalogCacheExactFileVersion || legacy.Hosts == nil { | |
| 220 | - | return catalogCacheFile{}, fmt.Errorf("remote catalog cache: malformed version 2 cache file") | |
| 221 | - | } | |
| 222 | - | hosts := make([]catalogCacheHost, 0, len(*legacy.Hosts)) | |
| 223 | - | for _, host := range *legacy.Hosts { | |
| 224 | - | if host.Sessions == nil { | |
| 225 | - | return catalogCacheFile{}, fmt.Errorf("remote catalog cache: malformed cache file: missing host fields") | |
| 226 | - | } | |
| 227 | - | sessions := make([]catalogCacheSession, 0, len(*host.Sessions)) | |
| 228 | - | for _, session := range *host.Sessions { | |
| 229 | - | if session.Tabs != nil || session.TabList == nil { | |
| 230 | - | return catalogCacheFile{}, fmt.Errorf("remote catalog cache: unsupported ambiguous version 2 tab count") | |
| 231 | - | } | |
| 232 | - | sessions = append(sessions, catalogCacheSession{ | |
| 233 | - | LifecycleID: session.LifecycleID, | |
| 234 | - | Name: session.Name, | |
| 235 | - | State: session.State, | |
| 236 | - | Ephemeral: session.Ephemeral, | |
| 237 | - | LastUsedSeq: session.LastUsedSeq, | |
| 238 | - | Tabs: session.TabList, | |
| 239 | - | ActiveTabID: session.ActiveTabID, | |
| 240 | - | Attached: session.Attached, | |
| 241 | - | }) | |
| 242 | - | } | |
| 243 | - | hosts = append(hosts, catalogCacheHost{ | |
| 244 | - | Target: host.Target, FetchedAtUnixNano: host.FetchedAtUnixNano, Sessions: &sessions, | |
| 245 | - | }) | |
| 246 | - | } | |
| 247 | - | version := catalogCacheFileVersion | |
| 248 | - | return catalogCacheFile{Version: &version, Hosts: &hosts}, nil | |
| 249 | - | } | |
| 250 | - | ||
| 251 | - | // Store atomically replaces the cache with a complete normalized snapshot. | |
| 252 | - | func (c *fileCatalogCache) Store(entries []catalogue.RemoteCatalogCacheEntry) error { | |
| 253 | - | normalized, err := normalizeCatalogCacheEntries(entries) | |
| 254 | - | if err != nil { | |
| 255 | - | return err | |
| 256 | - | } | |
| 257 | - | hosts := make([]catalogCacheHost, 0, len(normalized)) | |
| 258 | - | for _, entry := range normalized { | |
| 259 | - | target := entry.Host | |
| 260 | - | fetchedAt := entry.FetchedAt.UnixNano() | |
| 261 | - | incarnation := hex.EncodeToString(entry.Incarnation[:]) | |
| 262 | - | sessions := make([]catalogCacheSession, 0, len(entry.Sessions)) | |
| 263 | - | for _, session := range entry.Sessions { | |
| 264 | - | name := session.Name | |
| 265 | - | state := session.State | |
| 266 | - | ephemeral := session.Ephemeral | |
| 267 | - | attached := session.Attached | |
| 268 | - | id := session.LifecycleID | |
| 269 | - | tabs := make([]catalogue.RemoteCatalogTab, len(session.Tabs)) | |
| 270 | - | copy(tabs, session.Tabs) | |
| 271 | - | row := catalogCacheSession{LifecycleID: &id, Name: &name, State: &state, Ephemeral: &ephemeral, Tabs: &tabs, Attached: &attached} | |
| 272 | - | if session.LastUsedSeq != 0 { | |
| 273 | - | lastUsed := session.LastUsedSeq | |
| 274 | - | row.LastUsedSeq = &lastUsed | |
| 275 | - | } | |
| 276 | - | if session.ActiveTabID != "" { | |
| 277 | - | active := session.ActiveTabID | |
| 278 | - | row.ActiveTabID = &active | |
| 279 | - | } | |
| 280 | - | sessions = append(sessions, row) | |
| 281 | - | } | |
| 282 | - | hosts = append(hosts, catalogCacheHost{ | |
| 283 | - | Target: &target, | |
| 284 | - | FetchedAtUnixNano: &fetchedAt, | |
| 285 | - | Incarnation: &incarnation, | |
| 286 | - | Sessions: &sessions, | |
| 287 | - | }) | |
| 288 | - | } | |
| 289 | - | version := catalogCacheFileVersion | |
| 290 | - | payload, err := json.Marshal(catalogCacheFile{Version: &version, Hosts: &hosts}) | |
| 291 | - | if err != nil { | |
| 292 | - | return err | |
| 293 | - | } | |
| 294 | - | return atomicReplacePrivateFile(c.path, append(payload, '\n')) | |
| 295 | - | } | |
| 296 | - | ||
| 297 | - | func normalizeCatalogCacheEntries(entries []catalogue.RemoteCatalogCacheEntry) ([]catalogue.RemoteCatalogCacheEntry, error) { | |
| 298 | - | if err := catalogue.ValidateRemoteCatalogCacheEntries(entries); err != nil { | |
| 299 | - | return nil, err | |
| 300 | - | } | |
| 301 | - | normalized := make([]catalogue.RemoteCatalogCacheEntry, 0, len(entries)) | |
| 302 | - | hosts := make(map[string]struct{}, len(entries)) | |
| 303 | - | for _, entry := range entries { | |
| 304 | - | if err := validateHostTarget(entry.Host); err != nil { | |
| 305 | - | return nil, fmt.Errorf("remote catalog cache: invalid host %q: %w", entry.Host, err) | |
| 306 | - | } | |
| 307 | - | if entry.FetchedAt.UnixNano() <= 0 { | |
| 308 | - | return nil, fmt.Errorf("remote catalog cache: host %q has non-positive fetched time", entry.Host) | |
| 309 | - | } | |
| 310 | - | if _, duplicate := hosts[entry.Host]; duplicate { | |
| 311 | - | return nil, fmt.Errorf("remote catalog cache: duplicate host %q", entry.Host) | |
| 312 | - | } | |
| 313 | - | hosts[entry.Host] = struct{}{} | |
| 314 | - | ||
| 315 | - | copyEntry := catalogue.RemoteCatalogCacheEntry{ | |
| 316 | - | Host: entry.Host, | |
| 317 | - | FetchedAt: entry.FetchedAt, | |
| 318 | - | Incarnation: entry.Incarnation, | |
| 319 | - | Sessions: make([]catalogue.RemoteCatalogSession, 0, len(entry.Sessions)), | |
| 320 | - | } | |
| 321 | - | sessions := make(map[string]struct{}, len(entry.Sessions)) | |
| 322 | - | for _, session := range entry.Sessions { | |
| 323 | - | if !utf8.ValidString(session.Name) || !utf8.ValidString(string(session.State)) { | |
| 324 | - | return nil, fmt.Errorf("remote catalog cache: session is not valid UTF-8") | |
| 325 | - | } | |
| 326 | - | if err := domain.ValidateSessionName(session.Name); err != nil { | |
| 327 | - | return nil, fmt.Errorf("remote catalog cache: invalid session %q: %w", session.Name, err) | |
| 328 | - | } | |
| 329 | - | if !session.State.Valid() { | |
| 330 | - | return nil, fmt.Errorf("remote catalog cache: invalid session state %q", session.State) | |
| 331 | - | } | |
| 332 | - | if _, duplicate := sessions[session.Name]; duplicate { | |
| 333 | - | return nil, fmt.Errorf("remote catalog cache: duplicate session %q for host %q", session.Name, entry.Host) | |
| 334 | - | } | |
| 335 | - | sessions[session.Name] = struct{}{} | |
| 336 | - | copySession := session | |
| 337 | - | copySession.Tabs = make([]catalogue.RemoteCatalogTab, len(session.Tabs)) | |
| 338 | - | for i, tab := range session.Tabs { | |
| 339 | - | copySession.Tabs[i] = catalogue.RemoteCatalogTab{ID: tab.ID, Index: tab.Index, Name: tab.Name} | |
| 340 | - | } | |
| 341 | - | copyEntry.Sessions = append(copyEntry.Sessions, copySession) | |
| 342 | - | } | |
| 343 | - | sort.Slice(copyEntry.Sessions, func(i, j int) bool { | |
| 344 | - | return copyEntry.Sessions[i].Name < copyEntry.Sessions[j].Name | |
| 345 | - | }) | |
| 346 | - | normalized = append(normalized, copyEntry) | |
| 347 | - | } | |
| 348 | - | sort.Slice(normalized, func(i, j int) bool { | |
| 349 | - | return normalized[i].Host < normalized[j].Host | |
| 350 | - | }) | |
| 351 | - | return normalized, nil | |
| 352 | - | } | |
D internal/adapters/remote/catalog_cache_test.go +0 -356
| @@ -1,356 +0,0 @@ | |||
| 1 | - | package remote | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "os" | |
| 5 | - | "path/filepath" | |
| 6 | - | "reflect" | |
| 7 | - | "slices" | |
| 8 | - | "strings" | |
| 9 | - | "testing" | |
| 10 | - | "time" | |
| 11 | - | ||
| 12 | - | "github.com/bnema/vev/internal/protocol/catalogue" | |
| 13 | - | "github.com/bnema/vev/pkg/safedir" | |
| 14 | - | ) | |
| 15 | - | ||
| 16 | - | func TestCatalogCachePath(t *testing.T) { | |
| 17 | - | stateDir := filepath.Join(t.TempDir(), "state") | |
| 18 | - | want := filepath.Join(stateDir, "remote-catalog-cache.json") | |
| 19 | - | if got := CatalogCachePath(stateDir); got != want { | |
| 20 | - | t.Fatalf("CatalogCachePath(%q) = %q, want %q", stateDir, got, want) | |
| 21 | - | } | |
| 22 | - | } | |
| 23 | - | ||
| 24 | - | func TestCatalogCacheStoreLoad(t *testing.T) { | |
| 25 | - | t.Parallel() | |
| 26 | - | ||
| 27 | - | path := filepath.Join(t.TempDir(), "vev", "remote-catalog-cache.json") | |
| 28 | - | cache := NewFileCatalogCache(path) | |
| 29 | - | fetchedAt := time.Unix(0, 1780000000000000000) | |
| 30 | - | entries := []catalogue.RemoteCatalogCacheEntry{ | |
| 31 | - | { | |
| 32 | - | Host: "zebra", | |
| 33 | - | FetchedAt: fetchedAt, | |
| 34 | - | Incarnation: [16]byte{9}, | |
| 35 | - | Sessions: []catalogue.RemoteCatalogSession{ | |
| 36 | - | { | |
| 37 | - | LifecycleID: [16]byte{1}, Name: "work", State: "up", LastUsedSeq: 42, ActiveTabID: "work-2", | |
| 38 | - | Tabs: []catalogue.RemoteCatalogTab{{ID: "work-1", Detail: "dynamic", Attention: true}, {ID: "work-2", Index: 1}}, | |
| 39 | - | }, | |
| 40 | - | {LifecycleID: [16]byte{2}, Name: "alpha", State: "down", Ephemeral: true, Tabs: []catalogue.RemoteCatalogTab{{ID: "alpha-1"}}, Attached: true}, | |
| 41 | - | }, | |
| 42 | - | }, | |
| 43 | - | { | |
| 44 | - | Host: "arch", | |
| 45 | - | FetchedAt: fetchedAt.Add(time.Second), | |
| 46 | - | Incarnation: [16]byte{7}, | |
| 47 | - | Sessions: []catalogue.RemoteCatalogSession{}, | |
| 48 | - | }, | |
| 49 | - | } | |
| 50 | - | ||
| 51 | - | if err := cache.Store(entries); err != nil { | |
| 52 | - | t.Fatalf("Store() error = %v", err) | |
| 53 | - | } | |
| 54 | - | raw, err := os.ReadFile(path) | |
| 55 | - | if err != nil { | |
| 56 | - | t.Fatalf("read cache: %v", err) | |
| 57 | - | } | |
| 58 | - | arch := strings.Index(string(raw), `"target":"arch"`) | |
| 59 | - | zebra := strings.Index(string(raw), `"target":"zebra"`) | |
| 60 | - | if arch < 0 || zebra < 0 || arch >= zebra { | |
| 61 | - | t.Fatalf("stored hosts are not ordered: %s", raw) | |
| 62 | - | } | |
| 63 | - | alpha := strings.Index(string(raw), `"name":"alpha"`) | |
| 64 | - | work := strings.Index(string(raw), `"name":"work"`) | |
| 65 | - | if alpha < 0 || work < 0 || alpha >= work { | |
| 66 | - | t.Fatalf("stored sessions are not ordered: %s", raw) | |
| 67 | - | } | |
| 68 | - | if !strings.Contains(string(raw), `"version":4`) || !strings.Contains(string(raw), `"tabs":[{"id":"work-1"`) { | |
| 69 | - | t.Fatalf("stored cache does not pin the v4 typed-tab contract: %s", raw) | |
| 70 | - | } | |
| 71 | - | if strings.Contains(string(raw), `"detail"`) || strings.Contains(string(raw), `"attention"`) { | |
| 72 | - | t.Fatalf("stored cache contains dynamic tab fields: %s", raw) | |
| 73 | - | } | |
| 74 | - | got, err := cache.Load() | |
| 75 | - | if err != nil { | |
| 76 | - | t.Fatalf("Load() error = %v", err) | |
| 77 | - | } | |
| 78 | - | want := []catalogue.RemoteCatalogCacheEntry{ | |
| 79 | - | { | |
| 80 | - | Host: "arch", | |
| 81 | - | FetchedAt: fetchedAt.Add(time.Second), | |
| 82 | - | Incarnation: [16]byte{7}, | |
| 83 | - | Sessions: []catalogue.RemoteCatalogSession{}, | |
| 84 | - | }, | |
| 85 | - | { | |
| 86 | - | Host: "zebra", | |
| 87 | - | FetchedAt: fetchedAt, | |
| 88 | - | Incarnation: [16]byte{9}, | |
| 89 | - | Sessions: []catalogue.RemoteCatalogSession{ | |
| 90 | - | {LifecycleID: [16]byte{2}, Name: "alpha", State: "down", Ephemeral: true, Tabs: []catalogue.RemoteCatalogTab{{ID: "alpha-1"}}, Attached: true}, | |
| 91 | - | {LifecycleID: [16]byte{1}, Name: "work", State: "up", LastUsedSeq: 42, ActiveTabID: "work-2", Tabs: []catalogue.RemoteCatalogTab{{ID: "work-1"}, {ID: "work-2", Index: 1}}}, | |
| 92 | - | }, | |
| 93 | - | }, | |
| 94 | - | } | |
| 95 | - | if !slices.EqualFunc(got, want, equalRemoteCatalogCacheEntry) { | |
| 96 | - | t.Fatalf("Load() = %#v, want %#v", got, want) | |
| 97 | - | } | |
| 98 | - | ||
| 99 | - | info, err := os.Stat(path) | |
| 100 | - | if err != nil { | |
| 101 | - | t.Fatalf("stat cache: %v", err) | |
| 102 | - | } | |
| 103 | - | if perm := info.Mode().Perm(); perm != 0o600 { | |
| 104 | - | t.Fatalf("cache permissions = %04o, want 0600", perm) | |
| 105 | - | } | |
| 106 | - | dirInfo, err := os.Lstat(filepath.Dir(path)) | |
| 107 | - | if err != nil { | |
| 108 | - | t.Fatalf("stat cache directory: %v", err) | |
| 109 | - | } | |
| 110 | - | if perm := dirInfo.Mode().Perm(); perm != 0o700 { | |
| 111 | - | t.Fatalf("cache directory permissions = %04o, want 0700", perm) | |
| 112 | - | } | |
| 113 | - | entriesOnDisk, err := os.ReadDir(filepath.Dir(path)) | |
| 114 | - | if err != nil { | |
| 115 | - | t.Fatalf("read cache directory: %v", err) | |
| 116 | - | } | |
| 117 | - | for _, entry := range entriesOnDisk { | |
| 118 | - | if strings.Contains(entry.Name(), ".tmp") { | |
| 119 | - | t.Fatalf("temporary file left behind: %q", entry.Name()) | |
| 120 | - | } | |
| 121 | - | } | |
| 122 | - | } | |
| 123 | - | ||
| 124 | - | func TestCatalogCacheLoadMigratesExactV2TabList(t *testing.T) { | |
| 125 | - | t.Parallel() | |
| 126 | - | ||
| 127 | - | path := filepath.Join(t.TempDir(), "vev", "remote-catalog-cache.json") | |
| 128 | - | if err := safedir.EnsurePrivate(filepath.Dir(path)); err != nil { | |
| 129 | - | t.Fatalf("mkdir: %v", err) | |
| 130 | - | } | |
| 131 | - | raw := []byte(`{"version":2,"hosts":[{"target":"user@arch","fetched_at_unix_nano":1780000000000000000,"sessions":[{"lifecycle_id":"02000000000000000000000000000000","name":"dumber","state":"down","ephemeral":false,"tab_list":[],"attached":false},{"lifecycle_id":"01000000000000000000000000000000","name":"work","state":"up","ephemeral":false,"last_used_seq":42,"tab_list":[{"id":"t_work","index":0,"name":"shell"}],"active_tab_id":"t_work","attached":false}]}]}`) | |
| 132 | - | if err := os.WriteFile(path, raw, 0o600); err != nil { | |
| 133 | - | t.Fatalf("write cache: %v", err) | |
| 134 | - | } | |
| 135 | - | ||
| 136 | - | got, err := NewFileCatalogCache(path).Load() | |
| 137 | - | if err != nil { | |
| 138 | - | t.Fatalf("Load() error = %v", err) | |
| 139 | - | } | |
| 140 | - | want := []catalogue.RemoteCatalogCacheEntry{{ | |
| 141 | - | Host: "user@arch", | |
| 142 | - | FetchedAt: time.Unix(0, 1780000000000000000), | |
| 143 | - | Sessions: []catalogue.RemoteCatalogSession{ | |
| 144 | - | {LifecycleID: [16]byte{2}, Name: "dumber", State: catalogue.RemoteCatalogSessionDown, Tabs: []catalogue.RemoteCatalogTab{}}, | |
| 145 | - | { | |
| 146 | - | LifecycleID: [16]byte{1}, Name: "work", State: catalogue.RemoteCatalogSessionUp, | |
| 147 | - | LastUsedSeq: 42, Tabs: []catalogue.RemoteCatalogTab{{ID: "t_work", Name: "shell"}}, ActiveTabID: "t_work", | |
| 148 | - | }, | |
| 149 | - | }, | |
| 150 | - | }} | |
| 151 | - | if !slices.EqualFunc(got, want, equalRemoteCatalogCacheEntry) { | |
| 152 | - | t.Fatalf("Load() = %#v, want %#v", got, want) | |
| 153 | - | } | |
| 154 | - | after, err := os.ReadFile(path) | |
| 155 | - | if err != nil { | |
| 156 | - | t.Fatalf("read cache after load: %v", err) | |
| 157 | - | } | |
| 158 | - | if !slices.Equal(after, raw) { | |
| 159 | - | t.Fatalf("Load changed v2 cache: got %q, want %q", after, raw) | |
| 160 | - | } | |
| 161 | - | } | |
| 162 | - | ||
| 163 | - | func TestCatalogCacheLegacyV3LoadsWithoutIncarnation(t *testing.T) { | |
| 164 | - | t.Parallel() | |
| 165 | - | ||
| 166 | - | path := filepath.Join(t.TempDir(), "vev", "remote-catalog-cache.json") | |
| 167 | - | if err := safedir.EnsurePrivate(filepath.Dir(path)); err != nil { | |
| 168 | - | t.Fatalf("mkdir: %v", err) | |
| 169 | - | } | |
| 170 | - | raw := []byte(`{"version":3,"hosts":[{"target":"user@arch","fetched_at_unix_nano":1780000000000000000,"sessions":[]}]}`) | |
| 171 | - | if err := os.WriteFile(path, raw, 0o600); err != nil { | |
| 172 | - | t.Fatalf("write cache: %v", err) | |
| 173 | - | } | |
| 174 | - | ||
| 175 | - | got, err := NewFileCatalogCache(path).Load() | |
| 176 | - | if err != nil { | |
| 177 | - | t.Fatalf("Load() error = %v", err) | |
| 178 | - | } | |
| 179 | - | if len(got) != 1 || got[0].Host != "user@arch" { | |
| 180 | - | t.Fatalf("Load() = %#v, want one user@arch entry", got) | |
| 181 | - | } | |
| 182 | - | if got[0].Incarnation != [16]byte{} { | |
| 183 | - | t.Fatalf("legacy entry incarnation = %x, want zero (unbound advisory data)", got[0].Incarnation) | |
| 184 | - | } | |
| 185 | - | } | |
| 186 | - | ||
| 187 | - | func TestCatalogCacheLoadRejectsInvalidFilesWithoutReplacingThem(t *testing.T) { | |
| 188 | - | t.Parallel() | |
| 189 | - | ||
| 190 | - | cases := []struct { | |
| 191 | - | name string | |
| 192 | - | raw []byte | |
| 193 | - | }{ | |
| 194 | - | {name: "truncated", raw: []byte(`{"version":3,"hosts":[`)}, | |
| 195 | - | {name: "trailing JSON", raw: []byte(`{"version":3,"hosts":[]} {}`)}, | |
| 196 | - | {name: "obsolete count-only version", raw: []byte(`{"version":2,"hosts":[{"target":"arch","fetched_at_unix_nano":1,"sessions":[{"lifecycle_id":"01000000000000000000000000000000","name":"work","state":"up","ephemeral":false,"tabs":1,"active_tab_id":"t_work","attached":false}]}]}`)}, | |
| 197 | - | {name: "unknown version", raw: []byte(`{"version":5,"hosts":[]}`)}, | |
| 198 | - | {name: "v4 missing incarnation", raw: []byte(`{"version":4,"hosts":[{"target":"arch","fetched_at_unix_nano":1,"sessions":[]}]}`)}, | |
| 199 | - | {name: "v4 malformed incarnation", raw: []byte(`{"version":4,"hosts":[{"target":"arch","fetched_at_unix_nano":1,"incarnation":"not-hex","sessions":[]}]}`)}, | |
| 200 | - | {name: "v4 short incarnation", raw: []byte(`{"version":4,"hosts":[{"target":"arch","fetched_at_unix_nano":1,"incarnation":"0123","sessions":[]}]}`)}, | |
| 201 | - | {name: "missing hosts", raw: []byte(`{"version":3}`)}, | |
| 202 | - | {name: "null hosts", raw: []byte(`{"version":3,"hosts":null}`)}, | |
| 203 | - | {name: "null sessions", raw: []byte(`{"version":3,"hosts":[{"target":"arch","fetched_at_unix_nano":1,"sessions":null}]}`)}, | |
| 204 | - | {name: "zero fetched at", raw: []byte(`{"version":3,"hosts":[{"target":"arch","fetched_at_unix_nano":0,"sessions":[]}]}`)}, | |
| 205 | - | {name: "negative fetched at", raw: []byte(`{"version":3,"hosts":[{"target":"arch","fetched_at_unix_nano":-1,"sessions":[]}]}`)}, | |
| 206 | - | {name: "duplicate hosts", raw: []byte(`{"version":3,"hosts":[{"target":"arch","fetched_at_unix_nano":1,"sessions":[]},{"target":"arch","fetched_at_unix_nano":2,"sessions":[]}]}`)}, | |
| 207 | - | {name: "missing exact session fields", raw: []byte(`{"version":3,"hosts":[{"target":"arch","fetched_at_unix_nano":1,"sessions":[{"name":"work","state":"up","ephemeral":false,"tabs":[],"attached":false}]}]}`)}, | |
| 208 | - | {name: "count-only tabs", raw: []byte(`{"version":3,"hosts":[{"target":"arch","fetched_at_unix_nano":1,"sessions":[{"lifecycle_id":"01000000000000000000000000000000","name":"work","state":"up","ephemeral":false,"tabs":1,"attached":false}]}]}`)}, | |
| 209 | - | {name: "unknown field", raw: []byte(`{"version":3,"hosts":[],"future":true}`)}, | |
| 210 | - | {name: "invalid utf8", raw: []byte("{\"version\":3,\"hosts\":[{\"target\":\"\xff\",\"fetched_at_unix_nano\":1,\"sessions\":[]}]}")}, | |
| 211 | - | } | |
| 212 | - | for _, tc := range cases { | |
| 213 | - | t.Run(tc.name, func(t *testing.T) { | |
| 214 | - | t.Parallel() | |
| 215 | - | path := filepath.Join(t.TempDir(), "vev", "remote-catalog-cache.json") | |
| 216 | - | if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { | |
| 217 | - | t.Fatalf("mkdir: %v", err) | |
| 218 | - | } | |
| 219 | - | if err := os.WriteFile(path, tc.raw, 0o600); err != nil { | |
| 220 | - | t.Fatalf("write cache: %v", err) | |
| 221 | - | } | |
| 222 | - | ||
| 223 | - | if _, err := NewFileCatalogCache(path).Load(); err == nil { | |
| 224 | - | t.Fatal("Load() error = nil, want malformed cache error") | |
| 225 | - | } | |
| 226 | - | after, err := os.ReadFile(path) | |
| 227 | - | if err != nil { | |
| 228 | - | t.Fatalf("read cache: %v", err) | |
| 229 | - | } | |
| 230 | - | if !slices.Equal(after, tc.raw) { | |
| 231 | - | t.Fatalf("invalid cache changed: got %q, want %q", after, tc.raw) | |
| 232 | - | } | |
| 233 | - | }) | |
| 234 | - | } | |
| 235 | - | } | |
| 236 | - | ||
| 237 | - | func TestCatalogCacheStoreFailurePreservesExistingFile(t *testing.T) { | |
| 238 | - | t.Parallel() | |
| 239 | - | ||
| 240 | - | path := filepath.Join(t.TempDir(), "vev", "remote-catalog-cache.json") | |
| 241 | - | cache := NewFileCatalogCache(path) | |
| 242 | - | valid := []catalogue.RemoteCatalogCacheEntry{{ | |
| 243 | - | Host: "arch", | |
| 244 | - | FetchedAt: time.Unix(0, 1), | |
| 245 | - | Sessions: []catalogue.RemoteCatalogSession{}, | |
| 246 | - | }} | |
| 247 | - | if err := cache.Store(valid); err != nil { | |
| 248 | - | t.Fatalf("initial Store() error = %v", err) | |
| 249 | - | } | |
| 250 | - | before, err := os.ReadFile(path) | |
| 251 | - | if err != nil { | |
| 252 | - | t.Fatalf("read initial cache: %v", err) | |
| 253 | - | } | |
| 254 | - | ||
| 255 | - | invalid := []catalogue.RemoteCatalogCacheEntry{{ | |
| 256 | - | Host: "arch", | |
| 257 | - | FetchedAt: time.Time{}, | |
| 258 | - | Sessions: []catalogue.RemoteCatalogSession{}, | |
| 259 | - | }} | |
| 260 | - | if err := cache.Store(invalid); err == nil { | |
| 261 | - | t.Fatal("Store() error = nil, want validation error") | |
| 262 | - | } | |
| 263 | - | after, err := os.ReadFile(path) | |
| 264 | - | if err != nil { | |
| 265 | - | t.Fatalf("read cache after failed Store: %v", err) | |
| 266 | - | } | |
| 267 | - | if !slices.Equal(after, before) { | |
| 268 | - | t.Fatalf("failed Store changed cache: got %q, want %q", after, before) | |
| 269 | - | } | |
| 270 | - | } | |
| 271 | - | ||
| 272 | - | func TestCatalogCacheStoreRejectsNonPositiveFetchedTimes(t *testing.T) { | |
| 273 | - | t.Parallel() | |
| 274 | - | ||
| 275 | - | for _, tc := range []struct { | |
| 276 | - | name string | |
| 277 | - | fetchedAt time.Time | |
| 278 | - | }{ | |
| 279 | - | {name: "zero Unix nano", fetchedAt: time.Unix(0, 0)}, | |
| 280 | - | {name: "negative Unix nano", fetchedAt: time.Unix(0, -1)}, | |
| 281 | - | } { | |
| 282 | - | t.Run(tc.name, func(t *testing.T) { | |
| 283 | - | t.Parallel() | |
| 284 | - | path := filepath.Join(t.TempDir(), "remote-catalog-cache.json") | |
| 285 | - | entries := []catalogue.RemoteCatalogCacheEntry{{ | |
| 286 | - | Host: "arch", | |
| 287 | - | FetchedAt: tc.fetchedAt, | |
| 288 | - | Sessions: []catalogue.RemoteCatalogSession{}, | |
| 289 | - | }} | |
| 290 | - | ||
| 291 | - | err := NewFileCatalogCache(path).Store(entries) | |
| 292 | - | ||
| 293 | - | if err == nil { | |
| 294 | - | t.Fatal("Store() error = nil, want non-positive fetched time error") | |
| 295 | - | } | |
| 296 | - | if _, statErr := os.Stat(path); !os.IsNotExist(statErr) { | |
| 297 | - | t.Fatalf("failed Store created cache: stat error = %v", statErr) | |
| 298 | - | } | |
| 299 | - | }) | |
| 300 | - | } | |
| 301 | - | } | |
| 302 | - | ||
| 303 | - | func TestCatalogCacheStoreHardensExistingFilePermissions(t *testing.T) { | |
| 304 | - | t.Parallel() | |
| 305 | - | ||
| 306 | - | path := filepath.Join(t.TempDir(), "vev", "remote-catalog-cache.json") | |
| 307 | - | if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { | |
| 308 | - | t.Fatalf("mkdir: %v", err) | |
| 309 | - | } | |
| 310 | - | if err := os.WriteFile(path, []byte(`{"version":1,"hosts":[]}`), 0o644); err != nil { | |
| 311 | - | t.Fatalf("seed cache: %v", err) | |
| 312 | - | } | |
| 313 | - | ||
| 314 | - | cache := NewFileCatalogCache(path) | |
| 315 | - | if err := cache.Store([]catalogue.RemoteCatalogCacheEntry{}); err != nil { | |
| 316 | - | t.Fatalf("Store() error = %v", err) | |
| 317 | - | } | |
| 318 | - | info, err := os.Stat(path) | |
| 319 | - | if err != nil { | |
| 320 | - | t.Fatalf("stat cache: %v", err) | |
| 321 | - | } | |
| 322 | - | if perm := info.Mode().Perm(); perm != 0o600 { | |
| 323 | - | t.Fatalf("cache permissions = %04o, want 0600", perm) | |
| 324 | - | } | |
| 325 | - | } | |
| 326 | - | ||
| 327 | - | func TestCatalogCacheStoreRenameFailureCleansTemporaryFile(t *testing.T) { | |
| 328 | - | t.Parallel() | |
| 329 | - | ||
| 330 | - | dir := filepath.Join(t.TempDir(), "vev") | |
| 331 | - | if err := os.MkdirAll(dir, 0o700); err != nil { | |
| 332 | - | t.Fatalf("mkdir parent: %v", err) | |
| 333 | - | } | |
| 334 | - | path := filepath.Join(dir, "remote-catalog-cache.json") | |
| 335 | - | if err := os.Mkdir(path, 0o700); err != nil { | |
| 336 | - | t.Fatalf("mkdir destination: %v", err) | |
| 337 | - | } | |
| 338 | - | ||
| 339 | - | cache := NewFileCatalogCache(path) | |
| 340 | - | if err := cache.Store([]catalogue.RemoteCatalogCacheEntry{}); err == nil { | |
| 341 | - | t.Fatal("Store() error = nil, want rename error") | |
| 342 | - | } | |
| 343 | - | entries, err := os.ReadDir(dir) | |
| 344 | - | if err != nil { | |
| 345 | - | t.Fatalf("read cache directory: %v", err) | |
| 346 | - | } | |
| 347 | - | for _, entry := range entries { | |
| 348 | - | if strings.Contains(entry.Name(), ".tmp") { | |
| 349 | - | t.Fatalf("temporary file left behind after failed rename: %q", entry.Name()) | |
| 350 | - | } | |
| 351 | - | } | |
| 352 | - | } | |
| 353 | - | ||
| 354 | - | func equalRemoteCatalogCacheEntry(a, b catalogue.RemoteCatalogCacheEntry) bool { | |
| 355 | - | return a.Host == b.Host && a.FetchedAt.Equal(b.FetchedAt) && a.Incarnation == b.Incarnation && reflect.DeepEqual(a.Sessions, b.Sessions) | |
| 356 | - | } | |
D internal/adapters/remote/catalog_test.go +0 -632
| @@ -1,632 +0,0 @@ | |||
| 1 | - | package remote | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "context" | |
| 5 | - | "encoding/json" | |
| 6 | - | "errors" | |
| 7 | - | "fmt" | |
| 8 | - | "os/exec" | |
| 9 | - | "reflect" | |
| 10 | - | "strings" | |
| 11 | - | "testing" | |
| 12 | - | "time" | |
| 13 | - | ||
| 14 | - | "github.com/bnema/vev/internal/adapters/sshstdio" | |
| 15 | - | "github.com/bnema/vev/internal/domain" | |
| 16 | - | "github.com/bnema/vev/internal/protocol" | |
| 17 | - | "github.com/bnema/vev/internal/protocol/catalogue" | |
| 18 | - | ) | |
| 19 | - | ||
| 20 | - | func TestRemoteCatalogClientCommandConstruction(t *testing.T) { | |
| 21 | - | t.Parallel() | |
| 22 | - | ||
| 23 | - | tests := []struct { | |
| 24 | - | name string | |
| 25 | - | target string | |
| 26 | - | }{ | |
| 27 | - | {name: "plain host", target: "arch"}, | |
| 28 | - | {name: "metacharacters stay single local arg", target: "user@host;touch/tmp/pwn"}, | |
| 29 | - | {name: "user at host", target: "build@mule"}, | |
| 30 | - | } | |
| 31 | - | ||
| 32 | - | for _, tt := range tests { | |
| 33 | - | t.Run(tt.name, func(t *testing.T) { | |
| 34 | - | t.Parallel() | |
| 35 | - | var gotPath string | |
| 36 | - | var gotArgs []string | |
| 37 | - | client := &CatalogClient{ | |
| 38 | - | command: func(ctx context.Context, name string, args ...string) *exec.Cmd { | |
| 39 | - | gotPath = name | |
| 40 | - | gotArgs = append([]string(nil), args...) | |
| 41 | - | return stdoutCmd(ctx, mustCatalogJSON(t, catalogue.RemoteCatalog{ | |
| 42 | - | ProtocolVersion: protocol.Version, | |
| 43 | - | SchemaVersion: catalogue.RemoteCatalogSchemaVersion, | |
| 44 | - | Sessions: []catalogue.RemoteCatalogSession{}, | |
| 45 | - | })) | |
| 46 | - | }, | |
| 47 | - | } | |
| 48 | - | ||
| 49 | - | if _, err := client.List(context.Background(), tt.target); err != nil { | |
| 50 | - | t.Fatalf("List() error = %v", err) | |
| 51 | - | } | |
| 52 | - | ||
| 53 | - | want := sshstdio.BuildCommandForObservation(tt.target, catalogSSHConnectTimeout, "vev", "cmd", "remote-catalog", "--json") | |
| 54 | - | if gotPath != want.Path { | |
| 55 | - | t.Fatalf("Path = %q, want %q", gotPath, want.Path) | |
| 56 | - | } | |
| 57 | - | if len(gotArgs) != len(want.Args) { | |
| 58 | - | t.Fatalf("Args len = %d, want %d (%q)", len(gotArgs), len(want.Args), gotArgs) | |
| 59 | - | } | |
| 60 | - | for i := range want.Args { | |
| 61 | - | if gotArgs[i] != want.Args[i] { | |
| 62 | - | t.Fatalf("Args[%d] = %q, want %q (all %q)", i, gotArgs[i], want.Args[i], gotArgs) | |
| 63 | - | } | |
| 64 | - | } | |
| 65 | - | sep := -1 | |
| 66 | - | for i, arg := range gotArgs { | |
| 67 | - | if arg == "--" { | |
| 68 | - | sep = i | |
| 69 | - | break | |
| 70 | - | } | |
| 71 | - | } | |
| 72 | - | if sep < 0 || sep+1 >= len(gotArgs) || gotArgs[sep+1] != tt.target { | |
| 73 | - | t.Fatalf("observation must keep the target one unquoted local argv word right after --; got %q", gotArgs) | |
| 74 | - | } | |
| 75 | - | joined := strings.Join(gotArgs[:sep], " ") | |
| 76 | - | for _, flag := range []string{"-T", "UpdateHostKeys=no", "ConnectTimeout=", "ConnectionAttempts=1"} { | |
| 77 | - | if !strings.Contains(joined, flag) { | |
| 78 | - | t.Fatalf("observation argv missing %q; got %q", flag, gotArgs) | |
| 79 | - | } | |
| 80 | - | } | |
| 81 | - | for _, hostPolicy := range []string{"BatchMode=", "StrictHostKeyChecking="} { | |
| 82 | - | if strings.Contains(joined, hostPolicy) { | |
| 83 | - | t.Fatalf("observation argv overrides host policy %q; got %q", hostPolicy, gotArgs) | |
| 84 | - | } | |
| 85 | - | } | |
| 86 | - | if sep+2 >= len(gotArgs) || !strings.Contains(gotArgs[sep+2], "'vev'") || !strings.Contains(gotArgs[sep+2], "'remote-catalog'") { | |
| 87 | - | t.Fatalf("remote command words must be shell-quoted; got %q", gotArgs) | |
| 88 | - | } | |
| 89 | - | }) | |
| 90 | - | } | |
| 91 | - | } | |
| 92 | - | ||
| 93 | - | func TestRemoteCatalogClientRejectsUnsafeTarget(t *testing.T) { | |
| 94 | - | t.Parallel() | |
| 95 | - | client := NewCatalogClient() | |
| 96 | - | _, err := client.List(context.Background(), "bad host") | |
| 97 | - | if err == nil { | |
| 98 | - | t.Fatal("List() error = nil, want target validation error") | |
| 99 | - | } | |
| 100 | - | } | |
| 101 | - | ||
| 102 | - | func TestRemoteCatalogClientDecode(t *testing.T) { | |
| 103 | - | t.Parallel() | |
| 104 | - | ||
| 105 | - | valid := catalogue.RemoteCatalog{ | |
| 106 | - | ProtocolVersion: protocol.Version, | |
| 107 | - | SchemaVersion: catalogue.RemoteCatalogSchemaVersion, | |
| 108 | - | Sessions: []catalogue.RemoteCatalogSession{ | |
| 109 | - | {LifecycleID: [16]byte{1}, Name: "work", State: "up", Ephemeral: false, Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-1"}, {ID: "tab-2", Index: 1}}, Attached: true}, | |
| 110 | - | }, | |
| 111 | - | } | |
| 112 | - | validJSON := mustCatalogJSON(t, valid) | |
| 113 | - | ||
| 114 | - | tests := []struct { | |
| 115 | - | name string | |
| 116 | - | stdout string | |
| 117 | - | stderr string | |
| 118 | - | exit int | |
| 119 | - | want catalogue.RemoteCatalog | |
| 120 | - | wantErr error | |
| 121 | - | check func(t *testing.T, err error) | |
| 122 | - | }{ | |
| 123 | - | { | |
| 124 | - | name: "matching protocol version", | |
| 125 | - | stdout: validJSON, | |
| 126 | - | want: valid, | |
| 127 | - | }, | |
| 128 | - | { | |
| 129 | - | name: "trailing whitespace accepted", | |
| 130 | - | stdout: validJSON + " \n\t", | |
| 131 | - | want: valid, | |
| 132 | - | }, | |
| 133 | - | { | |
| 134 | - | name: "mismatching protocol version", | |
| 135 | - | stdout: mustCatalogJSON(t, catalogue.RemoteCatalog{ProtocolVersion: protocol.Version + 1, SchemaVersion: catalogue.RemoteCatalogSchemaVersion, Sessions: []catalogue.RemoteCatalogSession{}}), | |
| 136 | - | check: func(t *testing.T, err error) { | |
| 137 | - | t.Helper() | |
| 138 | - | var mismatch *catalogue.RemoteCatalogVersionMismatchError | |
| 139 | - | if !errors.As(err, &mismatch) { | |
| 140 | - | t.Fatalf("error = %v, want RemoteCatalogVersionMismatchError", err) | |
| 141 | - | } | |
| 142 | - | if mismatch.Kind != "protocol" || mismatch.Got != protocol.Version+1 || mismatch.Want != protocol.Version { | |
| 143 | - | t.Fatalf("mismatch = %#v", mismatch) | |
| 144 | - | } | |
| 145 | - | }, | |
| 146 | - | }, | |
| 147 | - | { | |
| 148 | - | name: "obsolete count-only schema is classified before session decoding", | |
| 149 | - | stdout: fmt.Sprintf(`{"protocol_version":%d,"schema_version":2,"sessions":[{"tabs":1}]}`, | |
| 150 | - | protocol.Version), | |
| 151 | - | check: func(t *testing.T, err error) { | |
| 152 | - | t.Helper() | |
| 153 | - | var mismatch *catalogue.RemoteCatalogVersionMismatchError | |
| 154 | - | if !errors.As(err, &mismatch) || mismatch.Kind != "catalog" || mismatch.Got != 2 || mismatch.Want != catalogue.RemoteCatalogSchemaVersion { | |
| 155 | - | t.Fatalf("error = %v, want obsolete catalogue version mismatch", err) | |
| 156 | - | } | |
| 157 | - | }, | |
| 158 | - | }, | |
| 159 | - | { | |
| 160 | - | name: "unknown catalogue schema", | |
| 161 | - | stdout: fmt.Sprintf(`{"protocol_version":%d,"schema_version":4,"sessions":[]}`, protocol.Version), | |
| 162 | - | check: func(t *testing.T, err error) { | |
| 163 | - | t.Helper() | |
| 164 | - | var mismatch *catalogue.RemoteCatalogVersionMismatchError | |
| 165 | - | if !errors.As(err, &mismatch) || mismatch.Kind != "catalog" || mismatch.Got != 4 || mismatch.Want != catalogue.RemoteCatalogSchemaVersion { | |
| 166 | - | t.Fatalf("error = %v, want unknown catalogue version mismatch", err) | |
| 167 | - | } | |
| 168 | - | }, | |
| 169 | - | }, | |
| 170 | - | { | |
| 171 | - | name: "malformed json", | |
| 172 | - | stdout: `{"protocol_version":`, | |
| 173 | - | wantErr: errCatalogDecode, | |
| 174 | - | }, | |
| 175 | - | { | |
| 176 | - | name: "truncated json", | |
| 177 | - | stdout: `{"protocol_version":19,"sessions":[`, | |
| 178 | - | wantErr: errCatalogDecode, | |
| 179 | - | }, | |
| 180 | - | { | |
| 181 | - | name: "count-only tabs rejected", | |
| 182 | - | stdout: fmt.Sprintf(`{"protocol_version":%d,"schema_version":%d,"sessions":[{"lifecycle_id":"01000000000000000000000000000000","name":"work","state":"up","ephemeral":false,"tabs":1,"attached":false}]}`, | |
| 183 | - | protocol.Version, catalogue.RemoteCatalogSchemaVersion), | |
| 184 | - | wantErr: errCatalogDecode, | |
| 185 | - | }, | |
| 186 | - | { | |
| 187 | - | name: "unknown fields rejected", | |
| 188 | - | stdout: fmt.Sprintf(`{"protocol_version":%d,"schema_version":%d,"sessions":[],"future":true}`, | |
| 189 | - | protocol.Version, catalogue.RemoteCatalogSchemaVersion), | |
| 190 | - | wantErr: errCatalogDecode, | |
| 191 | - | }, | |
| 192 | - | { | |
| 193 | - | name: "missing required session field", | |
| 194 | - | stdout: fmt.Sprintf(`{"protocol_version":%d,"schema_version":%d,"sessions":[{"lifecycle_id":"01000000000000000000000000000000","name":"work","state":"up","tabs":[],"attached":false}]}`, | |
| 195 | - | protocol.Version, catalogue.RemoteCatalogSchemaVersion), | |
| 196 | - | wantErr: errCatalogRequired, | |
| 197 | - | }, | |
| 198 | - | { | |
| 199 | - | name: "trailing non-whitespace", | |
| 200 | - | stdout: strings.TrimSpace(validJSON) + `{"extra":true}`, | |
| 201 | - | wantErr: errCatalogTrailing, | |
| 202 | - | }, | |
| 203 | - | { | |
| 204 | - | name: "missing protocol_version", | |
| 205 | - | stdout: `{"sessions":[]}` + "\n", | |
| 206 | - | wantErr: errCatalogRequired, | |
| 207 | - | }, | |
| 208 | - | { | |
| 209 | - | name: "missing catalogue schema", | |
| 210 | - | stdout: fmt.Sprintf(`{"protocol_version":%d,"sessions":[]}`+"\n", protocol.Version), | |
| 211 | - | check: func(t *testing.T, err error) { | |
| 212 | - | t.Helper() | |
| 213 | - | var mismatch *catalogue.RemoteCatalogVersionMismatchError | |
| 214 | - | if !errors.As(err, &mismatch) || mismatch.Kind != "catalog" || mismatch.Got != 0 || mismatch.Want != catalogue.RemoteCatalogSchemaVersion { | |
| 215 | - | t.Fatalf("error = %v, want missing catalogue version mismatch", err) | |
| 216 | - | } | |
| 217 | - | }, | |
| 218 | - | }, | |
| 219 | - | { | |
| 220 | - | name: "missing sessions", | |
| 221 | - | stdout: fmt.Sprintf(`{"protocol_version":%d,"schema_version":%d}`+"\n", protocol.Version, catalogue.RemoteCatalogSchemaVersion), | |
| 222 | - | wantErr: errCatalogRequired, | |
| 223 | - | }, | |
| 224 | - | { | |
| 225 | - | name: "invalid session name", | |
| 226 | - | stdout: mustCatalogJSON(t, catalogue.RemoteCatalog{ProtocolVersion: protocol.Version, SchemaVersion: catalogue.RemoteCatalogSchemaVersion, Sessions: []catalogue.RemoteCatalogSession{{ | |
| 227 | - | LifecycleID: [16]byte{1}, Name: "bad name", State: "up", Tabs: []catalogue.RemoteCatalogTab{}, | |
| 228 | - | }}}), | |
| 229 | - | wantErr: catalogue.ErrInvalidRemoteCatalog, | |
| 230 | - | }, | |
| 231 | - | { | |
| 232 | - | name: "invalid session state", | |
| 233 | - | stdout: mustCatalogJSON(t, catalogue.RemoteCatalog{ProtocolVersion: protocol.Version, SchemaVersion: catalogue.RemoteCatalogSchemaVersion, Sessions: []catalogue.RemoteCatalogSession{{ | |
| 234 | - | LifecycleID: [16]byte{1}, Name: "work", State: "weird", Tabs: []catalogue.RemoteCatalogTab{}, | |
| 235 | - | }}}), | |
| 236 | - | wantErr: catalogue.ErrRemoteCatalogUnknownState, | |
| 237 | - | }, | |
| 238 | - | { | |
| 239 | - | name: "remote exit with stderr", | |
| 240 | - | stdout: "", | |
| 241 | - | stderr: "connection refused", | |
| 242 | - | exit: 255, | |
| 243 | - | wantErr: errCatalogSSH, | |
| 244 | - | }, | |
| 245 | - | } | |
| 246 | - | ||
| 247 | - | for _, tt := range tests { | |
| 248 | - | t.Run(tt.name, func(t *testing.T) { | |
| 249 | - | t.Parallel() | |
| 250 | - | client := &CatalogClient{ | |
| 251 | - | command: func(ctx context.Context, name string, args ...string) *exec.Cmd { | |
| 252 | - | if tt.exit != 0 { | |
| 253 | - | cmd := exec.CommandContext(ctx, "sh", "-c", fmt.Sprintf("cat >&2; exit %d", tt.exit)) | |
| 254 | - | cmd.Stdin = strings.NewReader(tt.stderr) | |
| 255 | - | return cmd | |
| 256 | - | } | |
| 257 | - | return stdoutCmd(ctx, tt.stdout) | |
| 258 | - | }, | |
| 259 | - | } | |
| 260 | - | got, err := client.List(context.Background(), "arch") | |
| 261 | - | if tt.check != nil { | |
| 262 | - | if err == nil { | |
| 263 | - | t.Fatal("List() error = nil, want error") | |
| 264 | - | } | |
| 265 | - | tt.check(t, err) | |
| 266 | - | return | |
| 267 | - | } | |
| 268 | - | if tt.wantErr != nil { | |
| 269 | - | if !errors.Is(err, tt.wantErr) { | |
| 270 | - | t.Fatalf("List() error = %v, want %v", err, tt.wantErr) | |
| 271 | - | } | |
| 272 | - | return | |
| 273 | - | } | |
| 274 | - | if err != nil { | |
| 275 | - | t.Fatalf("List() error = %v", err) | |
| 276 | - | } | |
| 277 | - | if got.ProtocolVersion != tt.want.ProtocolVersion { | |
| 278 | - | t.Fatalf("ProtocolVersion = %d, want %d", got.ProtocolVersion, tt.want.ProtocolVersion) | |
| 279 | - | } | |
| 280 | - | if got.SchemaVersion != tt.want.SchemaVersion { | |
| 281 | - | t.Fatalf("SchemaVersion = %d, want %d", got.SchemaVersion, tt.want.SchemaVersion) | |
| 282 | - | } | |
| 283 | - | if len(got.Sessions) != len(tt.want.Sessions) { | |
| 284 | - | t.Fatalf("Sessions len = %d, want %d", len(got.Sessions), len(tt.want.Sessions)) | |
| 285 | - | } | |
| 286 | - | for i := range tt.want.Sessions { | |
| 287 | - | if !reflect.DeepEqual(got.Sessions[i], tt.want.Sessions[i]) { | |
| 288 | - | t.Fatalf("Sessions[%d] = %#v, want %#v", i, got.Sessions[i], tt.want.Sessions[i]) | |
| 289 | - | } | |
| 290 | - | } | |
| 291 | - | }) | |
| 292 | - | } | |
| 293 | - | } | |
| 294 | - | ||
| 295 | - | func exitError(t *testing.T, code int) error { | |
| 296 | - | t.Helper() | |
| 297 | - | err := exec.Command("sh", "-c", fmt.Sprintf("exit %d", code)).Run() | |
| 298 | - | if err == nil { | |
| 299 | - | t.Fatalf("exit %d returned nil error", code) | |
| 300 | - | } | |
| 301 | - | return err | |
| 302 | - | } | |
| 303 | - | ||
| 304 | - | func TestClassifyCatalogError(t *testing.T) { | |
| 305 | - | t.Parallel() | |
| 306 | - | ||
| 307 | - | sshWrapped := func(code int, stderr string) error { | |
| 308 | - | return fmt.Errorf("%w: %w: %s", errCatalogSSH, exitError(t, code), stderr) | |
| 309 | - | } | |
| 310 | - | mismatch := &catalogue.RemoteCatalogVersionMismatchError{Got: 1, Want: 2, Kind: "protocol"} | |
| 311 | - | ||
| 312 | - | tests := []struct { | |
| 313 | - | name string | |
| 314 | - | err error | |
| 315 | - | want domain.RemoteFailureKind | |
| 316 | - | }{ | |
| 317 | - | {name: "nil", err: nil, want: domain.RemoteFailureNone}, | |
| 318 | - | {name: "deadline", err: context.DeadlineExceeded, want: domain.RemoteFailureTimeout}, | |
| 319 | - | {name: "canceled stays transport", err: context.Canceled, want: domain.RemoteFailureTransport}, | |
| 320 | - | {name: "version mismatch", err: mismatch, want: domain.RemoteFailureIncompatible}, | |
| 321 | - | {name: "wrapped version mismatch", err: fmt.Errorf("decode: %w", mismatch), want: domain.RemoteFailureIncompatible}, | |
| 322 | - | {name: "too large", err: errCatalogTooLarge, want: domain.RemoteFailureInvalidResponse}, | |
| 323 | - | {name: "decode", err: fmt.Errorf("%w: bad", errCatalogDecode), want: domain.RemoteFailureInvalidResponse}, | |
| 324 | - | {name: "trailing", err: errCatalogTrailing, want: domain.RemoteFailureInvalidResponse}, | |
| 325 | - | {name: "required", err: errCatalogRequired, want: domain.RemoteFailureInvalidResponse}, | |
| 326 | - | {name: "invalid catalog", err: fmt.Errorf("%w: bad session", catalogue.ErrInvalidRemoteCatalog), want: domain.RemoteFailureInvalidResponse}, | |
| 327 | - | {name: "trust failure", err: sshWrapped(255, "Host key verification failed."), want: domain.RemoteFailureTrust}, | |
| 328 | - | {name: "changed host key", err: sshWrapped(255, "@@@@@@@@@@@@\nWARNING: REMOTE HOST IDENTIFICATION HAS CHANGED!\n@@@@@@@@@@@@"), want: domain.RemoteFailureTrust}, | |
| 329 | - | {name: "auth failure", err: sshWrapped(255, "user@arch: Permission denied (publickey)."), want: domain.RemoteFailureAuthentication}, | |
| 330 | - | {name: "bare too many auth failures stay transport", err: sshWrapped(255, "Too many authentication failures"), want: domain.RemoteFailureTransport}, | |
| 331 | - | {name: "remote command exits 255 with auth text stays transport", err: sshWrapped(255, "vev: remote helper failed\nPermission denied (publickey)"), want: domain.RemoteFailureTransport}, | |
| 332 | - | {name: "remote command timeout text stays transport", err: sshWrapped(255, "catalog fetch timed out"), want: domain.RemoteFailureTransport}, | |
| 333 | - | {name: "connect timeout", err: sshWrapped(255, "ssh: connect to host arch port 22: Connection timed out"), want: domain.RemoteFailureTimeout}, | |
| 334 | - | {name: "unrecognized ssh failure stays transport", err: sshWrapped(255, "some new mysterious error"), want: domain.RemoteFailureTransport}, | |
| 335 | - | {name: "remote command failure stays transport", err: sshWrapped(1, "remote vev exploded"), want: domain.RemoteFailureTransport}, | |
| 336 | - | {name: "auth text without client exit stays transport", err: fmt.Errorf("%w: %s", errCatalogSSH, "Permission denied (publickey)"), want: domain.RemoteFailureTransport}, | |
| 337 | - | {name: "plain error stays transport", err: errors.New("boom"), want: domain.RemoteFailureTransport}, | |
| 338 | - | } | |
| 339 | - | for _, tt := range tests { | |
| 340 | - | t.Run(tt.name, func(t *testing.T) { | |
| 341 | - | t.Parallel() | |
| 342 | - | if got := ClassifyCatalogError(tt.err); got != tt.want { | |
| 343 | - | t.Fatalf("ClassifyCatalogError(%v) = %v, want %v", tt.err, got, tt.want) | |
| 344 | - | } | |
| 345 | - | }) | |
| 346 | - | } | |
| 347 | - | } | |
| 348 | - | ||
| 349 | - | func TestRemoteCatalogClientContextCanceled(t *testing.T) { | |
| 350 | - | t.Parallel() | |
| 351 | - | ctx, cancel := context.WithCancel(context.Background()) | |
| 352 | - | cancel() | |
| 353 | - | client := NewCatalogClient() | |
| 354 | - | _, err := client.List(ctx, "arch") | |
| 355 | - | if !errors.Is(err, context.Canceled) { | |
| 356 | - | t.Fatalf("List() error = %v, want context.Canceled", err) | |
| 357 | - | } | |
| 358 | - | } | |
| 359 | - | ||
| 360 | - | func TestRemoteCatalogClientAppliesLifetimeAndWaitDelay(t *testing.T) { | |
| 361 | - | t.Parallel() | |
| 362 | - | ||
| 363 | - | var commandCtx context.Context | |
| 364 | - | var cmd *exec.Cmd | |
| 365 | - | client := &CatalogClient{ | |
| 366 | - | command: func(ctx context.Context, name string, args ...string) *exec.Cmd { | |
| 367 | - | commandCtx = ctx | |
| 368 | - | cmd = stdoutCmd(ctx, mustCatalogJSON(t, catalogue.RemoteCatalog{ | |
| 369 | - | ProtocolVersion: protocol.Version, | |
| 370 | - | SchemaVersion: catalogue.RemoteCatalogSchemaVersion, | |
| 371 | - | Sessions: []catalogue.RemoteCatalogSession{}, | |
| 372 | - | })) | |
| 373 | - | return cmd | |
| 374 | - | }, | |
| 375 | - | } | |
| 376 | - | if _, err := client.List(context.Background(), "arch"); err != nil { | |
| 377 | - | t.Fatalf("List() error = %v", err) | |
| 378 | - | } | |
| 379 | - | deadline, ok := commandCtx.Deadline() | |
| 380 | - | if !ok { | |
| 381 | - | t.Fatal("command context missing default lifetime deadline") | |
| 382 | - | } | |
| 383 | - | if remaining := time.Until(deadline); remaining <= 0 || remaining > catalogCommandTimeout { | |
| 384 | - | t.Fatalf("deadline remaining = %v, want within (0, %v]", remaining, catalogCommandTimeout) | |
| 385 | - | } | |
| 386 | - | if cmd.WaitDelay != catalogCommandWaitDelay { | |
| 387 | - | t.Fatalf("WaitDelay = %v, want %v", cmd.WaitDelay, catalogCommandWaitDelay) | |
| 388 | - | } | |
| 389 | - | } | |
| 390 | - | ||
| 391 | - | func TestRemoteCatalogClientClassifiesRunContextErrors(t *testing.T) { | |
| 392 | - | tests := []struct { | |
| 393 | - | name string | |
| 394 | - | newContext func() (context.Context, context.CancelFunc) | |
| 395 | - | timeout time.Duration | |
| 396 | - | cancel bool | |
| 397 | - | want error | |
| 398 | - | }{ | |
| 399 | - | { | |
| 400 | - | name: "own deadline", | |
| 401 | - | newContext: func() (context.Context, context.CancelFunc) { | |
| 402 | - | return context.WithCancel(context.Background()) | |
| 403 | - | }, | |
| 404 | - | timeout: 20 * time.Millisecond, | |
| 405 | - | want: context.DeadlineExceeded, | |
| 406 | - | }, | |
| 407 | - | { | |
| 408 | - | name: "parent deadline", | |
| 409 | - | newContext: func() (context.Context, context.CancelFunc) { | |
| 410 | - | return context.WithTimeout(context.Background(), 20*time.Millisecond) | |
| 411 | - | }, | |
| 412 | - | timeout: time.Minute, | |
| 413 | - | want: context.DeadlineExceeded, | |
| 414 | - | }, | |
| 415 | - | { | |
| 416 | - | name: "parent cancellation", | |
| 417 | - | newContext: func() (context.Context, context.CancelFunc) { | |
| 418 | - | return context.WithCancel(context.Background()) | |
| 419 | - | }, | |
| 420 | - | timeout: time.Minute, | |
| 421 | - | cancel: true, | |
| 422 | - | want: context.Canceled, | |
| 423 | - | }, | |
| 424 | - | } | |
| 425 | - | ||
| 426 | - | for _, tt := range tests { | |
| 427 | - | t.Run(tt.name, func(t *testing.T) { | |
| 428 | - | ctx, cancel := tt.newContext() | |
| 429 | - | defer cancel() | |
| 430 | - | started := make(chan struct{}) | |
| 431 | - | client := &CatalogClient{ | |
| 432 | - | timeout: tt.timeout, | |
| 433 | - | command: func(ctx context.Context, name string, args ...string) *exec.Cmd { | |
| 434 | - | close(started) | |
| 435 | - | return exec.CommandContext(ctx, "sleep", "10") | |
| 436 | - | }, | |
| 437 | - | } | |
| 438 | - | ||
| 439 | - | errCh := make(chan error, 1) | |
| 440 | - | go func() { | |
| 441 | - | _, err := client.List(ctx, "arch") | |
| 442 | - | errCh <- err | |
| 443 | - | }() | |
| 444 | - | select { | |
| 445 | - | case <-started: | |
| 446 | - | case <-time.After(2 * time.Second): | |
| 447 | - | t.Fatal("command did not start") | |
| 448 | - | } | |
| 449 | - | if tt.cancel { | |
| 450 | - | cancel() | |
| 451 | - | } | |
| 452 | - | select { | |
| 453 | - | case err := <-errCh: | |
| 454 | - | if !errors.Is(err, tt.want) { | |
| 455 | - | t.Fatalf("List() error = %v, want %v", err, tt.want) | |
| 456 | - | } | |
| 457 | - | case <-time.After(2 * time.Second): | |
| 458 | - | t.Fatal("List() did not return after context ended") | |
| 459 | - | } | |
| 460 | - | }) | |
| 461 | - | } | |
| 462 | - | } | |
| 463 | - | ||
| 464 | - | func TestCatalogOutputSafety(t *testing.T) { | |
| 465 | - | t.Parallel() | |
| 466 | - | ||
| 467 | - | tests := []struct { | |
| 468 | - | name string | |
| 469 | - | command func(ctx context.Context) *exec.Cmd | |
| 470 | - | wantErr error | |
| 471 | - | check func(t *testing.T, err error) | |
| 472 | - | }{ | |
| 473 | - | { | |
| 474 | - | name: "oversized stdout", | |
| 475 | - | command: func(ctx context.Context) *exec.Cmd { | |
| 476 | - | return exec.CommandContext(ctx, "sh", "-c", | |
| 477 | - | fmt.Sprintf("head -c %d /dev/zero", maxCatalogBytes+1)) | |
| 478 | - | }, | |
| 479 | - | wantErr: errCatalogTooLarge, | |
| 480 | - | check: func(t *testing.T, err error) { | |
| 481 | - | t.Helper() | |
| 482 | - | if strings.Contains(err.Error(), strings.Repeat("\x00", 64)) { | |
| 483 | - | t.Fatalf("error embeds oversized stdout: %v", err) | |
| 484 | - | } | |
| 485 | - | }, | |
| 486 | - | }, | |
| 487 | - | { | |
| 488 | - | name: "oversized stderr", | |
| 489 | - | command: func(ctx context.Context) *exec.Cmd { | |
| 490 | - | return exec.CommandContext(ctx, "sh", "-c", | |
| 491 | - | fmt.Sprintf("head -c %d /dev/zero >&2; exit 1", maxCatalogDiagnosticBytes+1)) | |
| 492 | - | }, | |
| 493 | - | wantErr: errCatalogTooLarge, | |
| 494 | - | check: func(t *testing.T, err error) { | |
| 495 | - | t.Helper() | |
| 496 | - | if strings.Contains(err.Error(), strings.Repeat("\x00", 64)) { | |
| 497 | - | t.Fatalf("error embeds oversized stderr: %v", err) | |
| 498 | - | } | |
| 499 | - | if errors.Is(err, errCatalogSSH) { | |
| 500 | - | t.Fatalf("oversized stderr must not wrap SSH with captured output: %v", err) | |
| 501 | - | } | |
| 502 | - | }, | |
| 503 | - | }, | |
| 504 | - | { | |
| 505 | - | name: "ansi control stderr", | |
| 506 | - | command: func(ctx context.Context) *exec.Cmd { | |
| 507 | - | cmd := exec.CommandContext(ctx, "sh", "-c", "cat >&2; exit 255") | |
| 508 | - | cmd.Stdin = strings.NewReader("\x1b[31mconnection refused\x1b[0m\x07") | |
| 509 | - | return cmd | |
| 510 | - | }, | |
| 511 | - | wantErr: errCatalogSSH, | |
| 512 | - | check: func(t *testing.T, err error) { | |
| 513 | - | t.Helper() | |
| 514 | - | msg := err.Error() | |
| 515 | - | if strings.ContainsRune(msg, '\x1b') || strings.ContainsRune(msg, '\x07') { | |
| 516 | - | t.Fatalf("error retains control/ANSI bytes: %q", msg) | |
| 517 | - | } | |
| 518 | - | if strings.Contains(msg, "[31m") || strings.Contains(msg, "[0m") { | |
| 519 | - | t.Fatalf("error retains terminal escape remnants: %q", msg) | |
| 520 | - | } | |
| 521 | - | if !strings.Contains(msg, "connection refused") { | |
| 522 | - | t.Fatalf("error = %q, want printable diagnostic text preserved", msg) | |
| 523 | - | } | |
| 524 | - | }, | |
| 525 | - | }, | |
| 526 | - | } | |
| 527 | - | ||
| 528 | - | for _, tt := range tests { | |
| 529 | - | t.Run(tt.name, func(t *testing.T) { | |
| 530 | - | t.Parallel() | |
| 531 | - | client := &CatalogClient{ | |
| 532 | - | command: func(ctx context.Context, name string, args ...string) *exec.Cmd { | |
| 533 | - | return tt.command(ctx) | |
| 534 | - | }, | |
| 535 | - | } | |
| 536 | - | _, err := client.List(context.Background(), "arch") | |
| 537 | - | if !errors.Is(err, tt.wantErr) { | |
| 538 | - | t.Fatalf("List() error = %v, want %v", err, tt.wantErr) | |
| 539 | - | } | |
| 540 | - | if tt.check != nil { | |
| 541 | - | tt.check(t, err) | |
| 542 | - | } | |
| 543 | - | }) | |
| 544 | - | } | |
| 545 | - | } | |
| 546 | - | ||
| 547 | - | func TestBoundedBufferLimits(t *testing.T) { | |
| 548 | - | t.Parallel() | |
| 549 | - | ||
| 550 | - | tests := []struct { | |
| 551 | - | name string | |
| 552 | - | limit int | |
| 553 | - | chunks [][]byte | |
| 554 | - | wantLen int | |
| 555 | - | wantOver bool | |
| 556 | - | wantBytes string | |
| 557 | - | }{ | |
| 558 | - | { | |
| 559 | - | name: "under limit", | |
| 560 | - | limit: 8, | |
| 561 | - | chunks: [][]byte{[]byte("abcd"), []byte("ef")}, | |
| 562 | - | wantLen: 6, | |
| 563 | - | wantOver: false, | |
| 564 | - | wantBytes: "abcdef", | |
| 565 | - | }, | |
| 566 | - | { | |
| 567 | - | name: "exact limit", | |
| 568 | - | limit: 4, | |
| 569 | - | chunks: [][]byte{[]byte("abcd")}, | |
| 570 | - | wantLen: 4, | |
| 571 | - | wantOver: false, | |
| 572 | - | wantBytes: "abcd", | |
| 573 | - | }, | |
| 574 | - | { | |
| 575 | - | name: "overflow discards remainder", | |
| 576 | - | limit: 4, | |
| 577 | - | chunks: [][]byte{[]byte("abc"), []byte("defgh")}, | |
| 578 | - | wantLen: 4, | |
| 579 | - | wantOver: true, | |
| 580 | - | wantBytes: "abcd", | |
| 581 | - | }, | |
| 582 | - | { | |
| 583 | - | name: "further writes stay discarded", | |
| 584 | - | limit: 2, | |
| 585 | - | chunks: [][]byte{[]byte("abcdef"), []byte("xyz")}, | |
| 586 | - | wantLen: 2, | |
| 587 | - | wantOver: true, | |
| 588 | - | wantBytes: "ab", | |
| 589 | - | }, | |
| 590 | - | } | |
| 591 | - | ||
| 592 | - | for _, tt := range tests { | |
| 593 | - | t.Run(tt.name, func(t *testing.T) { | |
| 594 | - | t.Parallel() | |
| 595 | - | var buf boundedBuffer | |
| 596 | - | buf.limit = tt.limit | |
| 597 | - | for _, chunk := range tt.chunks { | |
| 598 | - | n, err := buf.Write(chunk) | |
| 599 | - | if err != nil { | |
| 600 | - | t.Fatalf("Write(%q) error = %v", chunk, err) | |
| 601 | - | } | |
| 602 | - | if n != len(chunk) { | |
| 603 | - | t.Fatalf("Write(%q) n = %d, want %d", chunk, n, len(chunk)) | |
| 604 | - | } | |
| 605 | - | } | |
| 606 | - | if buf.overflow != tt.wantOver { | |
| 607 | - | t.Fatalf("overflow = %v, want %v", buf.overflow, tt.wantOver) | |
| 608 | - | } | |
| 609 | - | if got := len(buf.Bytes()); got != tt.wantLen { | |
| 610 | - | t.Fatalf("len = %d, want %d", got, tt.wantLen) | |
| 611 | - | } | |
| 612 | - | if got := string(buf.Bytes()); got != tt.wantBytes { | |
| 613 | - | t.Fatalf("bytes = %q, want %q", got, tt.wantBytes) | |
| 614 | - | } | |
| 615 | - | }) | |
| 616 | - | } | |
| 617 | - | } | |
| 618 | - | ||
| 619 | - | func stdoutCmd(ctx context.Context, stdout string) *exec.Cmd { | |
| 620 | - | cmd := exec.CommandContext(ctx, "cat") | |
| 621 | - | cmd.Stdin = strings.NewReader(stdout) | |
| 622 | - | return cmd | |
| 623 | - | } | |
| 624 | - | ||
| 625 | - | func mustCatalogJSON(t *testing.T, catalog catalogue.RemoteCatalog) string { | |
| 626 | - | t.Helper() | |
| 627 | - | raw, err := json.Marshal(catalog) | |
| 628 | - | if err != nil { | |
| 629 | - | t.Fatalf("Marshal: %v", err) | |
| 630 | - | } | |
| 631 | - | return string(raw) + "\n" | |
| 632 | - | } | |
D internal/adapters/remote/host_registry.go +0 -579
| @@ -1,579 +0,0 @@ | |||
| 1 | - | package remote | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "crypto/rand" | |
| 5 | - | "encoding/hex" | |
| 6 | - | "encoding/json" | |
| 7 | - | "errors" | |
| 8 | - | "fmt" | |
| 9 | - | "log/slog" | |
| 10 | - | "os" | |
| 11 | - | "path/filepath" | |
| 12 | - | "sort" | |
| 13 | - | "syscall" | |
| 14 | - | "unicode/utf8" | |
| 15 | - | ||
| 16 | - | "github.com/bnema/vev/internal/domain" | |
| 17 | - | "github.com/bnema/vev/internal/ports" | |
| 18 | - | "github.com/bnema/vev/pkg/safedir" | |
| 19 | - | ) | |
| 20 | - | ||
| 21 | - | const ( | |
| 22 | - | hostsFileName = "hosts.json" | |
| 23 | - | hostsFileVersion = 3 | |
| 24 | - | hostsBackupSuffix = ".v2.bak" | |
| 25 | - | legacyHostsVersion = 2 | |
| 26 | - | legacyListVersion = 1 | |
| 27 | - | ) | |
| 28 | - | ||
| 29 | - | // newRemoteRegistration creates a registration with a fresh random | |
| 30 | - | // incarnation for a validated endpoint. Domain construction stays | |
| 31 | - | // deterministic; only this adapter samples randomness. | |
| 32 | - | func newRemoteRegistration(endpoint string) (domain.RemoteRegistration, error) { | |
| 33 | - | var incarnation [16]byte | |
| 34 | - | if _, err := rand.Read(incarnation[:]); err != nil { | |
| 35 | - | return domain.RemoteRegistration{}, fmt.Errorf("remote registration: %w", err) | |
| 36 | - | } | |
| 37 | - | return domain.NewRemoteRegistration(endpoint, incarnation) | |
| 38 | - | } | |
| 39 | - | ||
| 40 | - | // HostStorePath returns the canonical location of the remote host store in stateDir. | |
| 41 | - | func HostStorePath(stateDir string) string { return filepath.Join(stateDir, hostsFileName) } | |
| 42 | - | ||
| 43 | - | type hostsFile struct { | |
| 44 | - | Version int `json:"version"` | |
| 45 | - | Pinned []hostRecord `json:"pinned"` | |
| 46 | - | Learned []hostRecord `json:"learned"` | |
| 47 | - | } | |
| 48 | - | ||
| 49 | - | // hostRecord is the persisted registration for one endpoint: the exact | |
| 50 | - | // configured identity plus a random incarnation and fencing generation. | |
| 51 | - | // Incarnation is hex-encoded to stay debuggable in the JSON file. | |
| 52 | - | type hostRecord struct { | |
| 53 | - | Endpoint string `json:"endpoint"` | |
| 54 | - | Incarnation string `json:"incarnation"` | |
| 55 | - | Generation uint64 `json:"generation"` | |
| 56 | - | } | |
| 57 | - | ||
| 58 | - | type legacyHostsFile struct { | |
| 59 | - | Version int `json:"version"` | |
| 60 | - | Pinned []string `json:"pinned"` | |
| 61 | - | Learned []string `json:"learned"` | |
| 62 | - | } | |
| 63 | - | ||
| 64 | - | type legacyListHostsFile struct { | |
| 65 | - | Version int `json:"version"` | |
| 66 | - | Hosts []string `json:"hosts"` | |
| 67 | - | } | |
| 68 | - | ||
| 69 | - | type hostState struct { | |
| 70 | - | pinned []domain.RemoteRegistration | |
| 71 | - | learned map[string]domain.RemoteRegistration | |
| 72 | - | } | |
| 73 | - | ||
| 74 | - | type fileHostStore struct { | |
| 75 | - | path string | |
| 76 | - | } | |
| 77 | - | ||
| 78 | - | // NewFileHostStore returns a unified pinned/learned host store backed by path. | |
| 79 | - | func NewFileHostStore(path string) ports.RemoteHostStore { | |
| 80 | - | return &fileHostStore{path: path} | |
| 81 | - | } | |
| 82 | - | ||
| 83 | - | var _ ports.RemoteHostStore = (*fileHostStore)(nil) | |
| 84 | - | ||
| 85 | - | func (s *fileHostStore) Hosts() (pinned, learned []domain.RemoteRegistration, err error) { | |
| 86 | - | err = s.withLock(func() error { | |
| 87 | - | state, loadErr := s.loadMigratedLocked() | |
| 88 | - | if loadErr != nil { | |
| 89 | - | return loadErr | |
| 90 | - | } | |
| 91 | - | pinned = append([]domain.RemoteRegistration(nil), state.pinned...) | |
| 92 | - | learned = sortedRegistrations(state.learned) | |
| 93 | - | return nil | |
| 94 | - | }) | |
| 95 | - | if err != nil { | |
| 96 | - | slog.Debug("remote host store hosts failed", "path", s.path, "err", err) | |
| 97 | - | return nil, nil, err | |
| 98 | - | } | |
| 99 | - | return pinned, learned, nil | |
| 100 | - | } | |
| 101 | - | ||
| 102 | - | func (s *fileHostStore) AddPinned(target string) error { | |
| 103 | - | if err := validateHostTarget(target); err != nil { | |
| 104 | - | return err | |
| 105 | - | } | |
| 106 | - | err := s.withLock(func() error { | |
| 107 | - | state, err := s.loadMigratedLocked() | |
| 108 | - | if err != nil { | |
| 109 | - | return err | |
| 110 | - | } | |
| 111 | - | for _, record := range state.pinned { | |
| 112 | - | if record.Endpoint == target { | |
| 113 | - | return nil | |
| 114 | - | } | |
| 115 | - | } | |
| 116 | - | // Pinning an existing registration retains its incarnation. | |
| 117 | - | if record, ok := state.learned[target]; ok { | |
| 118 | - | state.pinned = append(state.pinned, record) | |
| 119 | - | } else { | |
| 120 | - | record, err := newRemoteRegistration(target) | |
| 121 | - | if err != nil { | |
| 122 | - | return err | |
| 123 | - | } | |
| 124 | - | state.pinned = append(state.pinned, record) | |
| 125 | - | } | |
| 126 | - | return s.saveLocked(state) | |
| 127 | - | }) | |
| 128 | - | if err != nil { | |
| 129 | - | slog.Debug("remote host store add pinned failed", "path", s.path, "target", target, "err", err) | |
| 130 | - | } | |
| 131 | - | return err | |
| 132 | - | } | |
| 133 | - | ||
| 134 | - | func (s *fileHostStore) RemovePinned(target string) error { | |
| 135 | - | if err := validateHostTarget(target); err != nil { | |
| 136 | - | return err | |
| 137 | - | } | |
| 138 | - | err := s.withLock(func() error { | |
| 139 | - | state, err := s.loadMigratedLocked() | |
| 140 | - | if err != nil { | |
| 141 | - | return err | |
| 142 | - | } | |
| 143 | - | next, changed := removeRegistration(state.pinned, target) | |
| 144 | - | if !changed { | |
| 145 | - | return nil | |
| 146 | - | } | |
| 147 | - | state.pinned = next | |
| 148 | - | return s.saveLocked(state) | |
| 149 | - | }) | |
| 150 | - | if err != nil { | |
| 151 | - | slog.Debug("remote host store remove pinned failed", "path", s.path, "target", target, "err", err) | |
| 152 | - | } | |
| 153 | - | return err | |
| 154 | - | } | |
| 155 | - | ||
| 156 | - | func (s *fileHostStore) Remember(target string) error { | |
| 157 | - | if err := validateHostTarget(target); err != nil { | |
| 158 | - | return err | |
| 159 | - | } | |
| 160 | - | err := s.withLock(func() error { | |
| 161 | - | state, err := s.loadMigratedLocked() | |
| 162 | - | if err != nil { | |
| 163 | - | return err | |
| 164 | - | } | |
| 165 | - | if _, ok := state.learned[target]; ok { | |
| 166 | - | return nil | |
| 167 | - | } | |
| 168 | - | // Remembering an existing registration retains its incarnation. | |
| 169 | - | if record, ok := findRegistration(state.pinned, target); ok { | |
| 170 | - | state.learned[target] = record | |
| 171 | - | } else { | |
| 172 | - | record, err := newRemoteRegistration(target) | |
| 173 | - | if err != nil { | |
| 174 | - | return err | |
| 175 | - | } | |
| 176 | - | state.learned[target] = record | |
| 177 | - | } | |
| 178 | - | return s.saveLocked(state) | |
| 179 | - | }) | |
| 180 | - | if err != nil { | |
| 181 | - | slog.Debug("remote host store remember failed", "path", s.path, "target", target, "err", err) | |
| 182 | - | } | |
| 183 | - | return err | |
| 184 | - | } | |
| 185 | - | ||
| 186 | - | func (s *fileHostStore) Forget(target string) error { | |
| 187 | - | if err := validateHostTarget(target); err != nil { | |
| 188 | - | return err | |
| 189 | - | } | |
| 190 | - | err := s.withLock(func() error { | |
| 191 | - | state, err := s.loadMigratedLocked() | |
| 192 | - | if err != nil { | |
| 193 | - | return err | |
| 194 | - | } | |
| 195 | - | if _, ok := state.learned[target]; !ok { | |
| 196 | - | return nil | |
| 197 | - | } | |
| 198 | - | delete(state.learned, target) | |
| 199 | - | return s.saveLocked(state) | |
| 200 | - | }) | |
| 201 | - | if err != nil { | |
| 202 | - | slog.Debug("remote host store forget failed", "path", s.path, "target", target, "err", err) | |
| 203 | - | } | |
| 204 | - | return err | |
| 205 | - | } | |
| 206 | - | ||
| 207 | - | func (s *fileHostStore) Remove(target string) (deleted bool, err error) { | |
| 208 | - | if err := validateHostTarget(target); err != nil { | |
| 209 | - | return false, err | |
| 210 | - | } | |
| 211 | - | err = s.withLock(func() error { | |
| 212 | - | state, err := s.loadMigratedLocked() | |
| 213 | - | if err != nil { | |
| 214 | - | return err | |
| 215 | - | } | |
| 216 | - | nextPinned, pinnedChanged := removeRegistration(state.pinned, target) | |
| 217 | - | _, learnedPresent := state.learned[target] | |
| 218 | - | if !pinnedChanged && !learnedPresent { | |
| 219 | - | return nil | |
| 220 | - | } | |
| 221 | - | // Complete removal drops the incarnation: a later re-add creates | |
| 222 | - | // a new registration even though the endpoint string matches. | |
| 223 | - | state.pinned = nextPinned | |
| 224 | - | delete(state.learned, target) | |
| 225 | - | if err := s.saveLocked(state); err != nil { | |
| 226 | - | return err | |
| 227 | - | } | |
| 228 | - | deleted = true | |
| 229 | - | return nil | |
| 230 | - | }) | |
| 231 | - | if err != nil { | |
| 232 | - | slog.Debug("remote host store remove failed", "path", s.path, "target", target, "err", err) | |
| 233 | - | return false, err | |
| 234 | - | } | |
| 235 | - | return deleted, nil | |
| 236 | - | } | |
| 237 | - | ||
| 238 | - | func (s *fileHostStore) withLock(fn func() error) error { | |
| 239 | - | if err := safedir.EnsurePrivate(filepath.Dir(s.path)); err != nil { | |
| 240 | - | return err | |
| 241 | - | } | |
| 242 | - | lock, err := os.OpenFile(s.path+".lock", os.O_CREATE|os.O_RDWR, 0o600) | |
| 243 | - | if err != nil { | |
| 244 | - | return err | |
| 245 | - | } | |
| 246 | - | defer func() { _ = lock.Close() }() | |
| 247 | - | if err := lock.Chmod(0o600); err != nil { | |
| 248 | - | return err | |
| 249 | - | } | |
| 250 | - | if err := syscall.Flock(int(lock.Fd()), syscall.LOCK_EX); err != nil { | |
| 251 | - | return err | |
| 252 | - | } | |
| 253 | - | defer func() { _ = syscall.Flock(int(lock.Fd()), syscall.LOCK_UN) }() | |
| 254 | - | return fn() | |
| 255 | - | } | |
| 256 | - | ||
| 257 | - | // loadMigratedLocked loads the registry, migrating version 1 list files and | |
| 258 | - | // version 2 string lists to version 3 registration records. Migration assigns | |
| 259 | - | // one fresh random incarnation per endpoint, preserves pinned/learned | |
| 260 | - | // ordering and membership, and persists a private pre-migration backup. | |
| 261 | - | // Unsupported or corrupt versions fail closed without replacement. | |
| 262 | - | func (s *fileHostStore) loadMigratedLocked() (hostState, error) { | |
| 263 | - | raw, err := os.ReadFile(s.path) | |
| 264 | - | if errors.Is(err, os.ErrNotExist) { | |
| 265 | - | return hostState{ | |
| 266 | - | pinned: make([]domain.RemoteRegistration, 0), | |
| 267 | - | learned: make(map[string]domain.RemoteRegistration), | |
| 268 | - | }, nil | |
| 269 | - | } | |
| 270 | - | if err != nil { | |
| 271 | - | return hostState{}, err | |
| 272 | - | } | |
| 273 | - | if !utf8.Valid(raw) { | |
| 274 | - | return hostState{}, fmt.Errorf("remote host store: malformed hosts file: invalid UTF-8") | |
| 275 | - | } | |
| 276 | - | var header struct { | |
| 277 | - | Version int `json:"version"` | |
| 278 | - | } | |
| 279 | - | if err := json.Unmarshal(raw, &header); err != nil { | |
| 280 | - | return hostState{}, fmt.Errorf("remote host store: malformed hosts file: %w", err) | |
| 281 | - | } | |
| 282 | - | ||
| 283 | - | switch header.Version { | |
| 284 | - | case hostsFileVersion: | |
| 285 | - | return decodeHostsV3(raw) | |
| 286 | - | case legacyHostsVersion, legacyListVersion: | |
| 287 | - | state, err := decodeLegacyHosts(raw, header.Version) | |
| 288 | - | if err != nil { | |
| 289 | - | return hostState{}, err | |
| 290 | - | } | |
| 291 | - | if err := s.backupAndSaveMigratedLocked(raw, state); err != nil { | |
| 292 | - | return hostState{}, err | |
| 293 | - | } | |
| 294 | - | return state, nil | |
| 295 | - | default: | |
| 296 | - | return hostState{}, fmt.Errorf("remote host store: unsupported hosts file version %d", header.Version) | |
| 297 | - | } | |
| 298 | - | } | |
| 299 | - | ||
| 300 | - | // backupAndSaveMigratedLocked preserves the pre-migration bytes privately and | |
| 301 | - | // atomically replaces the registry with version 3 records. Both steps run | |
| 302 | - | // under the existing registry lock. | |
| 303 | - | func (s *fileHostStore) backupAndSaveMigratedLocked(raw []byte, state hostState) error { | |
| 304 | - | if err := atomicReplacePrivateFile(s.path+hostsBackupSuffix, raw); err != nil { | |
| 305 | - | return err | |
| 306 | - | } | |
| 307 | - | return s.saveLocked(state) | |
| 308 | - | } | |
| 309 | - | ||
| 310 | - | func decodeHostsV3(raw []byte) (hostState, error) { | |
| 311 | - | var decoded hostsFile | |
| 312 | - | if err := json.Unmarshal(raw, &decoded); err != nil { | |
| 313 | - | return hostState{}, fmt.Errorf("remote host store: malformed hosts file: %w", err) | |
| 314 | - | } | |
| 315 | - | if decoded.Pinned == nil { | |
| 316 | - | return hostState{}, fmt.Errorf("remote host store: malformed hosts file: missing pinned") | |
| 317 | - | } | |
| 318 | - | if decoded.Learned == nil { | |
| 319 | - | return hostState{}, fmt.Errorf("remote host store: malformed hosts file: missing learned") | |
| 320 | - | } | |
| 321 | - | pinned, err := validatePinnedRecords(decoded.Pinned) | |
| 322 | - | if err != nil { | |
| 323 | - | return hostState{}, err | |
| 324 | - | } | |
| 325 | - | learned, err := validateLearnedRecords(decoded.Learned) | |
| 326 | - | if err != nil { | |
| 327 | - | return hostState{}, err | |
| 328 | - | } | |
| 329 | - | for _, record := range pinned { | |
| 330 | - | if other, ok := learned[record.Endpoint]; ok && !other.Equal(record) { | |
| 331 | - | return hostState{}, fmt.Errorf("remote host store: malformed hosts file: conflicting identities for %q", record.Endpoint) | |
| 332 | - | } | |
| 333 | - | } | |
| 334 | - | return hostState{pinned: pinned, learned: learned}, nil | |
| 335 | - | } | |
| 336 | - | ||
| 337 | - | func decodeLegacyHosts(raw []byte, version int) (hostState, error) { | |
| 338 | - | if version == legacyListVersion { | |
| 339 | - | var legacy legacyListHostsFile | |
| 340 | - | if err := json.Unmarshal(raw, &legacy); err != nil { | |
| 341 | - | return hostState{}, fmt.Errorf("remote host store: malformed hosts file: %w", err) | |
| 342 | - | } | |
| 343 | - | if legacy.Hosts == nil { | |
| 344 | - | return hostState{}, fmt.Errorf("remote host store: malformed hosts file: missing hosts") | |
| 345 | - | } | |
| 346 | - | learned, err := migrateTargetsToRecords(legacy.Hosts, nil) | |
| 347 | - | if err != nil { | |
| 348 | - | return hostState{}, err | |
| 349 | - | } | |
| 350 | - | return hostState{pinned: []domain.RemoteRegistration{}, learned: learned}, nil | |
| 351 | - | } | |
| 352 | - | var legacy legacyHostsFile | |
| 353 | - | if err := json.Unmarshal(raw, &legacy); err != nil { | |
| 354 | - | return hostState{}, fmt.Errorf("remote host store: malformed hosts file: %w", err) | |
| 355 | - | } | |
| 356 | - | if legacy.Pinned == nil { | |
| 357 | - | return hostState{}, fmt.Errorf("remote host store: malformed hosts file: missing pinned") | |
| 358 | - | } | |
| 359 | - | if legacy.Learned == nil { | |
| 360 | - | return hostState{}, fmt.Errorf("remote host store: malformed hosts file: missing learned") | |
| 361 | - | } | |
| 362 | - | pinned, err := migrateOrderedTargetsToRecords(legacy.Pinned) | |
| 363 | - | if err != nil { | |
| 364 | - | return hostState{}, err | |
| 365 | - | } | |
| 366 | - | // One endpoint keeps one registration across memberships: learned | |
| 367 | - | // entries reuse the pinned incarnation instead of minting a second | |
| 368 | - | // identity that would surface on RemovePinned. | |
| 369 | - | reuse := make(map[string]domain.RemoteRegistration, len(pinned)) | |
| 370 | - | for _, record := range pinned { | |
| 371 | - | reuse[record.Endpoint] = record | |
| 372 | - | } | |
| 373 | - | learned, err := migrateTargetsToRecords(legacy.Learned, reuse) | |
| 374 | - | if err != nil { | |
| 375 | - | return hostState{}, err | |
| 376 | - | } | |
| 377 | - | return hostState{pinned: pinned, learned: learned}, nil | |
| 378 | - | } | |
| 379 | - | ||
| 380 | - | func migrateOrderedTargetsToRecords(hosts []string) ([]domain.RemoteRegistration, error) { | |
| 381 | - | pinned := make([]domain.RemoteRegistration, 0, len(hosts)) | |
| 382 | - | seen := make(map[string]struct{}, len(hosts)) | |
| 383 | - | for _, host := range hosts { | |
| 384 | - | if err := validateHostTarget(host); err != nil { | |
| 385 | - | return nil, fmt.Errorf("remote host store: malformed hosts file: %w", err) | |
| 386 | - | } | |
| 387 | - | if _, dup := seen[host]; dup { | |
| 388 | - | return nil, fmt.Errorf("remote host store: malformed hosts file: duplicate pinned host %q", host) | |
| 389 | - | } | |
| 390 | - | seen[host] = struct{}{} | |
| 391 | - | record, err := newRemoteRegistration(host) | |
| 392 | - | if err != nil { | |
| 393 | - | return nil, err | |
| 394 | - | } | |
| 395 | - | pinned = append(pinned, record) | |
| 396 | - | } | |
| 397 | - | return pinned, nil | |
| 398 | - | } | |
| 399 | - | ||
| 400 | - | func migrateTargetsToRecords(hosts []string, reuse map[string]domain.RemoteRegistration) (map[string]domain.RemoteRegistration, error) { | |
| 401 | - | learned := make(map[string]domain.RemoteRegistration, len(hosts)) | |
| 402 | - | for _, host := range hosts { | |
| 403 | - | if err := validateHostTarget(host); err != nil { | |
| 404 | - | return nil, fmt.Errorf("remote host store: malformed hosts file: %w", err) | |
| 405 | - | } | |
| 406 | - | if _, dup := learned[host]; dup { | |
| 407 | - | return nil, fmt.Errorf("remote host store: malformed hosts file: duplicate learned host %q", host) | |
| 408 | - | } | |
| 409 | - | if record, ok := reuse[host]; ok { | |
| 410 | - | learned[host] = record | |
| 411 | - | continue | |
| 412 | - | } | |
| 413 | - | record, err := newRemoteRegistration(host) | |
| 414 | - | if err != nil { | |
| 415 | - | return nil, err | |
| 416 | - | } | |
| 417 | - | learned[host] = record | |
| 418 | - | } | |
| 419 | - | return learned, nil | |
| 420 | - | } | |
| 421 | - | ||
| 422 | - | func (s *fileHostStore) saveLocked(state hostState) error { | |
| 423 | - | pinned := make([]hostRecord, 0, len(state.pinned)) | |
| 424 | - | for _, record := range state.pinned { | |
| 425 | - | pinned = append(pinned, encodeRecord(record)) | |
| 426 | - | } | |
| 427 | - | learned := make([]hostRecord, 0, len(state.learned)) | |
| 428 | - | for _, record := range sortedRegistrations(state.learned) { | |
| 429 | - | learned = append(learned, encodeRecord(record)) | |
| 430 | - | } | |
| 431 | - | payload, err := json.Marshal(hostsFile{ | |
| 432 | - | Version: hostsFileVersion, | |
| 433 | - | Pinned: pinned, | |
| 434 | - | Learned: learned, | |
| 435 | - | }) | |
| 436 | - | if err != nil { | |
| 437 | - | return err | |
| 438 | - | } | |
| 439 | - | return atomicReplacePrivateFile(s.path, append(payload, '\n')) | |
| 440 | - | } | |
| 441 | - | ||
| 442 | - | func encodeRecord(record domain.RemoteRegistration) hostRecord { | |
| 443 | - | return hostRecord{ | |
| 444 | - | Endpoint: record.Endpoint, | |
| 445 | - | Incarnation: hex.EncodeToString(record.Incarnation[:]), | |
| 446 | - | Generation: uint64(record.Generation), | |
| 447 | - | } | |
| 448 | - | } | |
| 449 | - | ||
| 450 | - | func decodeRecord(record hostRecord) (domain.RemoteRegistration, error) { | |
| 451 | - | if err := validateHostTarget(record.Endpoint); err != nil { | |
| 452 | - | return domain.RemoteRegistration{}, fmt.Errorf("remote host store: malformed hosts file: %w", err) | |
| 453 | - | } | |
| 454 | - | raw, err := hex.DecodeString(record.Incarnation) | |
| 455 | - | if err != nil || len(raw) != 16 { | |
| 456 | - | return domain.RemoteRegistration{}, fmt.Errorf("remote host store: malformed hosts file: invalid incarnation for %q", record.Endpoint) | |
| 457 | - | } | |
| 458 | - | var incarnation [16]byte | |
| 459 | - | copy(incarnation[:], raw) | |
| 460 | - | registration := domain.RemoteRegistration{Endpoint: record.Endpoint, Incarnation: incarnation, Generation: domain.RemoteGeneration(record.Generation)} | |
| 461 | - | if err := registration.Validate(); err != nil { | |
| 462 | - | return domain.RemoteRegistration{}, fmt.Errorf("remote host store: malformed hosts file: %w", err) | |
| 463 | - | } | |
| 464 | - | return registration, nil | |
| 465 | - | } | |
| 466 | - | ||
| 467 | - | func atomicReplacePrivateFile(path string, payload []byte) error { | |
| 468 | - | dir := filepath.Dir(path) | |
| 469 | - | if err := safedir.EnsurePrivate(dir); err != nil { | |
| 470 | - | return err | |
| 471 | - | } | |
| 472 | - | tmp, err := os.CreateTemp(dir, ".vev-*.tmp") | |
| 473 | - | if err != nil { | |
| 474 | - | return err | |
| 475 | - | } | |
| 476 | - | tmpName := tmp.Name() | |
| 477 | - | committed := false | |
| 478 | - | defer func() { | |
| 479 | - | if !committed { | |
| 480 | - | _ = tmp.Close() | |
| 481 | - | _ = os.Remove(tmpName) | |
| 482 | - | } | |
| 483 | - | }() | |
| 484 | - | if err := tmp.Chmod(0o600); err != nil { | |
| 485 | - | return err | |
| 486 | - | } | |
| 487 | - | if _, err := tmp.Write(payload); err != nil { | |
| 488 | - | return err | |
| 489 | - | } | |
| 490 | - | if err := tmp.Sync(); err != nil { | |
| 491 | - | return err | |
| 492 | - | } | |
| 493 | - | if err := tmp.Close(); err != nil { | |
| 494 | - | return err | |
| 495 | - | } | |
| 496 | - | if err := os.Rename(tmpName, path); err != nil { | |
| 497 | - | return err | |
| 498 | - | } | |
| 499 | - | committed = true | |
| 500 | - | return syncDir(dir) | |
| 501 | - | } | |
| 502 | - | ||
| 503 | - | func validatePinnedRecords(records []hostRecord) ([]domain.RemoteRegistration, error) { | |
| 504 | - | pinned := make([]domain.RemoteRegistration, 0, len(records)) | |
| 505 | - | seen := make(map[string]struct{}, len(records)) | |
| 506 | - | for _, record := range records { | |
| 507 | - | registration, err := decodeRecord(record) | |
| 508 | - | if err != nil { | |
| 509 | - | return nil, err | |
| 510 | - | } | |
| 511 | - | if _, dup := seen[registration.Endpoint]; dup { | |
| 512 | - | return nil, fmt.Errorf("remote host store: malformed hosts file: duplicate pinned host %q", registration.Endpoint) | |
| 513 | - | } | |
| 514 | - | seen[registration.Endpoint] = struct{}{} | |
| 515 | - | pinned = append(pinned, registration) | |
| 516 | - | } | |
| 517 | - | return pinned, nil | |
| 518 | - | } | |
| 519 | - | ||
| 520 | - | func validateLearnedRecords(records []hostRecord) (map[string]domain.RemoteRegistration, error) { | |
| 521 | - | learned := make(map[string]domain.RemoteRegistration, len(records)) | |
| 522 | - | for _, record := range records { | |
| 523 | - | registration, err := decodeRecord(record) | |
| 524 | - | if err != nil { | |
| 525 | - | return nil, err | |
| 526 | - | } | |
| 527 | - | if _, dup := learned[registration.Endpoint]; dup { | |
| 528 | - | return nil, fmt.Errorf("remote host store: malformed hosts file: duplicate learned host %q", registration.Endpoint) | |
| 529 | - | } | |
| 530 | - | learned[registration.Endpoint] = registration | |
| 531 | - | } | |
| 532 | - | return learned, nil | |
| 533 | - | } | |
| 534 | - | ||
| 535 | - | func validateHostTarget(target string) error { | |
| 536 | - | if !utf8.ValidString(target) { | |
| 537 | - | return fmt.Errorf("remote host target is not valid UTF-8") | |
| 538 | - | } | |
| 539 | - | return domain.ValidateRemoteHostTarget(target) | |
| 540 | - | } | |
| 541 | - | ||
| 542 | - | func findRegistration(registrations []domain.RemoteRegistration, target string) (domain.RemoteRegistration, bool) { | |
| 543 | - | for _, record := range registrations { | |
| 544 | - | if record.Endpoint == target { | |
| 545 | - | return record, true | |
| 546 | - | } | |
| 547 | - | } | |
| 548 | - | return domain.RemoteRegistration{}, false | |
| 549 | - | } | |
| 550 | - | ||
| 551 | - | func removeRegistration(registrations []domain.RemoteRegistration, target string) ([]domain.RemoteRegistration, bool) { | |
| 552 | - | out := make([]domain.RemoteRegistration, 0, len(registrations)) | |
| 553 | - | changed := false | |
| 554 | - | for _, record := range registrations { | |
| 555 | - | if record.Endpoint == target { | |
| 556 | - | changed = true | |
| 557 | - | continue | |
| 558 | - | } | |
| 559 | - | out = append(out, record) | |
| 560 | - | } | |
| 561 | - | return out, changed | |
| 562 | - | } | |
| 563 | - | ||
| 564 | - | func sortedRegistrations(registrations map[string]domain.RemoteRegistration) []domain.RemoteRegistration { | |
| 565 | - | out := make([]domain.RemoteRegistration, 0, len(registrations)) | |
| 566 | - | for _, record := range registrations { | |
| 567 | - | out = append(out, record) | |
| 568 | - | } | |
| 569 | - | sort.Slice(out, func(i, j int) bool { return out[i].Endpoint < out[j].Endpoint }) | |
| 570 | - | return out | |
| 571 | - | } | |
| 572 | - | ||
| 573 | - | func syncDir(dir string) error { | |
| 574 | - | file, err := os.Open(dir) | |
| 575 | - | if err != nil { | |
| 576 | - | return err | |
| 577 | - | } | |
| 578 | - | return errors.Join(file.Sync(), file.Close()) | |
| 579 | - | } | |
D internal/adapters/remote/host_registry_test.go +0 -901
| @@ -1,901 +0,0 @@ | |||
| 1 | - | package remote | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "encoding/json" | |
| 5 | - | "os" | |
| 6 | - | "os/exec" | |
| 7 | - | "path/filepath" | |
| 8 | - | "slices" | |
| 9 | - | "sort" | |
| 10 | - | "strconv" | |
| 11 | - | "strings" | |
| 12 | - | "sync" | |
| 13 | - | "testing" | |
| 14 | - | ||
| 15 | - | "github.com/bnema/vev/internal/domain" | |
| 16 | - | ) | |
| 17 | - | ||
| 18 | - | func TestHostStorePath(t *testing.T) { | |
| 19 | - | stateDir := filepath.Join(t.TempDir(), "state") | |
| 20 | - | want := filepath.Join(stateDir, "hosts.json") | |
| 21 | - | if got := HostStorePath(stateDir); got != want { | |
| 22 | - | t.Fatalf("HostStorePath(%q) = %q, want %q", stateDir, got, want) | |
| 23 | - | } | |
| 24 | - | } | |
| 25 | - | ||
| 26 | - | func TestNewRemoteRegistrationSamplesUniqueIncarnations(t *testing.T) { | |
| 27 | - | first, err := newRemoteRegistration("user@arch") | |
| 28 | - | if err != nil { | |
| 29 | - | t.Fatalf("newRemoteRegistration: %v", err) | |
| 30 | - | } | |
| 31 | - | second, err := newRemoteRegistration("user@arch") | |
| 32 | - | if err != nil { | |
| 33 | - | t.Fatalf("newRemoteRegistration: %v", err) | |
| 34 | - | } | |
| 35 | - | if first.Incarnation == second.Incarnation { | |
| 36 | - | t.Fatal("incarnations must be unique per registration") | |
| 37 | - | } | |
| 38 | - | if _, err := newRemoteRegistration("not a host"); err == nil { | |
| 39 | - | t.Fatal("invalid endpoint must be rejected") | |
| 40 | - | } | |
| 41 | - | } | |
| 42 | - | ||
| 43 | - | func TestHostStoreCrossProcessHelper(t *testing.T) { | |
| 44 | - | if os.Getenv("VEV_HOST_STORE_HELPER") != "1" { | |
| 45 | - | t.Skip("helper process") | |
| 46 | - | } | |
| 47 | - | ||
| 48 | - | path := os.Getenv("VEV_HOST_STORE_PATH") | |
| 49 | - | hosts := strings.Split(os.Getenv("VEV_HOST_STORE_HOSTS"), ",") | |
| 50 | - | store := NewFileHostStore(path) | |
| 51 | - | for _, host := range hosts { | |
| 52 | - | if host == "" { | |
| 53 | - | continue | |
| 54 | - | } | |
| 55 | - | if err := store.Remember(host); err != nil { | |
| 56 | - | t.Fatalf("Remember(%q) error = %v", host, err) | |
| 57 | - | } | |
| 58 | - | } | |
| 59 | - | } | |
| 60 | - | ||
| 61 | - | func TestHostStoreCrossProcessConcurrentRemember(t *testing.T) { | |
| 62 | - | path := filepath.Join(t.TempDir(), "vev", "hosts.json") | |
| 63 | - | if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { | |
| 64 | - | t.Fatalf("mkdir: %v", err) | |
| 65 | - | } | |
| 66 | - | ||
| 67 | - | left := make([]string, 0, 40) | |
| 68 | - | right := make([]string, 0, 40) | |
| 69 | - | for i := 0; i < 40; i++ { | |
| 70 | - | left = append(left, "left-"+strconv.Itoa(i)) | |
| 71 | - | right = append(right, "right-"+strconv.Itoa(i)) | |
| 72 | - | } | |
| 73 | - | ||
| 74 | - | startHelper := func(hosts []string) *exec.Cmd { | |
| 75 | - | cmd := exec.Command(os.Args[0], "-test.run=^TestHostStoreCrossProcessHelper$") | |
| 76 | - | cmd.Env = append(os.Environ(), | |
| 77 | - | "VEV_HOST_STORE_HELPER=1", | |
| 78 | - | "VEV_HOST_STORE_PATH="+path, | |
| 79 | - | "VEV_HOST_STORE_HOSTS="+strings.Join(hosts, ","), | |
| 80 | - | ) | |
| 81 | - | cmd.Stderr = os.Stderr | |
| 82 | - | return cmd | |
| 83 | - | } | |
| 84 | - | ||
| 85 | - | leftCmd := startHelper(left) | |
| 86 | - | rightCmd := startHelper(right) | |
| 87 | - | if err := leftCmd.Start(); err != nil { | |
| 88 | - | t.Fatalf("start left helper: %v", err) | |
| 89 | - | } | |
| 90 | - | if err := rightCmd.Start(); err != nil { | |
| 91 | - | t.Fatalf("start right helper: %v", err) | |
| 92 | - | } | |
| 93 | - | if err := leftCmd.Wait(); err != nil { | |
| 94 | - | t.Fatalf("left helper: %v", err) | |
| 95 | - | } | |
| 96 | - | if err := rightCmd.Wait(); err != nil { | |
| 97 | - | t.Fatalf("right helper: %v", err) | |
| 98 | - | } | |
| 99 | - | ||
| 100 | - | _, learned, err := NewFileHostStore(path).Hosts() | |
| 101 | - | if err != nil { | |
| 102 | - | t.Fatalf("Hosts() error = %v", err) | |
| 103 | - | } | |
| 104 | - | want := append(append([]string{}, left...), right...) | |
| 105 | - | sort.Strings(want) | |
| 106 | - | if !slices.Equal(registrationEndpoints(learned), want) { | |
| 107 | - | t.Fatalf("learned len=%d want %d; missing merges under cross-process writers", len(learned), len(want)) | |
| 108 | - | } | |
| 109 | - | } | |
| 110 | - | ||
| 111 | - | func registrationEndpoints(registrations []domain.RemoteRegistration) []string { | |
| 112 | - | endpoints := make([]string, 0, len(registrations)) | |
| 113 | - | for _, record := range registrations { | |
| 114 | - | endpoints = append(endpoints, record.Endpoint) | |
| 115 | - | } | |
| 116 | - | return endpoints | |
| 117 | - | } | |
| 118 | - | ||
| 119 | - | func requireValidRegistrations(t *testing.T, registrations []domain.RemoteRegistration) { | |
| 120 | - | t.Helper() | |
| 121 | - | for _, record := range registrations { | |
| 122 | - | if err := record.Validate(); err != nil { | |
| 123 | - | t.Fatalf("registration %+v invalid: %v", record, err) | |
| 124 | - | } | |
| 125 | - | } | |
| 126 | - | } | |
| 127 | - | ||
| 128 | - | func TestHostStore(t *testing.T) { | |
| 129 | - | t.Parallel() | |
| 130 | - | ||
| 131 | - | storePath := func(t *testing.T) string { | |
| 132 | - | t.Helper() | |
| 133 | - | dir := filepath.Join(t.TempDir(), "vev") | |
| 134 | - | return filepath.Join(dir, "hosts.json") | |
| 135 | - | } | |
| 136 | - | ||
| 137 | - | writeHosts := func(t *testing.T, path string, raw []byte) { | |
| 138 | - | t.Helper() | |
| 139 | - | if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { | |
| 140 | - | t.Fatalf("mkdir: %v", err) | |
| 141 | - | } | |
| 142 | - | if err := os.WriteFile(path, raw, 0o600); err != nil { | |
| 143 | - | t.Fatalf("write hosts: %v", err) | |
| 144 | - | } | |
| 145 | - | } | |
| 146 | - | ||
| 147 | - | t.Run("missing file returns empty lists", func(t *testing.T) { | |
| 148 | - | t.Parallel() | |
| 149 | - | path := storePath(t) | |
| 150 | - | store := NewFileHostStore(path) | |
| 151 | - | ||
| 152 | - | pinned, learned, err := store.Hosts() | |
| 153 | - | if err != nil { | |
| 154 | - | t.Fatalf("Hosts() error = %v", err) | |
| 155 | - | } | |
| 156 | - | if len(pinned) != 0 || len(learned) != 0 { | |
| 157 | - | t.Fatalf("Hosts() = pinned %v learned %v, want empty", pinned, learned) | |
| 158 | - | } | |
| 159 | - | }) | |
| 160 | - | ||
| 161 | - | t.Run("pinned order preserved and learned sorted with private perms", func(t *testing.T) { | |
| 162 | - | t.Parallel() | |
| 163 | - | path := storePath(t) | |
| 164 | - | store := NewFileHostStore(path) | |
| 165 | - | ||
| 166 | - | if err := store.AddPinned("zebra"); err != nil { | |
| 167 | - | t.Fatalf("AddPinned(zebra) error = %v", err) | |
| 168 | - | } | |
| 169 | - | if err := store.AddPinned("arch"); err != nil { | |
| 170 | - | t.Fatalf("AddPinned(arch) error = %v", err) | |
| 171 | - | } | |
| 172 | - | if err := store.Remember("mule"); err != nil { | |
| 173 | - | t.Fatalf("Remember(mule) error = %v", err) | |
| 174 | - | } | |
| 175 | - | if err := store.Remember("beta"); err != nil { | |
| 176 | - | t.Fatalf("Remember(beta) error = %v", err) | |
| 177 | - | } | |
| 178 | - | ||
| 179 | - | pinned, learned, err := store.Hosts() | |
| 180 | - | if err != nil { | |
| 181 | - | t.Fatalf("Hosts() error = %v", err) | |
| 182 | - | } | |
| 183 | - | wantPinned := []string{"zebra", "arch"} | |
| 184 | - | wantLearned := []string{"beta", "mule"} | |
| 185 | - | if !slices.Equal(registrationEndpoints(pinned), wantPinned) { | |
| 186 | - | t.Fatalf("pinned = %v, want %v", pinned, wantPinned) | |
| 187 | - | } | |
| 188 | - | if !slices.Equal(registrationEndpoints(learned), wantLearned) { | |
| 189 | - | t.Fatalf("learned = %v, want %v", learned, wantLearned) | |
| 190 | - | } | |
| 191 | - | requireValidRegistrations(t, pinned) | |
| 192 | - | requireValidRegistrations(t, learned) | |
| 193 | - | ||
| 194 | - | info, err := os.Stat(path) | |
| 195 | - | if err != nil { | |
| 196 | - | t.Fatalf("stat store: %v", err) | |
| 197 | - | } | |
| 198 | - | if perm := info.Mode().Perm(); perm != 0o600 { | |
| 199 | - | t.Fatalf("store permissions = %04o, want 0600", perm) | |
| 200 | - | } | |
| 201 | - | dirInfo, err := os.Lstat(filepath.Dir(path)) | |
| 202 | - | if err != nil { | |
| 203 | - | t.Fatalf("stat parent: %v", err) | |
| 204 | - | } | |
| 205 | - | if perm := dirInfo.Mode().Perm(); perm != 0o700 { | |
| 206 | - | t.Fatalf("parent permissions = %04o, want 0700", perm) | |
| 207 | - | } | |
| 208 | - | ||
| 209 | - | raw, err := os.ReadFile(path) | |
| 210 | - | if err != nil { | |
| 211 | - | t.Fatalf("read store: %v", err) | |
| 212 | - | } | |
| 213 | - | var decoded hostsFile | |
| 214 | - | if err := json.Unmarshal(raw, &decoded); err != nil { | |
| 215 | - | t.Fatalf("decode store: %v", err) | |
| 216 | - | } | |
| 217 | - | if decoded.Version != 3 { | |
| 218 | - | t.Fatalf("version = %d, want 3", decoded.Version) | |
| 219 | - | } | |
| 220 | - | stored := make([]domain.RemoteRegistration, 0, len(decoded.Pinned)+len(decoded.Learned)) | |
| 221 | - | for _, record := range decoded.Pinned { | |
| 222 | - | registration, err := decodeRecord(record) | |
| 223 | - | if err != nil { | |
| 224 | - | t.Fatalf("decode stored pinned: %v", err) | |
| 225 | - | } | |
| 226 | - | stored = append(stored, registration) | |
| 227 | - | } | |
| 228 | - | for _, record := range decoded.Learned { | |
| 229 | - | registration, err := decodeRecord(record) | |
| 230 | - | if err != nil { | |
| 231 | - | t.Fatalf("decode stored learned: %v", err) | |
| 232 | - | } | |
| 233 | - | stored = append(stored, registration) | |
| 234 | - | } | |
| 235 | - | if !slices.Equal(registrationEndpoints(stored[:len(decoded.Pinned)]), wantPinned) { | |
| 236 | - | t.Fatalf("stored pinned = %v, want %v", decoded.Pinned, wantPinned) | |
| 237 | - | } | |
| 238 | - | if !slices.Equal(registrationEndpoints(stored[len(decoded.Pinned):]), wantLearned) { | |
| 239 | - | t.Fatalf("stored learned = %v, want %v", decoded.Learned, wantLearned) | |
| 240 | - | } | |
| 241 | - | requireValidRegistrations(t, stored) | |
| 242 | - | }) | |
| 243 | - | ||
| 244 | - | t.Run("overlap allowed across pinned and learned", func(t *testing.T) { | |
| 245 | - | t.Parallel() | |
| 246 | - | path := storePath(t) | |
| 247 | - | store := NewFileHostStore(path) | |
| 248 | - | ||
| 249 | - | if err := store.AddPinned("arch"); err != nil { | |
| 250 | - | t.Fatalf("AddPinned(arch) error = %v", err) | |
| 251 | - | } | |
| 252 | - | if err := store.Remember("arch"); err != nil { | |
| 253 | - | t.Fatalf("Remember(arch) error = %v", err) | |
| 254 | - | } | |
| 255 | - | pinned, learned, err := store.Hosts() | |
| 256 | - | if err != nil { | |
| 257 | - | t.Fatalf("Hosts() error = %v", err) | |
| 258 | - | } | |
| 259 | - | if !slices.Equal(registrationEndpoints(pinned), []string{"arch"}) { | |
| 260 | - | t.Fatalf("pinned = %v, want [arch]", pinned) | |
| 261 | - | } | |
| 262 | - | if !slices.Equal(registrationEndpoints(learned), []string{"arch"}) { | |
| 263 | - | t.Fatalf("learned = %v, want [arch]", learned) | |
| 264 | - | } | |
| 265 | - | // Overlap shares one registration: pinning a learned host and | |
| 266 | - | // remembering a pinned host retain the incarnation. | |
| 267 | - | if !pinned[0].Equal(learned[0]) { | |
| 268 | - | t.Fatalf("overlapping registrations differ: pinned %+v learned %+v", pinned[0], learned[0]) | |
| 269 | - | } | |
| 270 | - | }) | |
| 271 | - | ||
| 272 | - | t.Run("idempotent mutations and remove from both sources", func(t *testing.T) { | |
| 273 | - | t.Parallel() | |
| 274 | - | path := storePath(t) | |
| 275 | - | store := NewFileHostStore(path) | |
| 276 | - | ||
| 277 | - | if err := store.AddPinned("arch"); err != nil { | |
| 278 | - | t.Fatalf("AddPinned(arch) error = %v", err) | |
| 279 | - | } | |
| 280 | - | if err := store.AddPinned("arch"); err != nil { | |
| 281 | - | t.Fatalf("AddPinned(arch) again error = %v", err) | |
| 282 | - | } | |
| 283 | - | if err := store.Remember("mule"); err != nil { | |
| 284 | - | t.Fatalf("Remember(mule) error = %v", err) | |
| 285 | - | } | |
| 286 | - | if err := store.Remember("mule"); err != nil { | |
| 287 | - | t.Fatalf("Remember(mule) again error = %v", err) | |
| 288 | - | } | |
| 289 | - | if err := store.Remember("arch"); err != nil { | |
| 290 | - | t.Fatalf("Remember(arch) error = %v", err) | |
| 291 | - | } | |
| 292 | - | ||
| 293 | - | if err := store.RemovePinned("missing"); err != nil { | |
| 294 | - | t.Fatalf("RemovePinned(missing) error = %v", err) | |
| 295 | - | } | |
| 296 | - | if err := store.Forget("missing"); err != nil { | |
| 297 | - | t.Fatalf("Forget(missing) error = %v", err) | |
| 298 | - | } | |
| 299 | - | if deleted, err := store.Remove("missing"); err != nil { | |
| 300 | - | t.Fatalf("Remove(missing) error = %v", err) | |
| 301 | - | } else if deleted { | |
| 302 | - | t.Fatal("Remove(missing) deleted = true, want false") | |
| 303 | - | } | |
| 304 | - | ||
| 305 | - | if deleted, err := store.Remove("arch"); err != nil { | |
| 306 | - | t.Fatalf("Remove(arch) error = %v", err) | |
| 307 | - | } else if !deleted { | |
| 308 | - | t.Fatal("Remove(arch) deleted = false, want true") | |
| 309 | - | } | |
| 310 | - | pinned, learned, err := store.Hosts() | |
| 311 | - | if err != nil { | |
| 312 | - | t.Fatalf("Hosts() error = %v", err) | |
| 313 | - | } | |
| 314 | - | if len(pinned) != 0 { | |
| 315 | - | t.Fatalf("pinned after Remove(arch) = %v, want empty", pinned) | |
| 316 | - | } | |
| 317 | - | if !slices.Equal(registrationEndpoints(learned), []string{"mule"}) { | |
| 318 | - | t.Fatalf("learned after Remove(arch) = %v, want [mule]", learned) | |
| 319 | - | } | |
| 320 | - | ||
| 321 | - | if err := store.Forget("mule"); err != nil { | |
| 322 | - | t.Fatalf("Forget(mule) error = %v", err) | |
| 323 | - | } | |
| 324 | - | if err := store.Forget("mule"); err != nil { | |
| 325 | - | t.Fatalf("Forget(mule) again error = %v", err) | |
| 326 | - | } | |
| 327 | - | _, learned, err = store.Hosts() | |
| 328 | - | if err != nil { | |
| 329 | - | t.Fatalf("Hosts() after forget error = %v", err) | |
| 330 | - | } | |
| 331 | - | if len(learned) != 0 { | |
| 332 | - | t.Fatalf("learned = %v, want empty", learned) | |
| 333 | - | } | |
| 334 | - | }) | |
| 335 | - | ||
| 336 | - | t.Run("malformed file is preserved and fails closed", func(t *testing.T) { | |
| 337 | - | t.Parallel() | |
| 338 | - | path := storePath(t) | |
| 339 | - | malformed := []byte(`{"version":2,"pinned":`) | |
| 340 | - | writeHosts(t, path, malformed) | |
| 341 | - | ||
| 342 | - | store := NewFileHostStore(path) | |
| 343 | - | if _, _, err := store.Hosts(); err == nil { | |
| 344 | - | t.Fatal("Hosts() error = nil, want malformed error") | |
| 345 | - | } | |
| 346 | - | if err := store.AddPinned("arch"); err == nil { | |
| 347 | - | t.Fatal("AddPinned() error = nil, want malformed error") | |
| 348 | - | } | |
| 349 | - | if err := store.Remember("arch"); err == nil { | |
| 350 | - | t.Fatal("Remember() error = nil, want malformed error") | |
| 351 | - | } | |
| 352 | - | if _, err := store.Remove("arch"); err == nil { | |
| 353 | - | t.Fatal("Remove() error = nil, want malformed error") | |
| 354 | - | } | |
| 355 | - | ||
| 356 | - | raw, err := os.ReadFile(path) | |
| 357 | - | if err != nil { | |
| 358 | - | t.Fatalf("read preserved file: %v", err) | |
| 359 | - | } | |
| 360 | - | if string(raw) != string(malformed) { | |
| 361 | - | t.Fatalf("malformed file changed: got %q, want %q", raw, malformed) | |
| 362 | - | } | |
| 363 | - | }) | |
| 364 | - | ||
| 365 | - | t.Run("legacy version 1 migrates on read with backup", func(t *testing.T) { | |
| 366 | - | t.Parallel() | |
| 367 | - | cases := []struct { | |
| 368 | - | name string | |
| 369 | - | raw string | |
| 370 | - | wantLearned []string | |
| 371 | - | }{ | |
| 372 | - | {name: "empty", raw: `{"version":1,"hosts":[]}`, wantLearned: []string{}}, | |
| 373 | - | {name: "hosts", raw: `{"version":1,"hosts":["mule","arch"]}`, wantLearned: []string{"arch", "mule"}}, | |
| 374 | - | } | |
| 375 | - | for _, tc := range cases { | |
| 376 | - | t.Run(tc.name, func(t *testing.T) { | |
| 377 | - | t.Parallel() | |
| 378 | - | path := storePath(t) | |
| 379 | - | writeHosts(t, path, []byte(tc.raw)) | |
| 380 | - | store := NewFileHostStore(path) | |
| 381 | - | ||
| 382 | - | pinned, learned, err := store.Hosts() | |
| 383 | - | if err != nil { | |
| 384 | - | t.Fatalf("Hosts() error = %v", err) | |
| 385 | - | } | |
| 386 | - | if len(pinned) != 0 || !slices.Equal(registrationEndpoints(learned), tc.wantLearned) { | |
| 387 | - | t.Fatalf("Hosts() = pinned %v learned %v, want pinned empty learned %v", pinned, learned, tc.wantLearned) | |
| 388 | - | } | |
| 389 | - | requireValidRegistrations(t, learned) | |
| 390 | - | // The read migrated: the file is now version 3 and the | |
| 391 | - | // pre-migration bytes are preserved privately. | |
| 392 | - | raw, err := os.ReadFile(path) | |
| 393 | - | if err != nil { | |
| 394 | - | t.Fatalf("read migrated store: %v", err) | |
| 395 | - | } | |
| 396 | - | var decoded hostsFile | |
| 397 | - | if err := json.Unmarshal(raw, &decoded); err != nil { | |
| 398 | - | t.Fatalf("decode migrated store: %v", err) | |
| 399 | - | } | |
| 400 | - | if decoded.Version != hostsFileVersion { | |
| 401 | - | t.Fatalf("migrated version = %d, want %d", decoded.Version, hostsFileVersion) | |
| 402 | - | } | |
| 403 | - | backup, err := os.ReadFile(path + hostsBackupSuffix) | |
| 404 | - | if err != nil { | |
| 405 | - | t.Fatalf("read migration backup: %v", err) | |
| 406 | - | } | |
| 407 | - | if string(backup) != tc.raw { | |
| 408 | - | t.Fatalf("backup = %q, want pre-migration %q", backup, tc.raw) | |
| 409 | - | } | |
| 410 | - | info, err := os.Stat(path + hostsBackupSuffix) | |
| 411 | - | if err != nil { | |
| 412 | - | t.Fatalf("stat backup: %v", err) | |
| 413 | - | } | |
| 414 | - | if perm := info.Mode().Perm(); perm != 0o600 { | |
| 415 | - | t.Fatalf("backup permissions = %04o, want 0600", perm) | |
| 416 | - | } | |
| 417 | - | // A repeated load retains the migrated identity. | |
| 418 | - | againPinned, againLearned, err := store.Hosts() | |
| 419 | - | if err != nil { | |
| 420 | - | t.Fatalf("Hosts() again error = %v", err) | |
| 421 | - | } | |
| 422 | - | if len(againPinned) != 0 || len(againLearned) != len(learned) { | |
| 423 | - | t.Fatalf("Hosts() again = pinned %v learned %v", againPinned, againLearned) | |
| 424 | - | } | |
| 425 | - | for i := range learned { | |
| 426 | - | if !learned[i].Equal(againLearned[i]) { | |
| 427 | - | t.Fatalf("identity not retained: %+v vs %+v", learned[i], againLearned[i]) | |
| 428 | - | } | |
| 429 | - | } | |
| 430 | - | }) | |
| 431 | - | } | |
| 432 | - | }) | |
| 433 | - | ||
| 434 | - | t.Run("legacy version 2 migrates on read preserving order", func(t *testing.T) { | |
| 435 | - | t.Parallel() | |
| 436 | - | path := storePath(t) | |
| 437 | - | legacy := `{"version":2,"pinned":["zebra","arch"],"learned":["mule","beta"]}` | |
| 438 | - | writeHosts(t, path, []byte(legacy)) | |
| 439 | - | store := NewFileHostStore(path) | |
| 440 | - | ||
| 441 | - | pinned, learned, err := store.Hosts() | |
| 442 | - | if err != nil { | |
| 443 | - | t.Fatalf("Hosts() error = %v", err) | |
| 444 | - | } | |
| 445 | - | if !slices.Equal(registrationEndpoints(pinned), []string{"zebra", "arch"}) { | |
| 446 | - | t.Fatalf("pinned = %v, want [zebra arch]", pinned) | |
| 447 | - | } | |
| 448 | - | if !slices.Equal(registrationEndpoints(learned), []string{"beta", "mule"}) { | |
| 449 | - | t.Fatalf("learned = %v, want [beta mule]", learned) | |
| 450 | - | } | |
| 451 | - | requireValidRegistrations(t, pinned) | |
| 452 | - | requireValidRegistrations(t, learned) | |
| 453 | - | ||
| 454 | - | raw, err := os.ReadFile(path) | |
| 455 | - | if err != nil { | |
| 456 | - | t.Fatalf("read migrated store: %v", err) | |
| 457 | - | } | |
| 458 | - | var decoded hostsFile | |
| 459 | - | if err := json.Unmarshal(raw, &decoded); err != nil { | |
| 460 | - | t.Fatalf("decode migrated store: %v", err) | |
| 461 | - | } | |
| 462 | - | if decoded.Version != hostsFileVersion { | |
| 463 | - | t.Fatalf("migrated version = %d, want %d", decoded.Version, hostsFileVersion) | |
| 464 | - | } | |
| 465 | - | backup, err := os.ReadFile(path + hostsBackupSuffix) | |
| 466 | - | if err != nil { | |
| 467 | - | t.Fatalf("read migration backup: %v", err) | |
| 468 | - | } | |
| 469 | - | if string(backup) != legacy { | |
| 470 | - | t.Fatalf("backup = %q, want pre-migration %q", backup, legacy) | |
| 471 | - | } | |
| 472 | - | }) | |
| 473 | - | ||
| 474 | - | t.Run("legacy version 2 overlapping membership shares one registration", func(t *testing.T) { | |
| 475 | - | t.Parallel() | |
| 476 | - | path := storePath(t) | |
| 477 | - | legacy := `{"version":2,"pinned":["arch"],"learned":["arch","mule"]}` | |
| 478 | - | writeHosts(t, path, []byte(legacy)) | |
| 479 | - | store := NewFileHostStore(path) | |
| 480 | - | ||
| 481 | - | pinned, learned, err := store.Hosts() | |
| 482 | - | if err != nil { | |
| 483 | - | t.Fatalf("Hosts() error = %v", err) | |
| 484 | - | } | |
| 485 | - | requireValidRegistrations(t, pinned) | |
| 486 | - | requireValidRegistrations(t, learned) | |
| 487 | - | if len(pinned) != 1 || len(learned) != 2 { | |
| 488 | - | t.Fatalf("pinned = %v, learned = %v, want 1 pinned and 2 learned", pinned, learned) | |
| 489 | - | } | |
| 490 | - | if pinned[0] != learned[0] { | |
| 491 | - | t.Fatalf("overlapping endpoint has two incarnations: pinned %+v vs learned %+v", pinned[0], learned[0]) | |
| 492 | - | } | |
| 493 | - | // Removing only the pin must not surface a second identity | |
| 494 | - | // for an endpoint that was never fully removed. | |
| 495 | - | if err := store.RemovePinned("arch"); err != nil { | |
| 496 | - | t.Fatalf("RemovePinned(arch) error = %v", err) | |
| 497 | - | } | |
| 498 | - | _, againLearned, err := store.Hosts() | |
| 499 | - | if err != nil { | |
| 500 | - | t.Fatalf("Hosts() error = %v", err) | |
| 501 | - | } | |
| 502 | - | if len(againLearned) != 2 || againLearned[0] != pinned[0] { | |
| 503 | - | t.Fatalf("identity not retained after unpin: %+v vs %+v", againLearned, pinned) | |
| 504 | - | } | |
| 505 | - | }) | |
| 506 | - | ||
| 507 | - | t.Run("conflicting version 3 overlap fails closed", func(t *testing.T) { | |
| 508 | - | t.Parallel() | |
| 509 | - | path := storePath(t) | |
| 510 | - | conflict := `{"version":3,"pinned":[{"endpoint":"arch","incarnation":"01000000000000000000000000000000","generation":1}],"learned":[{"endpoint":"arch","incarnation":"02000000000000000000000000000000","generation":1}]}` | |
| 511 | - | writeHosts(t, path, []byte(conflict)) | |
| 512 | - | store := NewFileHostStore(path) | |
| 513 | - | ||
| 514 | - | if _, _, err := store.Hosts(); err == nil { | |
| 515 | - | t.Fatal("Hosts() error = nil, want conflicting identity rejection") | |
| 516 | - | } | |
| 517 | - | raw, err := os.ReadFile(path) | |
| 518 | - | if err != nil { | |
| 519 | - | t.Fatalf("read store: %v", err) | |
| 520 | - | } | |
| 521 | - | if string(raw) != conflict { | |
| 522 | - | t.Fatalf("rejected store changed: got %q, want %q", raw, conflict) | |
| 523 | - | } | |
| 524 | - | }) | |
| 525 | - | ||
| 526 | - | t.Run("remove and re-add creates a new incarnation", func(t *testing.T) { | |
| 527 | - | t.Parallel() | |
| 528 | - | path := storePath(t) | |
| 529 | - | store := NewFileHostStore(path) | |
| 530 | - | if err := store.Remember("arch"); err != nil { | |
| 531 | - | t.Fatalf("Remember(arch) error = %v", err) | |
| 532 | - | } | |
| 533 | - | _, learned, err := store.Hosts() | |
| 534 | - | if err != nil { | |
| 535 | - | t.Fatalf("Hosts() error = %v", err) | |
| 536 | - | } | |
| 537 | - | first := learned[0] | |
| 538 | - | ||
| 539 | - | if deleted, err := store.Remove("arch"); err != nil || !deleted { | |
| 540 | - | t.Fatalf("Remove(arch) = %t, %v", deleted, err) | |
| 541 | - | } | |
| 542 | - | if err := store.Remember("arch"); err != nil { | |
| 543 | - | t.Fatalf("Remember(arch) again error = %v", err) | |
| 544 | - | } | |
| 545 | - | _, relearned, err := store.Hosts() | |
| 546 | - | if err != nil { | |
| 547 | - | t.Fatalf("Hosts() again error = %v", err) | |
| 548 | - | } | |
| 549 | - | if len(relearned) != 1 { | |
| 550 | - | t.Fatalf("Hosts() again = %v, want one registration", relearned) | |
| 551 | - | } | |
| 552 | - | if relearned[0].Equal(first) { | |
| 553 | - | t.Fatalf("re-added registration %+v must not equal removed %+v", relearned[0], first) | |
| 554 | - | } | |
| 555 | - | if relearned[0].Incarnation == first.Incarnation { | |
| 556 | - | t.Fatalf("re-added incarnation reused after full removal") | |
| 557 | - | } | |
| 558 | - | }) | |
| 559 | - | ||
| 560 | - | t.Run("schema failures fail closed and preserve file", func(t *testing.T) { | |
| 561 | - | t.Parallel() | |
| 562 | - | cases := []struct { | |
| 563 | - | name string | |
| 564 | - | raw string | |
| 565 | - | wantError string | |
| 566 | - | }{ | |
| 567 | - | {name: "unsupported version", raw: `{"version":99,"pinned":[],"learned":[]}`, wantError: "unsupported hosts file version 99"}, | |
| 568 | - | {name: "null pinned", raw: `{"version":2,"pinned":null,"learned":[]}`, wantError: "malformed hosts file"}, | |
| 569 | - | {name: "null learned", raw: `{"version":2,"pinned":[],"learned":null}`, wantError: "malformed hosts file"}, | |
| 570 | - | {name: "duplicate pinned", raw: `{"version":2,"pinned":["arch","arch"],"learned":[]}`, wantError: "malformed hosts file"}, | |
| 571 | - | {name: "duplicate learned", raw: `{"version":2,"pinned":[],"learned":["mule","mule"]}`, wantError: "malformed hosts file"}, | |
| 572 | - | {name: "invalid target", raw: `{"version":2,"pinned":[" arch"],"learned":[]}`, wantError: "malformed hosts file"}, | |
| 573 | - | {name: "invalid utf8", raw: "{\"version\":2,\"pinned\":[\"\xff\"],\"learned\":[]}", wantError: "malformed hosts file"}, | |
| 574 | - | {name: "legacy missing hosts", raw: `{"version":1}`, wantError: "malformed hosts file: missing hosts"}, | |
| 575 | - | {name: "legacy null hosts", raw: `{"version":1,"hosts":null}`, wantError: "malformed hosts file: missing hosts"}, | |
| 576 | - | {name: "legacy duplicate hosts", raw: `{"version":1,"hosts":["arch","arch"]}`, wantError: "malformed hosts file: duplicate learned host"}, | |
| 577 | - | {name: "legacy invalid target", raw: `{"version":1,"hosts":[" arch"]}`, wantError: "malformed hosts file"}, | |
| 578 | - | {name: "legacy invalid utf8", raw: "{\"version\":1,\"hosts\":[\"\xff\"]}", wantError: "malformed hosts file"}, | |
| 579 | - | {name: "v3 string pinned is malformed", raw: `{"version":3,"pinned":["arch"],"learned":[]}`, wantError: "malformed hosts file"}, | |
| 580 | - | {name: "v3 missing incarnation", raw: `{"version":3,"pinned":[{"endpoint":"arch","generation":1}],"learned":[]}`, wantError: "malformed hosts file"}, | |
| 581 | - | {name: "v3 malformed incarnation", raw: `{"version":3,"pinned":[{"endpoint":"arch","incarnation":"zzzz","generation":1}],"learned":[]}`, wantError: "malformed hosts file"}, | |
| 582 | - | {name: "v3 short incarnation", raw: `{"version":3,"pinned":[{"endpoint":"arch","incarnation":"0123","generation":1}],"learned":[]}`, wantError: "malformed hosts file"}, | |
| 583 | - | {name: "v3 zero incarnation", raw: `{"version":3,"pinned":[{"endpoint":"arch","incarnation":"00000000000000000000000000000000","generation":1}],"learned":[]}`, wantError: "malformed hosts file"}, | |
| 584 | - | {name: "v3 zero generation", raw: `{"version":3,"pinned":[{"endpoint":"arch","incarnation":"01000000000000000000000000000000","generation":0}],"learned":[]}`, wantError: "malformed hosts file"}, | |
| 585 | - | {name: "v3 duplicate pinned", raw: `{"version":3,"pinned":[{"endpoint":"arch","incarnation":"01000000000000000000000000000000","generation":1},{"endpoint":"arch","incarnation":"02000000000000000000000000000000","generation":1}],"learned":[]}`, wantError: "duplicate pinned host"}, | |
| 586 | - | } | |
| 587 | - | for _, tc := range cases { | |
| 588 | - | t.Run(tc.name, func(t *testing.T) { | |
| 589 | - | t.Parallel() | |
| 590 | - | path := storePath(t) | |
| 591 | - | writeHosts(t, path, []byte(tc.raw)) | |
| 592 | - | ||
| 593 | - | store := NewFileHostStore(path) | |
| 594 | - | if _, _, err := store.Hosts(); err == nil { | |
| 595 | - | t.Fatal("Hosts() error = nil, want schema error") | |
| 596 | - | } else if !strings.Contains(err.Error(), tc.wantError) { | |
| 597 | - | t.Fatalf("Hosts() error = %q, want substring %q", err, tc.wantError) | |
| 598 | - | } | |
| 599 | - | if err := store.Remember("arch"); err == nil { | |
| 600 | - | t.Fatal("Remember() error = nil, want schema error") | |
| 601 | - | } | |
| 602 | - | raw, err := os.ReadFile(path) | |
| 603 | - | if err != nil { | |
| 604 | - | t.Fatalf("read preserved file: %v", err) | |
| 605 | - | } | |
| 606 | - | if string(raw) != tc.raw { | |
| 607 | - | t.Fatalf("file changed: got %q, want %q", raw, tc.raw) | |
| 608 | - | } | |
| 609 | - | }) | |
| 610 | - | } | |
| 611 | - | }) | |
| 612 | - | ||
| 613 | - | t.Run("reads v3 without rewriting until mutation", func(t *testing.T) { | |
| 614 | - | t.Parallel() | |
| 615 | - | path := storePath(t) | |
| 616 | - | seeded := `{"version":3,"pinned":[{"endpoint":"zebra","incarnation":"01000000000000000000000000000000","generation":1},{"endpoint":"arch","incarnation":"02000000000000000000000000000000","generation":1}],"learned":[{"endpoint":"mule","incarnation":"03000000000000000000000000000000","generation":1},{"endpoint":"beta","incarnation":"04000000000000000000000000000000","generation":1}]}` + "\n" | |
| 617 | - | writeHosts(t, path, []byte(seeded)) | |
| 618 | - | ||
| 619 | - | store := NewFileHostStore(path) | |
| 620 | - | pinned, learned, err := store.Hosts() | |
| 621 | - | if err != nil { | |
| 622 | - | t.Fatalf("Hosts() error = %v", err) | |
| 623 | - | } | |
| 624 | - | if !slices.Equal(registrationEndpoints(pinned), []string{"zebra", "arch"}) { | |
| 625 | - | t.Fatalf("pinned = %v, want [zebra arch]", pinned) | |
| 626 | - | } | |
| 627 | - | if !slices.Equal(registrationEndpoints(learned), []string{"beta", "mule"}) { | |
| 628 | - | t.Fatalf("learned = %v, want [beta mule]", learned) | |
| 629 | - | } | |
| 630 | - | // Identity survives the read; incarnations are stable. | |
| 631 | - | beforePinned, beforeLearned := pinned, learned | |
| 632 | - | raw, err := os.ReadFile(path) | |
| 633 | - | if err != nil { | |
| 634 | - | t.Fatalf("read store: %v", err) | |
| 635 | - | } | |
| 636 | - | if string(raw) != seeded { | |
| 637 | - | t.Fatalf("read-only Hosts rewrote file: got %q", raw) | |
| 638 | - | } | |
| 639 | - | ||
| 640 | - | if err := store.Remember("gamma"); err != nil { | |
| 641 | - | t.Fatalf("Remember(gamma) error = %v", err) | |
| 642 | - | } | |
| 643 | - | raw, err = os.ReadFile(path) | |
| 644 | - | if err != nil { | |
| 645 | - | t.Fatalf("read after mutation: %v", err) | |
| 646 | - | } | |
| 647 | - | var decoded hostsFile | |
| 648 | - | if err := json.Unmarshal(raw, &decoded); err != nil { | |
| 649 | - | t.Fatalf("decode: %v", err) | |
| 650 | - | } | |
| 651 | - | afterPinned, err := validatePinnedRecords(decoded.Pinned) | |
| 652 | - | if err != nil { | |
| 653 | - | t.Fatalf("decode pinned: %v", err) | |
| 654 | - | } | |
| 655 | - | afterLearned, err := validateLearnedRecords(decoded.Learned) | |
| 656 | - | if err != nil { | |
| 657 | - | t.Fatalf("decode learned: %v", err) | |
| 658 | - | } | |
| 659 | - | if !slices.Equal(registrationEndpoints(afterPinned), []string{"zebra", "arch"}) { | |
| 660 | - | t.Fatalf("pinned after mutation = %v", decoded.Pinned) | |
| 661 | - | } | |
| 662 | - | if !slices.Equal(registrationEndpoints(sortedRegistrations(afterLearned)), []string{"beta", "gamma", "mule"}) { | |
| 663 | - | t.Fatalf("learned after mutation = %v", decoded.Learned) | |
| 664 | - | } | |
| 665 | - | // Unrelated registrations keep their incarnations across mutation. | |
| 666 | - | for i := range beforePinned { | |
| 667 | - | if !beforePinned[i].Equal(afterPinned[i]) { | |
| 668 | - | t.Fatalf("pinned identity changed: %+v vs %+v", beforePinned[i], afterPinned[i]) | |
| 669 | - | } | |
| 670 | - | } | |
| 671 | - | for endpoint, before := range map[string]domain.RemoteRegistration{ | |
| 672 | - | "mule": beforeLearned[1], "beta": beforeLearned[0], | |
| 673 | - | } { | |
| 674 | - | after, ok := afterLearned[endpoint] | |
| 675 | - | if !ok || !before.Equal(after) { | |
| 676 | - | t.Fatalf("learned identity changed for %q: %+v vs %+v", endpoint, before, after) | |
| 677 | - | } | |
| 678 | - | } | |
| 679 | - | }) | |
| 680 | - | ||
| 681 | - | t.Run("rejects invalid targets", func(t *testing.T) { | |
| 682 | - | t.Parallel() | |
| 683 | - | path := storePath(t) | |
| 684 | - | store := NewFileHostStore(path) | |
| 685 | - | ||
| 686 | - | for _, target := range []string{"", " arch", "arch ", "ar ch", "arch\n", string([]byte{0xff})} { | |
| 687 | - | if err := store.AddPinned(target); err == nil { | |
| 688 | - | t.Fatalf("AddPinned(%q) error = nil, want validation error", target) | |
| 689 | - | } | |
| 690 | - | if err := store.Remember(target); err == nil { | |
| 691 | - | t.Fatalf("Remember(%q) error = nil, want validation error", target) | |
| 692 | - | } | |
| 693 | - | if _, err := store.Remove(target); err == nil { | |
| 694 | - | t.Fatalf("Remove(%q) error = nil, want validation error", target) | |
| 695 | - | } | |
| 696 | - | } | |
| 697 | - | }) | |
| 698 | - | ||
| 699 | - | t.Run("rejects symlink parent", func(t *testing.T) { | |
| 700 | - | t.Parallel() | |
| 701 | - | root := t.TempDir() | |
| 702 | - | realDir := filepath.Join(root, "real") | |
| 703 | - | if err := os.MkdirAll(realDir, 0o700); err != nil { | |
| 704 | - | t.Fatalf("mkdir real: %v", err) | |
| 705 | - | } | |
| 706 | - | linkDir := filepath.Join(root, "link") | |
| 707 | - | if err := os.Symlink(realDir, linkDir); err != nil { | |
| 708 | - | t.Fatalf("symlink parent: %v", err) | |
| 709 | - | } | |
| 710 | - | path := filepath.Join(linkDir, "hosts.json") | |
| 711 | - | store := NewFileHostStore(path) | |
| 712 | - | ||
| 713 | - | if err := store.Remember("arch"); err == nil { | |
| 714 | - | t.Fatal("Remember() error = nil, want symlink parent rejection") | |
| 715 | - | } | |
| 716 | - | }) | |
| 717 | - | ||
| 718 | - | t.Run("rejects non-private parent permissions", func(t *testing.T) { | |
| 719 | - | t.Parallel() | |
| 720 | - | path := storePath(t) | |
| 721 | - | if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { | |
| 722 | - | t.Fatalf("mkdir: %v", err) | |
| 723 | - | } | |
| 724 | - | store := NewFileHostStore(path) | |
| 725 | - | if err := store.Remember("arch"); err == nil { | |
| 726 | - | t.Fatal("Remember() error = nil, want permission rejection") | |
| 727 | - | } | |
| 728 | - | }) | |
| 729 | - | ||
| 730 | - | t.Run("atomic replacement leaves no temp files", func(t *testing.T) { | |
| 731 | - | t.Parallel() | |
| 732 | - | path := storePath(t) | |
| 733 | - | store := NewFileHostStore(path) | |
| 734 | - | if err := store.AddPinned("arch"); err != nil { | |
| 735 | - | t.Fatalf("AddPinned() error = %v", err) | |
| 736 | - | } | |
| 737 | - | if err := store.Remember("mule"); err != nil { | |
| 738 | - | t.Fatalf("Remember() error = %v", err) | |
| 739 | - | } | |
| 740 | - | ||
| 741 | - | entries, err := os.ReadDir(filepath.Dir(path)) | |
| 742 | - | if err != nil { | |
| 743 | - | t.Fatalf("readdir: %v", err) | |
| 744 | - | } | |
| 745 | - | base := filepath.Base(path) | |
| 746 | - | for _, entry := range entries { | |
| 747 | - | name := entry.Name() | |
| 748 | - | if name == base || strings.HasSuffix(name, ".lock") { | |
| 749 | - | continue | |
| 750 | - | } | |
| 751 | - | if strings.Contains(name, ".tmp") { | |
| 752 | - | t.Fatalf("leftover temp file %q", name) | |
| 753 | - | } | |
| 754 | - | t.Fatalf("unexpected leftover entry %q", name) | |
| 755 | - | } | |
| 756 | - | }) | |
| 757 | - | ||
| 758 | - | t.Run("hardens existing file and lock permissions to 0600", func(t *testing.T) { | |
| 759 | - | t.Parallel() | |
| 760 | - | path := storePath(t) | |
| 761 | - | dir := filepath.Dir(path) | |
| 762 | - | if err := os.MkdirAll(dir, 0o700); err != nil { | |
| 763 | - | t.Fatalf("mkdir: %v", err) | |
| 764 | - | } | |
| 765 | - | if err := os.WriteFile(path, []byte(`{"version":2,"pinned":[],"learned":[]}`), 0o644); err != nil { | |
| 766 | - | t.Fatalf("seed hosts: %v", err) | |
| 767 | - | } | |
| 768 | - | lockPath := path + ".lock" | |
| 769 | - | if err := os.WriteFile(lockPath, nil, 0o644); err != nil { | |
| 770 | - | t.Fatalf("seed lock: %v", err) | |
| 771 | - | } | |
| 772 | - | info, err := os.Stat(lockPath) | |
| 773 | - | if err != nil { | |
| 774 | - | t.Fatalf("stat seeded lock: %v", err) | |
| 775 | - | } | |
| 776 | - | if perm := info.Mode().Perm(); perm != 0o644 { | |
| 777 | - | t.Fatalf("seeded lock permissions = %04o, want 0644", perm) | |
| 778 | - | } | |
| 779 | - | ||
| 780 | - | store := NewFileHostStore(path) | |
| 781 | - | if err := store.Remember("arch"); err != nil { | |
| 782 | - | t.Fatalf("Remember() error = %v", err) | |
| 783 | - | } | |
| 784 | - | ||
| 785 | - | info, err = os.Stat(path) | |
| 786 | - | if err != nil { | |
| 787 | - | t.Fatalf("stat hosts: %v", err) | |
| 788 | - | } | |
| 789 | - | if perm := info.Mode().Perm(); perm != 0o600 { | |
| 790 | - | t.Fatalf("hosts permissions = %04o, want 0600", perm) | |
| 791 | - | } | |
| 792 | - | info, err = os.Stat(lockPath) | |
| 793 | - | if err != nil { | |
| 794 | - | t.Fatalf("stat lock: %v", err) | |
| 795 | - | } | |
| 796 | - | if perm := info.Mode().Perm(); perm != 0o600 { | |
| 797 | - | t.Fatalf("lock permissions = %04o, want 0600", perm) | |
| 798 | - | } | |
| 799 | - | }) | |
| 800 | - | ||
| 801 | - | t.Run("failed mutation rolls back without partial write", func(t *testing.T) { | |
| 802 | - | t.Parallel() | |
| 803 | - | path := storePath(t) | |
| 804 | - | store := NewFileHostStore(path) | |
| 805 | - | if err := store.AddPinned("arch"); err != nil { | |
| 806 | - | t.Fatalf("AddPinned() error = %v", err) | |
| 807 | - | } | |
| 808 | - | if err := store.Remember("mule"); err != nil { | |
| 809 | - | t.Fatalf("Remember() error = %v", err) | |
| 810 | - | } | |
| 811 | - | before, err := os.ReadFile(path) | |
| 812 | - | if err != nil { | |
| 813 | - | t.Fatalf("read before: %v", err) | |
| 814 | - | } | |
| 815 | - | ||
| 816 | - | // Replace with malformed content while holding no store lock; next mutation must fail closed. | |
| 817 | - | malformed := []byte(`{"version":2,"pinned":`) | |
| 818 | - | if err := os.WriteFile(path, malformed, 0o600); err != nil { | |
| 819 | - | t.Fatalf("inject malformed: %v", err) | |
| 820 | - | } | |
| 821 | - | if err := store.AddPinned("zebra"); err == nil { | |
| 822 | - | t.Fatal("AddPinned() error = nil, want malformed error") | |
| 823 | - | } | |
| 824 | - | after, err := os.ReadFile(path) | |
| 825 | - | if err != nil { | |
| 826 | - | t.Fatalf("read after failed mutation: %v", err) | |
| 827 | - | } | |
| 828 | - | if string(after) != string(malformed) { | |
| 829 | - | t.Fatalf("failed mutation changed file: got %q want %q (previous good was %q)", after, malformed, before) | |
| 830 | - | } | |
| 831 | - | }) | |
| 832 | - | ||
| 833 | - | t.Run("concurrent independent stores lose no hosts", func(t *testing.T) { | |
| 834 | - | t.Parallel() | |
| 835 | - | path := storePath(t) | |
| 836 | - | ||
| 837 | - | left := make([]string, 0, 40) | |
| 838 | - | right := make([]string, 0, 40) | |
| 839 | - | for i := 0; i < 40; i++ { | |
| 840 | - | left = append(left, "left-"+strconv.Itoa(i)) | |
| 841 | - | right = append(right, "right-"+strconv.Itoa(i)) | |
| 842 | - | } | |
| 843 | - | ||
| 844 | - | start := make(chan struct{}) | |
| 845 | - | var wg sync.WaitGroup | |
| 846 | - | errs := make(chan error, 2) | |
| 847 | - | ||
| 848 | - | wg.Add(2) | |
| 849 | - | go func() { | |
| 850 | - | defer wg.Done() | |
| 851 | - | store := NewFileHostStore(path) | |
| 852 | - | <-start | |
| 853 | - | for _, host := range left { | |
| 854 | - | if err := store.Remember(host); err != nil { | |
| 855 | - | errs <- err | |
| 856 | - | return | |
| 857 | - | } | |
| 858 | - | } | |
| 859 | - | }() | |
| 860 | - | go func() { | |
| 861 | - | defer wg.Done() | |
| 862 | - | store := NewFileHostStore(path) | |
| 863 | - | <-start | |
| 864 | - | for _, host := range right { | |
| 865 | - | if err := store.AddPinned(host); err != nil { | |
| 866 | - | errs <- err | |
| 867 | - | return | |
| 868 | - | } | |
| 869 | - | } | |
| 870 | - | }() | |
| 871 | - | ||
| 872 | - | close(start) | |
| 873 | - | wg.Wait() | |
| 874 | - | close(errs) | |
| 875 | - | for err := range errs { | |
| 876 | - | t.Fatalf("concurrent mutation error = %v", err) | |
| 877 | - | } | |
| 878 | - | ||
| 879 | - | pinned, learned, err := NewFileHostStore(path).Hosts() | |
| 880 | - | if err != nil { | |
| 881 | - | t.Fatalf("Hosts() error = %v", err) | |
| 882 | - | } | |
| 883 | - | wantLearned := append([]string{}, left...) | |
| 884 | - | sort.Strings(wantLearned) | |
| 885 | - | if !slices.Equal(registrationEndpoints(learned), wantLearned) { | |
| 886 | - | t.Fatalf("learned len=%d want %d; missing merges under dual writers", len(learned), len(wantLearned)) | |
| 887 | - | } | |
| 888 | - | if len(pinned) != len(right) { | |
| 889 | - | t.Fatalf("pinned len=%d want %d", len(pinned), len(right)) | |
| 890 | - | } | |
| 891 | - | pinnedSet := make(map[string]struct{}, len(pinned)) | |
| 892 | - | for _, record := range pinned { | |
| 893 | - | pinnedSet[record.Endpoint] = struct{}{} | |
| 894 | - | } | |
| 895 | - | for _, host := range right { | |
| 896 | - | if _, ok := pinnedSet[host]; !ok { | |
| 897 | - | t.Fatalf("pinned missing %q", host) | |
| 898 | - | } | |
| 899 | - | } | |
| 900 | - | }) | |
| 901 | - | } | |
D internal/adapters/remote/memory_catalog_cache.go +0 -85
| @@ -1,85 +0,0 @@ | |||
| 1 | - | package remote | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "sync" | |
| 5 | - | ||
| 6 | - | appports "github.com/bnema/vev/internal/ports" | |
| 7 | - | "github.com/bnema/vev/internal/protocol/catalogue" | |
| 8 | - | ) | |
| 9 | - | ||
| 10 | - | // SeededCatalogCache is a runner-local remote catalogue cache: it starts from | |
| 11 | - | // the durable cache's entries and only ever writes in memory. A client monitor | |
| 12 | - | // uses it so its own observations can never race, truncate, or overwrite the | |
| 13 | - | // daemon's whole-directory snapshot file. | |
| 14 | - | type SeededCatalogCache struct { | |
| 15 | - | seed appports.RemoteCatalogCache | |
| 16 | - | ||
| 17 | - | mu sync.Mutex | |
| 18 | - | loaded bool | |
| 19 | - | entries []catalogue.RemoteCatalogCacheEntry | |
| 20 | - | loadErr error | |
| 21 | - | } | |
| 22 | - | ||
| 23 | - | var _ appports.RemoteCatalogCache = (*SeededCatalogCache)(nil) | |
| 24 | - | ||
| 25 | - | // NewSeededCatalogCache wraps the durable cache. Construction performs no I/O: | |
| 26 | - | // the seed is read on the first Load. | |
| 27 | - | func NewSeededCatalogCache(seed appports.RemoteCatalogCache) *SeededCatalogCache { | |
| 28 | - | return &SeededCatalogCache{seed: seed} | |
| 29 | - | } | |
| 30 | - | ||
| 31 | - | // Load returns the in-memory entries, reading the durable seed once. | |
| 32 | - | func (c *SeededCatalogCache) Load() ([]catalogue.RemoteCatalogCacheEntry, error) { | |
| 33 | - | if c == nil { | |
| 34 | - | return nil, nil | |
| 35 | - | } | |
| 36 | - | c.mu.Lock() | |
| 37 | - | defer c.mu.Unlock() | |
| 38 | - | if !c.loaded { | |
| 39 | - | c.loaded = true | |
| 40 | - | if c.seed != nil { | |
| 41 | - | entries, err := c.seed.Load() | |
| 42 | - | if err != nil { | |
| 43 | - | // A failed seed read is reported once and never retried: the | |
| 44 | - | // client monitor must not hammer a broken cache file. | |
| 45 | - | c.loadErr = err | |
| 46 | - | } else { | |
| 47 | - | c.entries = cloneCatalogEntries(entries) | |
| 48 | - | } | |
| 49 | - | } | |
| 50 | - | } | |
| 51 | - | if c.loadErr != nil { | |
| 52 | - | return nil, c.loadErr | |
| 53 | - | } | |
| 54 | - | return cloneCatalogEntries(c.entries), nil | |
| 55 | - | } | |
| 56 | - | ||
| 57 | - | // Store replaces the in-memory entries. Nothing reaches the durable file. | |
| 58 | - | func (c *SeededCatalogCache) Store(entries []catalogue.RemoteCatalogCacheEntry) error { | |
| 59 | - | if c == nil { | |
| 60 | - | return nil | |
| 61 | - | } | |
| 62 | - | c.mu.Lock() | |
| 63 | - | defer c.mu.Unlock() | |
| 64 | - | c.loaded = true | |
| 65 | - | c.entries = cloneCatalogEntries(entries) | |
| 66 | - | return nil | |
| 67 | - | } | |
| 68 | - | ||
| 69 | - | // cloneCatalogEntries returns a defensive copy of the entries, including the | |
| 70 | - | // nested session and tab slices the monitor publishes from. | |
| 71 | - | func cloneCatalogEntries(entries []catalogue.RemoteCatalogCacheEntry) []catalogue.RemoteCatalogCacheEntry { | |
| 72 | - | if entries == nil { | |
| 73 | - | return nil | |
| 74 | - | } | |
| 75 | - | out := make([]catalogue.RemoteCatalogCacheEntry, len(entries)) | |
| 76 | - | for i, entry := range entries { | |
| 77 | - | out[i] = entry | |
| 78 | - | out[i].Sessions = make([]catalogue.RemoteCatalogSession, len(entry.Sessions)) | |
| 79 | - | for j, session := range entry.Sessions { | |
| 80 | - | out[i].Sessions[j] = session | |
| 81 | - | out[i].Sessions[j].Tabs = append([]catalogue.RemoteCatalogTab(nil), session.Tabs...) | |
| 82 | - | } | |
| 83 | - | } | |
| 84 | - | return out | |
| 85 | - | } | |
D internal/adapters/remote/preview.go +0 -109
| @@ -1,109 +0,0 @@ | |||
| 1 | - | package remote | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "context" | |
| 5 | - | "encoding/base64" | |
| 6 | - | "errors" | |
| 7 | - | "fmt" | |
| 8 | - | "os/exec" | |
| 9 | - | "time" | |
| 10 | - | ||
| 11 | - | "github.com/bnema/vev/internal/adapters/sessionwire" | |
| 12 | - | "github.com/bnema/vev/internal/adapters/sshstdio" | |
| 13 | - | "github.com/bnema/vev/internal/domain" | |
| 14 | - | "github.com/bnema/vev/internal/ports" | |
| 15 | - | "github.com/bnema/vev/internal/protocol" | |
| 16 | - | ) | |
| 17 | - | ||
| 18 | - | const ( | |
| 19 | - | remotePreviewCommandTimeout = 5 * time.Second | |
| 20 | - | remotePreviewSSHConnectTimeout = 5 * time.Second | |
| 21 | - | remotePreviewWaitDelay = 500 * time.Millisecond | |
| 22 | - | remotePreviewMaxDiagnostic = 4 << 10 | |
| 23 | - | ) | |
| 24 | - | ||
| 25 | - | var ( | |
| 26 | - | errRemotePreviewSSH = errors.New("remote preview: ssh command failed") | |
| 27 | - | errRemotePreviewTooLarge = errors.New("remote preview: output exceeds size limit") | |
| 28 | - | errRemotePreviewIdentity = errors.New("remote preview: response identity mismatch") | |
| 29 | - | ) | |
| 30 | - | ||
| 31 | - | // PreviewClient invokes the hidden remote preview command over SSH. The | |
| 32 | - | // command receives only a base64url request and stdout is decoded as one | |
| 33 | - | // bounded binary response. | |
| 34 | - | type PreviewClient struct { | |
| 35 | - | command func(ctx context.Context, name string, args ...string) *exec.Cmd | |
| 36 | - | timeout time.Duration | |
| 37 | - | } | |
| 38 | - | ||
| 39 | - | var _ ports.RemotePreviewClient = (*PreviewClient)(nil) | |
| 40 | - | ||
| 41 | - | func NewPreviewClient() ports.RemotePreviewClient { | |
| 42 | - | return &PreviewClient{command: exec.CommandContext} | |
| 43 | - | } | |
| 44 | - | ||
| 45 | - | func (c *PreviewClient) Preview(ctx context.Context, target domain.RemoteSessionTarget, width, height uint16) (protocol.RemotePreview, error) { | |
| 46 | - | request := protocol.RemotePreviewRequest{Version: protocol.RemotePreviewSchemaVersion, Target: target, Width: width, Height: height} | |
| 47 | - | payload, err := sessionwire.EncodeClientMessage(request) | |
| 48 | - | if err != nil { | |
| 49 | - | return protocol.RemotePreview{}, protocol.ErrInvalidRemotePreviewRequest | |
| 50 | - | } | |
| 51 | - | runCtx, cancel := context.WithTimeout(ctx, c.previewTimeout()) | |
| 52 | - | defer cancel() | |
| 53 | - | command := c.command | |
| 54 | - | if command == nil { | |
| 55 | - | command = exec.CommandContext | |
| 56 | - | } | |
| 57 | - | encoded := base64.RawURLEncoding.EncodeToString(payload) | |
| 58 | - | // Preview is background observation, not attach: it must never grab a TTY | |
| 59 | - | // or mutate the user's known-hosts trust state, so it uses the same | |
| 60 | - | // non-interactive argv builder as catalogue checks (batch mode, strict | |
| 61 | - | // host-key checking, bounded connect, no TTY, refused host-key updates). | |
| 62 | - | spec := sshstdio.BuildCommandForObservation(target.Endpoint, remotePreviewSSHConnectTimeout, "vev", "_remote-preview", encoded) | |
| 63 | - | cmd := command(runCtx, spec.Path, spec.Args...) | |
| 64 | - | stdout := boundedBuffer{limit: protocol.RemotePreviewMaxBytes} | |
| 65 | - | stderr := boundedBuffer{limit: remotePreviewMaxDiagnostic} | |
| 66 | - | cmd.Stdout = &stdout | |
| 67 | - | cmd.Stderr = &stderr | |
| 68 | - | cmd.WaitDelay = remotePreviewWaitDelay | |
| 69 | - | if err := cmd.Run(); err != nil { | |
| 70 | - | if ctxErr := runCtx.Err(); ctxErr != nil { | |
| 71 | - | if parentErr := ctx.Err(); parentErr != nil { | |
| 72 | - | return protocol.RemotePreview{}, parentErr | |
| 73 | - | } | |
| 74 | - | return protocol.RemotePreview{}, protocol.ErrRemotePreviewTimeout | |
| 75 | - | } | |
| 76 | - | if stdout.overflow || stderr.overflow { | |
| 77 | - | return protocol.RemotePreview{}, errRemotePreviewTooLarge | |
| 78 | - | } | |
| 79 | - | if diagnostic := sanitizeCatalogDiagnostic(string(stderr.Bytes())); diagnostic != "" { | |
| 80 | - | return protocol.RemotePreview{}, fmt.Errorf("%w: %v: %s", errRemotePreviewSSH, err, diagnostic) | |
| 81 | - | } | |
| 82 | - | return protocol.RemotePreview{}, fmt.Errorf("%w: %v", errRemotePreviewSSH, err) | |
| 83 | - | } | |
| 84 | - | if stdout.overflow || stderr.overflow { | |
| 85 | - | return protocol.RemotePreview{}, errRemotePreviewTooLarge | |
| 86 | - | } | |
| 87 | - | previewMessage, err := sessionwire.DecodeServerEnvelope(stdout.Bytes()) | |
| 88 | - | if err != nil { | |
| 89 | - | return protocol.RemotePreview{}, err | |
| 90 | - | } | |
| 91 | - | preview, ok := previewMessage.(protocol.RemotePreview) | |
| 92 | - | if !ok { | |
| 93 | - | return protocol.RemotePreview{}, protocol.ErrInvalidRemotePreview | |
| 94 | - | } | |
| 95 | - | if preview.Status != protocol.RemotePreviewOK { | |
| 96 | - | return protocol.RemotePreview{}, fmt.Errorf("remote preview unavailable: status %d", preview.Status) | |
| 97 | - | } | |
| 98 | - | if preview.LifecycleID != target.LifecycleID || preview.TabID != target.LiveTabID { | |
| 99 | - | return protocol.RemotePreview{}, errRemotePreviewIdentity | |
| 100 | - | } | |
| 101 | - | return preview, nil | |
| 102 | - | } | |
| 103 | - | ||
| 104 | - | func (c *PreviewClient) previewTimeout() time.Duration { | |
| 105 | - | if c != nil && c.timeout > 0 { | |
| 106 | - | return c.timeout | |
| 107 | - | } | |
| 108 | - | return remotePreviewCommandTimeout | |
| 109 | - | } | |
D internal/adapters/remote/preview_test.go +0 -181
| @@ -1,181 +0,0 @@ | |||
| 1 | - | package remote | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "context" | |
| 5 | - | "encoding/base64" | |
| 6 | - | "errors" | |
| 7 | - | "fmt" | |
| 8 | - | "os/exec" | |
| 9 | - | "strings" | |
| 10 | - | "testing" | |
| 11 | - | "time" | |
| 12 | - | ||
| 13 | - | "github.com/stretchr/testify/require" | |
| 14 | - | ||
| 15 | - | renderer "github.com/bnema/vev-vt" | |
| 16 | - | "github.com/bnema/vev/internal/adapters/sessionwire" | |
| 17 | - | "github.com/bnema/vev/internal/adapters/sshstdio" | |
| 18 | - | "github.com/bnema/vev/internal/domain" | |
| 19 | - | "github.com/bnema/vev/internal/protocol" | |
| 20 | - | ) | |
| 21 | - | ||
| 22 | - | func previewClientTargetForTest() domain.RemoteSessionTarget { | |
| 23 | - | var lifecycle domain.SessionLifecycleID | |
| 24 | - | lifecycle[0] = 1 | |
| 25 | - | return domain.RemoteSessionTarget{Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle, SessionName: "work", LiveTabID: "tab-1"} | |
| 26 | - | } | |
| 27 | - | ||
| 28 | - | func TestRemotePreviewClientBuildsExactSSHCommandAndDecodesResponse(t *testing.T) { | |
| 29 | - | var lifecycle domain.SessionLifecycleID | |
| 30 | - | lifecycle[0] = 9 | |
| 31 | - | target := domain.RemoteSessionTarget{ | |
| 32 | - | Endpoint: "build@mule", DisplayOrigin: "mule", LifecycleID: lifecycle, | |
| 33 | - | SessionName: "work", LiveTabID: "tab-1", | |
| 34 | - | } | |
| 35 | - | want := protocol.RemotePreview{ | |
| 36 | - | Version: protocol.RemotePreviewSchemaVersion, Status: protocol.RemotePreviewOK, | |
| 37 | - | LifecycleID: lifecycle, TabID: target.LiveTabID, Revision: 7, Width: 1, Height: 1, | |
| 38 | - | Cells: []renderer.Cell{{Rune: 'x', Style: renderer.DefaultStyle()}}, | |
| 39 | - | } | |
| 40 | - | payload, err := sessionwire.EncodeServerMessage(want) | |
| 41 | - | require.NoError(t, err) | |
| 42 | - | require.NotNil(t, payload) | |
| 43 | - | ||
| 44 | - | var gotPath string | |
| 45 | - | var gotArgs []string | |
| 46 | - | wantCommand := sshstdio.BuildCommandForObservation(target.Endpoint, remotePreviewSSHConnectTimeout, "vev", "_remote-preview", "request") | |
| 47 | - | client := &PreviewClient{command: func(ctx context.Context, name string, args ...string) *exec.Cmd { | |
| 48 | - | gotPath = name | |
| 49 | - | gotArgs = append([]string(nil), args...) | |
| 50 | - | if len(args) != len(wantCommand.Args) { | |
| 51 | - | t.Fatalf("ssh args = %#v, want observation argv %#v", args, wantCommand.Args) | |
| 52 | - | } | |
| 53 | - | remote := args[len(args)-1] | |
| 54 | - | words := strings.Split(remote, " ") | |
| 55 | - | if len(words) != 3 { | |
| 56 | - | t.Fatalf("remote command = %q, want three shell words", remote) | |
| 57 | - | } | |
| 58 | - | encoded := strings.Trim(words[2], "'") | |
| 59 | - | requestPayload, err := base64.RawURLEncoding.DecodeString(encoded) | |
| 60 | - | if err != nil { | |
| 61 | - | t.Fatalf("decode request: %v", err) | |
| 62 | - | } | |
| 63 | - | requestMessage, err := sessionwire.DecodeClientEnvelope(requestPayload) | |
| 64 | - | if err != nil { | |
| 65 | - | t.Fatalf("decode request payload: %v", err) | |
| 66 | - | } | |
| 67 | - | request, ok := requestMessage.(protocol.RemotePreviewRequest) | |
| 68 | - | if !ok { | |
| 69 | - | t.Fatalf("request payload = %T, want protocol.RemotePreviewRequest", requestMessage) | |
| 70 | - | } | |
| 71 | - | if request.Target != target || request.Width != 1 || request.Height != 1 { | |
| 72 | - | t.Fatalf("request = %#v, want target and 1x1 dimensions", request) | |
| 73 | - | } | |
| 74 | - | return stdoutCmd(ctx, string(payload)) | |
| 75 | - | }} | |
| 76 | - | ||
| 77 | - | got, err := client.Preview(context.Background(), target, 1, 1) | |
| 78 | - | require.NoError(t, err) | |
| 79 | - | require.Equal(t, want, got) | |
| 80 | - | require.Equal(t, wantCommand.Path, gotPath) | |
| 81 | - | require.Equal(t, wantCommand.Args[:len(wantCommand.Args)-1], gotArgs[:len(gotArgs)-1], | |
| 82 | - | "preview must use the observation-safe ssh argv") | |
| 83 | - | require.Contains(t, gotArgs[len(gotArgs)-1], "'_remote-preview'") | |
| 84 | - | require.Contains(t, gotArgs, "-T") | |
| 85 | - | require.Contains(t, gotArgs, "UpdateHostKeys=no") | |
| 86 | - | } | |
| 87 | - | ||
| 88 | - | func TestRemotePreviewClientRejectsMismatchedResponseIdentity(t *testing.T) { | |
| 89 | - | target := previewClientTargetForTest() | |
| 90 | - | var otherLifecycle domain.SessionLifecycleID | |
| 91 | - | otherLifecycle[0] = 2 | |
| 92 | - | valid := protocol.RemotePreview{ | |
| 93 | - | Version: protocol.RemotePreviewSchemaVersion, Status: protocol.RemotePreviewOK, | |
| 94 | - | LifecycleID: target.LifecycleID, TabID: target.LiveTabID, Revision: 1, Width: 1, Height: 1, | |
| 95 | - | Cells: []renderer.Cell{{Rune: 'x', Style: renderer.DefaultStyle()}}, | |
| 96 | - | } | |
| 97 | - | tests := []struct { | |
| 98 | - | name string | |
| 99 | - | mutate func(*protocol.RemotePreview) | |
| 100 | - | }{ | |
| 101 | - | {name: "lifecycle ID", mutate: func(preview *protocol.RemotePreview) { preview.LifecycleID = otherLifecycle }}, | |
| 102 | - | {name: "tab ID", mutate: func(preview *protocol.RemotePreview) { preview.TabID = "tab-2" }}, | |
| 103 | - | } | |
| 104 | - | for _, tt := range tests { | |
| 105 | - | t.Run(tt.name, func(t *testing.T) { | |
| 106 | - | preview := valid | |
| 107 | - | tt.mutate(&preview) | |
| 108 | - | payload, err := sessionwire.EncodeServerMessage(preview) | |
| 109 | - | require.NoError(t, err) | |
| 110 | - | require.NotNil(t, payload) | |
| 111 | - | ||
| 112 | - | client := &PreviewClient{command: func(ctx context.Context, _ string, _ ...string) *exec.Cmd { | |
| 113 | - | return stdoutCmd(ctx, string(payload)) | |
| 114 | - | }} | |
| 115 | - | _, err = client.Preview(context.Background(), target, 1, 1) | |
| 116 | - | require.ErrorIs(t, err, errRemotePreviewIdentity) | |
| 117 | - | }) | |
| 118 | - | } | |
| 119 | - | } | |
| 120 | - | ||
| 121 | - | func TestRemotePreviewClientRejectsMalformedResponse(t *testing.T) { | |
| 122 | - | client := &PreviewClient{command: func(ctx context.Context, _ string, _ ...string) *exec.Cmd { | |
| 123 | - | return stdoutCmd(ctx, "not-a-preview") | |
| 124 | - | }} | |
| 125 | - | var lifecycle domain.SessionLifecycleID | |
| 126 | - | lifecycle[0] = 1 | |
| 127 | - | target := domain.RemoteSessionTarget{Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle, SessionName: "work", LiveTabID: "tab-1"} | |
| 128 | - | _, err := client.Preview(context.Background(), target, 1, 1) | |
| 129 | - | require.Error(t, err) | |
| 130 | - | } | |
| 131 | - | ||
| 132 | - | func TestRemotePreviewClientRejectsOversizedOutput(t *testing.T) { | |
| 133 | - | client := &PreviewClient{command: func(ctx context.Context, _ string, _ ...string) *exec.Cmd { | |
| 134 | - | return exec.CommandContext(ctx, "sh", "-c", fmt.Sprintf("head -c %d /dev/zero", protocol.RemotePreviewMaxBytes+1)) | |
| 135 | - | }} | |
| 136 | - | target := previewClientTargetForTest() | |
| 137 | - | _, err := client.Preview(context.Background(), target, 1, 1) | |
| 138 | - | require.ErrorIs(t, err, errRemotePreviewTooLarge) | |
| 139 | - | } | |
| 140 | - | ||
| 141 | - | func TestRemotePreviewClientReturnsStderrDiagnostic(t *testing.T) { | |
| 142 | - | client := &PreviewClient{command: func(ctx context.Context, _ string, _ ...string) *exec.Cmd { | |
| 143 | - | return exec.CommandContext(ctx, "sh", "-c", "printf 'connection refused' >&2; exit 1") | |
| 144 | - | }} | |
| 145 | - | target := previewClientTargetForTest() | |
| 146 | - | _, err := client.Preview(context.Background(), target, 1, 1) | |
| 147 | - | require.ErrorIs(t, err, errRemotePreviewSSH) | |
| 148 | - | require.Contains(t, err.Error(), "connection refused") | |
| 149 | - | } | |
| 150 | - | ||
| 151 | - | func TestRemotePreviewClientReturnsInternalTimeoutSentinel(t *testing.T) { | |
| 152 | - | client := &PreviewClient{ | |
| 153 | - | timeout: 20 * time.Millisecond, | |
| 154 | - | command: func(ctx context.Context, _ string, _ ...string) *exec.Cmd { | |
| 155 | - | return exec.CommandContext(ctx, "sleep", "10") | |
| 156 | - | }, | |
| 157 | - | } | |
| 158 | - | _, err := client.Preview(context.Background(), previewClientTargetForTest(), 1, 1) | |
| 159 | - | require.ErrorIs(t, err, protocol.ErrRemotePreviewTimeout) | |
| 160 | - | } | |
| 161 | - | ||
| 162 | - | func TestRemotePreviewClientReturnsCallerCancellation(t *testing.T) { | |
| 163 | - | started := make(chan struct{}) | |
| 164 | - | client := &PreviewClient{command: func(ctx context.Context, _ string, _ ...string) *exec.Cmd { | |
| 165 | - | close(started) | |
| 166 | - | return exec.CommandContext(ctx, "sleep", "10") | |
| 167 | - | }} | |
| 168 | - | target := previewClientTargetForTest() | |
| 169 | - | ctx, cancel := context.WithCancel(context.Background()) | |
| 170 | - | defer cancel() | |
| 171 | - | errCh := make(chan error, 1) | |
| 172 | - | go func() { | |
| 173 | - | _, err := client.Preview(ctx, target, 1, 1) | |
| 174 | - | errCh <- err | |
| 175 | - | }() | |
| 176 | - | <-started | |
| 177 | - | cancel() | |
| 178 | - | err := <-errCh | |
| 179 | - | require.ErrorIs(t, err, context.Canceled) | |
| 180 | - | require.False(t, errors.Is(err, errRemotePreviewSSH)) | |
| 181 | - | } | |
D internal/adapters/remote/quic_dialer.go +0 -177
| @@ -1,177 +0,0 @@ | |||
| 1 | - | package remote | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "context" | |
| 5 | - | "fmt" | |
| 6 | - | "io" | |
| 7 | - | "log/slog" | |
| 8 | - | "net" | |
| 9 | - | "os/exec" | |
| 10 | - | "strconv" | |
| 11 | - | "strings" | |
| 12 | - | "time" | |
| 13 | - | ||
| 14 | - | "github.com/bnema/vev/internal/adapters/quic" | |
| 15 | - | "github.com/bnema/vev/internal/adapters/sshstdio" | |
| 16 | - | "github.com/bnema/vev/internal/ports" | |
| 17 | - | "github.com/bnema/vev/internal/protocol/wire" | |
| 18 | - | ) | |
| 19 | - | ||
| 20 | - | // quicDialer is the default remote carriage: SSH launches one ephemeral | |
| 21 | - | // QUIC bootstrap server, the readiness line binds the pinned dial, and a | |
| 22 | - | // single authenticated stream carries the session. Command construction | |
| 23 | - | // is structured (no shell interpolation); secret-bearing values never | |
| 24 | - | // reach logs or error text. | |
| 25 | - | type quicDialer struct { | |
| 26 | - | target string | |
| 27 | - | log *slog.Logger | |
| 28 | - | observer ports.SerializedRuntimeObserver | |
| 29 | - | launch *EndpointLaunch | |
| 30 | - | // startBootstrap builds the SSH bootstrap child. It is a field | |
| 31 | - | // (not a method) so tests can stub the child without a network. | |
| 32 | - | startBootstrap func(ctx context.Context, target string, launch *EndpointLaunch, stderr *limitedBootstrapBuffer) bootstrapProcess | |
| 33 | - | } | |
| 34 | - | ||
| 35 | - | var _ wire.Dialer = quicDialer{} | |
| 36 | - | ||
| 37 | - | func (d quicDialer) Dial(ctx context.Context) (wire.Transport, error) { | |
| 38 | - | if err := ctx.Err(); err != nil { | |
| 39 | - | return nil, err | |
| 40 | - | } | |
| 41 | - | bootstrapTimeout := 30 * time.Second | |
| 42 | - | bootstrapCtx, bootstrapCancel := context.WithTimeout(ctx, bootstrapTimeout) | |
| 43 | - | defer bootstrapCancel() | |
| 44 | - | ||
| 45 | - | var stderr limitedBootstrapBuffer | |
| 46 | - | start := d.startBootstrap | |
| 47 | - | if start == nil { | |
| 48 | - | start = startSSHBootstrap | |
| 49 | - | } | |
| 50 | - | proc := start(bootstrapCtx, d.target, d.launch, &stderr) | |
| 51 | - | stdout, err := proc.StdoutPipe() | |
| 52 | - | if err != nil { | |
| 53 | - | return nil, quicUnavailable("bootstrap stdout", err) | |
| 54 | - | } | |
| 55 | - | if err := proc.Start(); err != nil { | |
| 56 | - | _ = stdout.Close() | |
| 57 | - | return nil, quicUnavailable("start bootstrap", err) | |
| 58 | - | } | |
| 59 | - | // Deterministic process cleanup: kill on every path except a | |
| 60 | - | // transferred success. The bootstrap prints one readiness line | |
| 61 | - | // from a detached proxy and exits, so Wait returns promptly and | |
| 62 | - | // the session outlives this SSH child. | |
| 63 | - | cleanup := true | |
| 64 | - | waited := false | |
| 65 | - | defer func() { | |
| 66 | - | if cleanup { | |
| 67 | - | _ = proc.Kill() | |
| 68 | - | } | |
| 69 | - | if !waited { | |
| 70 | - | _ = proc.Wait() | |
| 71 | - | } | |
| 72 | - | _ = stdout.Close() | |
| 73 | - | }() | |
| 74 | - | ||
| 75 | - | readiness, err := quic.ReadReadiness(bootstrapCtx, stdout) | |
| 76 | - | if err != nil { | |
| 77 | - | _ = proc.Kill() | |
| 78 | - | _ = proc.Wait() | |
| 79 | - | waited = true | |
| 80 | - | cleanup = false | |
| 81 | - | return nil, quicUnavailable("read bootstrap readiness", err) | |
| 82 | - | } | |
| 83 | - | // The bootstrap SSH channel served its purpose: the detached proxy | |
| 84 | - | // owns the session lifetime, so reap the short-lived child before | |
| 85 | - | // dialing. Do not kill it: SIGKILL would race the proxy detach. | |
| 86 | - | if err := proc.Wait(); err != nil { | |
| 87 | - | waited = true | |
| 88 | - | cleanup = false | |
| 89 | - | return nil, quicUnavailable("wait bootstrap", err) | |
| 90 | - | } | |
| 91 | - | waited = true | |
| 92 | - | cleanup = false | |
| 93 | - | ||
| 94 | - | // Compose the dial address from the SSH target host plus the | |
| 95 | - | // published port: the readiness record is host-independent and a | |
| 96 | - | // remote loopback address is never dialed. | |
| 97 | - | addr, err := resolveQUICPeer(d.target, readiness.Port) | |
| 98 | - | if err != nil { | |
| 99 | - | return nil, quicUnavailable("resolve QUIC peer", err) | |
| 100 | - | } | |
| 101 | - | transport, err := quic.Dial(bootstrapCtx, addr, readiness, quic.Config{}, 15*time.Second, quic.WithRuntimeObserver(d.observer)) | |
| 102 | - | if err != nil { | |
| 103 | - | return nil, quicUnavailable("dial bootstrap QUIC", err) | |
| 104 | - | } | |
| 105 | - | return transport, nil | |
| 106 | - | } | |
| 107 | - | ||
| 108 | - | // resolveQUICPeer composes the dial address from the SSH target host | |
| 109 | - | // and the bootstrap-published port. user@ prefixes, explicit ports, | |
| 110 | - | // and bracketed IPv6 literals are stripped to the bare host before | |
| 111 | - | // the published port is attached. | |
| 112 | - | func resolveQUICPeer(target string, port int) (string, error) { | |
| 113 | - | if port <= 0 || port > 65535 { | |
| 114 | - | return "", fmt.Errorf("invalid bootstrap port %d", port) | |
| 115 | - | } | |
| 116 | - | host := target | |
| 117 | - | if at := strings.LastIndexByte(host, '@'); at >= 0 { | |
| 118 | - | host = host[at+1:] | |
| 119 | - | } | |
| 120 | - | if h, _, err := net.SplitHostPort(host); err == nil { | |
| 121 | - | host = h | |
| 122 | - | } | |
| 123 | - | host = strings.TrimPrefix(strings.TrimSuffix(host, "]"), "[") | |
| 124 | - | if host == "" { | |
| 125 | - | return "", fmt.Errorf("empty bootstrap host") | |
| 126 | - | } | |
| 127 | - | return net.JoinHostPort(host, strconv.Itoa(port)), nil | |
| 128 | - | } | |
| 129 | - | ||
| 130 | - | // startSSHBootstrap launches the real `_quic-bootstrap` SSH child. | |
| 131 | - | func startSSHBootstrap(ctx context.Context, target string, launch *EndpointLaunch, stderr *limitedBootstrapBuffer) bootstrapProcess { | |
| 132 | - | if launch != nil { | |
| 133 | - | spec := sshstdio.BuildCommandForRemoteLaunchWithRoot(target, launch.Root, launch.OwnerToken, launch.Binary, launch.Environment, "_quic-bootstrap") | |
| 134 | - | cmd := exec.CommandContext(ctx, spec.Path, spec.Args...) | |
| 135 | - | cmd.Stderr = stderr | |
| 136 | - | return execBootstrapProcess{cmd: cmd} | |
| 137 | - | } | |
| 138 | - | spec := sshstdio.BuildCommandForMode(target, "_quic-bootstrap", "") | |
| 139 | - | cmd := exec.CommandContext(ctx, spec.Path, spec.Args...) | |
| 140 | - | cmd.Stderr = stderr | |
| 141 | - | return execBootstrapProcess{cmd: cmd} | |
| 142 | - | } | |
| 143 | - | ||
| 144 | - | // bootstrapProcess is the stub seam for the SSH bootstrap child. | |
| 145 | - | type bootstrapProcess interface { | |
| 146 | - | StdoutPipe() (io.ReadCloser, error) | |
| 147 | - | Start() error | |
| 148 | - | Kill() error | |
| 149 | - | Wait() error | |
| 150 | - | } | |
| 151 | - | ||
| 152 | - | type execBootstrapProcess struct{ cmd *exec.Cmd } | |
| 153 | - | ||
| 154 | - | func (p execBootstrapProcess) StdoutPipe() (io.ReadCloser, error) { return p.cmd.StdoutPipe() } | |
| 155 | - | ||
| 156 | - | func (p execBootstrapProcess) Start() error { return p.cmd.Start() } | |
| 157 | - | func (p execBootstrapProcess) Kill() error { return p.cmd.Process.Kill() } | |
| 158 | - | func (p execBootstrapProcess) Wait() error { return p.cmd.Wait() } | |
| 159 | - | ||
| 160 | - | // limitedBootstrapBuffer bounds captured stderr; secret-bearing values | |
| 161 | - | // are never logged (the bootstrap prints readiness on stdout only). | |
| 162 | - | type limitedBootstrapBuffer struct { | |
| 163 | - | buf []byte | |
| 164 | - | } | |
| 165 | - | ||
| 166 | - | func (b *limitedBootstrapBuffer) Write(p []byte) (int, error) { | |
| 167 | - | const maxBootstrapStderr = 4 << 10 | |
| 168 | - | if len(b.buf) < maxBootstrapStderr { | |
| 169 | - | keep := min(len(p), maxBootstrapStderr-len(b.buf)) | |
| 170 | - | b.buf = append(b.buf, p[:keep]...) | |
| 171 | - | } | |
| 172 | - | return len(p), nil | |
| 173 | - | } | |
| 174 | - | ||
| 175 | - | func quicUnavailable(action string, err error) error { | |
| 176 | - | return fmt.Errorf("remote QUIC transport unavailable: %s: %w", action, err) | |
| 177 | - | } | |
D internal/adapters/remote/quic_dialer_test.go +0 -183
| @@ -1,183 +0,0 @@ | |||
| 1 | - | package remote | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "bytes" | |
| 5 | - | "context" | |
| 6 | - | "errors" | |
| 7 | - | "io" | |
| 8 | - | "sync" | |
| 9 | - | "testing" | |
| 10 | - | "time" | |
| 11 | - | ||
| 12 | - | "github.com/bnema/vev/internal/adapters/quic" | |
| 13 | - | "github.com/bnema/vev/internal/protocol/wire" | |
| 14 | - | ) | |
| 15 | - | ||
| 16 | - | // stubBootstrapProcess replays one readiness line then exits, modelling | |
| 17 | - | // the detached `_quic-bootstrap` child: the session outlives the child, | |
| 18 | - | // so Wait succeeds promptly after the line is consumed. | |
| 19 | - | type stubBootstrapProcess struct { | |
| 20 | - | line []byte | |
| 21 | - | waitErr error | |
| 22 | - | ||
| 23 | - | killOnce sync.Once | |
| 24 | - | killed bool | |
| 25 | - | waitOnce sync.Once | |
| 26 | - | waited chan struct{} | |
| 27 | - | } | |
| 28 | - | ||
| 29 | - | func newStubBootstrapProcess(line []byte, waitErr error) *stubBootstrapProcess { | |
| 30 | - | return &stubBootstrapProcess{line: line, waitErr: waitErr, waited: make(chan struct{})} | |
| 31 | - | } | |
| 32 | - | ||
| 33 | - | func (p *stubBootstrapProcess) StdoutPipe() (io.ReadCloser, error) { | |
| 34 | - | return io.NopCloser(bytes.NewReader(append(append([]byte(nil), p.line...), '\n'))), nil | |
| 35 | - | } | |
| 36 | - | ||
| 37 | - | func (p *stubBootstrapProcess) Start() error { return nil } | |
| 38 | - | ||
| 39 | - | func (p *stubBootstrapProcess) Kill() error { | |
| 40 | - | p.killOnce.Do(func() { p.killed = true }) | |
| 41 | - | return nil | |
| 42 | - | } | |
| 43 | - | ||
| 44 | - | func (p *stubBootstrapProcess) Wait() error { | |
| 45 | - | p.waitOnce.Do(func() { close(p.waited) }) | |
| 46 | - | <-p.waited | |
| 47 | - | return p.waitErr | |
| 48 | - | } | |
| 49 | - | ||
| 50 | - | // stubQuicDialer runs quicDialer.Dial against the stub bootstrap child. | |
| 51 | - | func stubQuicDialerForTest(target string, stub *stubBootstrapProcess) quicDialer { | |
| 52 | - | return quicDialer{ | |
| 53 | - | target: target, | |
| 54 | - | startBootstrap: func(context.Context, string, *EndpointLaunch, *limitedBootstrapBuffer) bootstrapProcess { | |
| 55 | - | return stub | |
| 56 | - | }, | |
| 57 | - | } | |
| 58 | - | } | |
| 59 | - | ||
| 60 | - | func TestQUICDialerComposesHostPortAndAuthenticates(t *testing.T) { | |
| 61 | - | server, readiness, err := quic.NewServer() | |
| 62 | - | if err != nil { | |
| 63 | - | t.Fatal(err) | |
| 64 | - | } | |
| 65 | - | defer func() { _ = server.Close() }() | |
| 66 | - | raw, err := quic.EncodeReadiness(readiness) | |
| 67 | - | if err != nil { | |
| 68 | - | t.Fatal(err) | |
| 69 | - | } | |
| 70 | - | ||
| 71 | - | accepted := make(chan wire.Transport, 1) | |
| 72 | - | go func() { | |
| 73 | - | transport, err := server.Accept(context.Background()) | |
| 74 | - | if err == nil { | |
| 75 | - | accepted <- transport | |
| 76 | - | } | |
| 77 | - | }() | |
| 78 | - | ||
| 79 | - | // The SSH target carries a user prefix; the dial must resolve the | |
| 80 | - | // bare host and attach the published port, never a remote loopback. | |
| 81 | - | stub := newStubBootstrapProcess(raw, nil) | |
| 82 | - | dialer := stubQuicDialerForTest("user@127.0.0.1", stub) | |
| 83 | - | observer := &recordingObserver{} | |
| 84 | - | dialer.observer = observer | |
| 85 | - | ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) | |
| 86 | - | defer cancel() | |
| 87 | - | transport, err := dialer.Dial(ctx) | |
| 88 | - | if err != nil { | |
| 89 | - | t.Fatalf("Dial() error = %v", err) | |
| 90 | - | } | |
| 91 | - | defer func() { _ = transport.Close() }() | |
| 92 | - | ||
| 93 | - | select { | |
| 94 | - | case peer := <-accepted: | |
| 95 | - | defer func() { _ = peer.Close() }() | |
| 96 | - | payload := []byte("dialer session stays exact") | |
| 97 | - | if err := transport.Send(wire.Envelope{Payload: payload}); err != nil { | |
| 98 | - | t.Fatalf("Send() error = %v", err) | |
| 99 | - | } | |
| 100 | - | got, err := peer.Recv() | |
| 101 | - | if err != nil { | |
| 102 | - | t.Fatalf("Recv() error = %v", err) | |
| 103 | - | } | |
| 104 | - | if !bytes.Equal(got.Payload, payload) { | |
| 105 | - | t.Fatalf("payload = %q, want %q", got.Payload, payload) | |
| 106 | - | } | |
| 107 | - | case <-ctx.Done(): | |
| 108 | - | t.Fatal("bootstrap accept timed out") | |
| 109 | - | } | |
| 110 | - | // The short-lived bootstrap child was reaped, never killed: the | |
| 111 | - | // session outlives the SSH channel by design. | |
| 112 | - | if stub.killed { | |
| 113 | - | t.Fatal("bootstrap child was killed; the detached proxy must outlive it") | |
| 114 | - | } | |
| 115 | - | if observer.count() == 0 { | |
| 116 | - | t.Fatal("runtime observer was not propagated to the QUIC transport") | |
| 117 | - | } | |
| 118 | - | select { | |
| 119 | - | case <-stub.waited: | |
| 120 | - | default: | |
| 121 | - | t.Fatal("bootstrap child was not reaped") | |
| 122 | - | } | |
| 123 | - | } | |
| 124 | - | ||
| 125 | - | func TestQUICDialerRejectsFailedBootstrapWait(t *testing.T) { | |
| 126 | - | server, readiness, err := quic.NewServer() | |
| 127 | - | if err != nil { | |
| 128 | - | t.Fatal(err) | |
| 129 | - | } | |
| 130 | - | defer func() { _ = server.Close() }() | |
| 131 | - | raw, err := quic.EncodeReadiness(readiness) | |
| 132 | - | if err != nil { | |
| 133 | - | t.Fatal(err) | |
| 134 | - | } | |
| 135 | - | dialer := stubQuicDialerForTest("127.0.0.1", newStubBootstrapProcess(raw, errors.New("exit status 1"))) | |
| 136 | - | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) | |
| 137 | - | defer cancel() | |
| 138 | - | transport, err := dialer.Dial(ctx) | |
| 139 | - | if err == nil { | |
| 140 | - | _ = transport.Close() | |
| 141 | - | t.Fatal("Dial() error = nil, want bootstrap wait failure") | |
| 142 | - | } | |
| 143 | - | if transport != nil { | |
| 144 | - | t.Fatalf("Dial() transport = %T, want nil", transport) | |
| 145 | - | } | |
| 146 | - | } | |
| 147 | - | ||
| 148 | - | func TestResolveQUICPeer(t *testing.T) { | |
| 149 | - | tests := []struct { | |
| 150 | - | name string | |
| 151 | - | target string | |
| 152 | - | port int | |
| 153 | - | want string | |
| 154 | - | wantErr bool | |
| 155 | - | }{ | |
| 156 | - | {name: "bare host", target: "remote.example", port: 4433, want: "remote.example:4433"}, | |
| 157 | - | {name: "user prefix", target: "user@remote.example", port: 4433, want: "remote.example:4433"}, | |
| 158 | - | {name: "loopback", target: "127.0.0.1", port: 4433, want: "127.0.0.1:4433"}, | |
| 159 | - | {name: "explicit port replaced", target: "remote.example:22", port: 4433, want: "remote.example:4433"}, | |
| 160 | - | {name: "bracketed ipv6", target: "[::1]", port: 4433, want: "[::1]:4433"}, | |
| 161 | - | {name: "ipv6 with port", target: "[::1]:22", port: 4433, want: "[::1]:4433"}, | |
| 162 | - | {name: "zero port", target: "remote.example", port: 0, wantErr: true}, | |
| 163 | - | {name: "port overflow", target: "remote.example", port: 65536, wantErr: true}, | |
| 164 | - | {name: "empty host", target: "user@", port: 4433, wantErr: true}, | |
| 165 | - | } | |
| 166 | - | for _, tt := range tests { | |
| 167 | - | t.Run(tt.name, func(t *testing.T) { | |
| 168 | - | got, err := resolveQUICPeer(tt.target, tt.port) | |
| 169 | - | if tt.wantErr { | |
| 170 | - | if err == nil { | |
| 171 | - | t.Fatalf("resolveQUICPeer() error = nil, want failure") | |
| 172 | - | } | |
| 173 | - | return | |
| 174 | - | } | |
| 175 | - | if err != nil { | |
| 176 | - | t.Fatalf("resolveQUICPeer() error = %v", err) | |
| 177 | - | } | |
| 178 | - | if got != tt.want { | |
| 179 | - | t.Fatalf("resolveQUICPeer() = %q, want %q", got, tt.want) | |
| 180 | - | } | |
| 181 | - | }) | |
| 182 | - | } | |
| 183 | - | } | |
D internal/adapters/remote/remote_factory.go +0 -93
| @@ -1,93 +0,0 @@ | |||
| 1 | - | package remote | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "context" | |
| 5 | - | "errors" | |
| 6 | - | "fmt" | |
| 7 | - | "log/slog" | |
| 8 | - | ||
| 9 | - | "github.com/bnema/vev/internal/adapters/sshstdio" | |
| 10 | - | "github.com/bnema/vev/internal/ports" | |
| 11 | - | "github.com/bnema/vev/internal/protocol/wire" | |
| 12 | - | ) | |
| 13 | - | ||
| 14 | - | // TransportMode selects a concrete remote carriage after app validation. | |
| 15 | - | type TransportMode string | |
| 16 | - | ||
| 17 | - | const ( | |
| 18 | - | // TransportQUIC is the default remote carriage: one authenticated | |
| 19 | - | // QUIC stream per connection (P6.3). TransportStdio selects the | |
| 20 | - | // explicit SSH-only carriage. | |
| 21 | - | TransportQUIC TransportMode = "quic" | |
| 22 | - | TransportStdio TransportMode = "stdio" | |
| 23 | - | ) | |
| 24 | - | ||
| 25 | - | // DialerFactory constructs target-specific raw carriage dialers. | |
| 26 | - | type DialerFactory struct { | |
| 27 | - | observer ports.SerializedRuntimeObserver | |
| 28 | - | } | |
| 29 | - | ||
| 30 | - | // EndpointLaunch contains the absolute remote executable and complete child | |
| 31 | - | // environment selected by an explicit UI-driver launch configuration. | |
| 32 | - | type EndpointLaunch struct { | |
| 33 | - | Binary string | |
| 34 | - | Root string | |
| 35 | - | OwnerToken string | |
| 36 | - | Environment []string | |
| 37 | - | } | |
| 38 | - | ||
| 39 | - | func NewDialerFactory() DialerFactory { return DialerFactory{} } | |
| 40 | - | ||
| 41 | - | func NewDialerFactoryWithRuntimeObserver(observer ports.SerializedRuntimeObserver) DialerFactory { | |
| 42 | - | return DialerFactory{observer: observer} | |
| 43 | - | } | |
| 44 | - | ||
| 45 | - | func (f DialerFactory) DialerForRemote(target, session string, mode TransportMode, log *slog.Logger) (wire.Dialer, error) { | |
| 46 | - | return f.dialerForRemote(target, session, mode, log, nil) | |
| 47 | - | } | |
| 48 | - | ||
| 49 | - | // DialerForRemoteWithLaunch selects the same normal carriage while using the | |
| 50 | - | // explicit endpoint executable/environment for remote child startup. | |
| 51 | - | func (f DialerFactory) DialerForRemoteWithLaunch(target, session string, mode TransportMode, log *slog.Logger, launch *EndpointLaunch) (wire.Dialer, error) { | |
| 52 | - | if launch != nil && (launch.Binary == "" || launch.Root == "" || launch.OwnerToken == "") { | |
| 53 | - | return nil, errors.New("vev: isolated remote launch requires a binary, root, and owner") | |
| 54 | - | } | |
| 55 | - | return f.dialerForRemote(target, session, mode, log, launch) | |
| 56 | - | } | |
| 57 | - | ||
| 58 | - | func (f DialerFactory) dialerForRemote(target, session string, mode TransportMode, log *slog.Logger, launch *EndpointLaunch) (wire.Dialer, error) { | |
| 59 | - | switch mode { | |
| 60 | - | case TransportQUIC: | |
| 61 | - | if log != nil { | |
| 62 | - | log.Info("remote transport selected", "mode", mode, "target", target, "session", session) | |
| 63 | - | } | |
| 64 | - | return quicDialer{target: target, log: log, observer: f.observer, launch: launch}, nil | |
| 65 | - | case TransportStdio: | |
| 66 | - | if log != nil { | |
| 67 | - | log.Info("remote transport selected", "mode", mode, "target", target, "session", session) | |
| 68 | - | } | |
| 69 | - | var selected *EndpointLaunch | |
| 70 | - | if launch != nil { | |
| 71 | - | copyLaunch := *launch | |
| 72 | - | copyLaunch.Environment = append([]string(nil), launch.Environment...) | |
| 73 | - | selected = ©Launch | |
| 74 | - | } | |
| 75 | - | return stdioDialer{target: target, log: log, observer: f.observer, launch: selected}, nil | |
| 76 | - | default: | |
| 77 | - | return nil, fmt.Errorf("vev: unsupported remote transport %q", mode) | |
| 78 | - | } | |
| 79 | - | } | |
| 80 | - | ||
| 81 | - | type stdioDialer struct { | |
| 82 | - | target string | |
| 83 | - | log *slog.Logger | |
| 84 | - | observer ports.SerializedRuntimeObserver | |
| 85 | - | launch *EndpointLaunch | |
| 86 | - | } | |
| 87 | - | ||
| 88 | - | func (d stdioDialer) Dial(ctx context.Context) (wire.Transport, error) { | |
| 89 | - | if d.launch == nil { | |
| 90 | - | return sshstdio.DialContextWithRuntimeObserver(ctx, d.target, "", d.log, sshstdio.WithRuntimeObserver(d.observer)) | |
| 91 | - | } | |
| 92 | - | return sshstdio.DialContextWithIsolatedLaunch(ctx, d.target, d.launch.Root, d.launch.OwnerToken, d.launch.Binary, d.launch.Environment, d.log, sshstdio.WithRuntimeObserver(d.observer)) | |
| 93 | - | } | |
D internal/adapters/remote/remote_factory_test.go +0 -129
| @@ -1,129 +0,0 @@ | |||
| 1 | - | package remote | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "context" | |
| 5 | - | "errors" | |
| 6 | - | "sync" | |
| 7 | - | "testing" | |
| 8 | - | ||
| 9 | - | "github.com/bnema/vev/internal/ports" | |
| 10 | - | ) | |
| 11 | - | ||
| 12 | - | type recordingObserver struct { | |
| 13 | - | mu sync.Mutex | |
| 14 | - | marks []ports.RuntimeMark | |
| 15 | - | } | |
| 16 | - | ||
| 17 | - | func (r *recordingObserver) ObserveRuntime(mark ports.RuntimeMark) { | |
| 18 | - | r.mu.Lock() | |
| 19 | - | r.marks = append(r.marks, mark) | |
| 20 | - | r.mu.Unlock() | |
| 21 | - | } | |
| 22 | - | ||
| 23 | - | func (r *recordingObserver) Flush() {} | |
| 24 | - | func (r *recordingObserver) Close() {} | |
| 25 | - | ||
| 26 | - | func (r *recordingObserver) count() int { | |
| 27 | - | r.mu.Lock() | |
| 28 | - | defer r.mu.Unlock() | |
| 29 | - | return len(r.marks) | |
| 30 | - | } | |
| 31 | - | ||
| 32 | - | func TestDialerFactorySelectsExplicitModes(t *testing.T) { | |
| 33 | - | factory := NewDialerFactory() | |
| 34 | - | ||
| 35 | - | tests := []struct { | |
| 36 | - | name string | |
| 37 | - | mode TransportMode | |
| 38 | - | wantType any | |
| 39 | - | }{ | |
| 40 | - | {name: "quic", mode: TransportQUIC, wantType: quicDialer{}}, | |
| 41 | - | {name: "stdio", mode: TransportStdio, wantType: stdioDialer{}}, | |
| 42 | - | } | |
| 43 | - | for _, tt := range tests { | |
| 44 | - | t.Run(tt.name, func(t *testing.T) { | |
| 45 | - | dialer, err := factory.DialerForRemote("remote.example", "work", tt.mode, nil) | |
| 46 | - | if err != nil { | |
| 47 | - | t.Fatalf("DialerForRemote() error = %v", err) | |
| 48 | - | } | |
| 49 | - | switch tt.wantType.(type) { | |
| 50 | - | case quicDialer: | |
| 51 | - | got, ok := dialer.(quicDialer) | |
| 52 | - | if !ok { | |
| 53 | - | t.Fatalf("dialer type = %T, want %T", dialer, quicDialer{}) | |
| 54 | - | } | |
| 55 | - | if got.target != "remote.example" { | |
| 56 | - | t.Fatalf("dialer target = %q, want %q", got.target, "remote.example") | |
| 57 | - | } | |
| 58 | - | case stdioDialer: | |
| 59 | - | got, ok := dialer.(stdioDialer) | |
| 60 | - | if !ok { | |
| 61 | - | t.Fatalf("dialer type = %T, want %T", dialer, stdioDialer{}) | |
| 62 | - | } | |
| 63 | - | if got.target != "remote.example" { | |
| 64 | - | t.Fatalf("dialer target = %q, want %q", got.target, "remote.example") | |
| 65 | - | } | |
| 66 | - | } | |
| 67 | - | }) | |
| 68 | - | } | |
| 69 | - | } | |
| 70 | - | ||
| 71 | - | func TestDialerFactoryPropagatesObserverToQUICTransport(t *testing.T) { | |
| 72 | - | observer := &recordingObserver{} | |
| 73 | - | factory := NewDialerFactoryWithRuntimeObserver(observer) | |
| 74 | - | dialer, err := factory.DialerForRemote("remote.example", "work", TransportQUIC, nil) | |
| 75 | - | if err != nil { | |
| 76 | - | t.Fatalf("DialerForRemote() error = %v", err) | |
| 77 | - | } | |
| 78 | - | got, ok := dialer.(quicDialer) | |
| 79 | - | if !ok { | |
| 80 | - | t.Fatalf("dialer type = %T, want %T", dialer, quicDialer{}) | |
| 81 | - | } | |
| 82 | - | if got.observer != observer { | |
| 83 | - | t.Fatalf("quic dialer observer = %v, want the factory observer", got.observer) | |
| 84 | - | } | |
| 85 | - | } | |
| 86 | - | ||
| 87 | - | func TestDialerFactoryRejectsIncompleteIsolatedLaunch(t *testing.T) { | |
| 88 | - | factory := NewDialerFactory() | |
| 89 | - | for _, launch := range []*EndpointLaunch{ | |
| 90 | - | {Binary: "/bin/vev"}, | |
| 91 | - | {Binary: "/bin/vev", Root: "/tmp/root"}, | |
| 92 | - | {Root: "/tmp/root"}, | |
| 93 | - | {Root: "/tmp/root", OwnerToken: "token"}, | |
| 94 | - | } { | |
| 95 | - | _, err := factory.DialerForRemoteWithLaunch("remote.example", "work", TransportStdio, nil, launch) | |
| 96 | - | if err == nil { | |
| 97 | - | t.Fatalf("DialerForRemoteWithLaunch(%#v) error = nil, want incomplete launch rejection", launch) | |
| 98 | - | } | |
| 99 | - | } | |
| 100 | - | } | |
| 101 | - | ||
| 102 | - | func TestDialerFactoryRejectsUnsupportedMode(t *testing.T) { | |
| 103 | - | factory := NewDialerFactory() | |
| 104 | - | ||
| 105 | - | dialer, err := factory.DialerForRemote("remote.example", "work", TransportMode("serial"), nil) | |
| 106 | - | if err == nil { | |
| 107 | - | t.Fatal("DialerForRemote() error = nil, want unsupported mode error") | |
| 108 | - | } | |
| 109 | - | if dialer != nil { | |
| 110 | - | t.Fatalf("DialerForRemote() dialer = %T, want nil", dialer) | |
| 111 | - | } | |
| 112 | - | if got, want := err.Error(), "vev: unsupported remote transport \"serial\""; got != want { | |
| 113 | - | t.Fatalf("error = %q, want %q", got, want) | |
| 114 | - | } | |
| 115 | - | } | |
| 116 | - | ||
| 117 | - | func TestStdioDialerUsesContextBeforeStartingSSH(t *testing.T) { | |
| 118 | - | dialer := stdioDialer{target: "remote.example"} | |
| 119 | - | ctx, cancel := context.WithCancel(context.Background()) | |
| 120 | - | cancel() | |
| 121 | - | ||
| 122 | - | transport, err := dialer.Dial(ctx) | |
| 123 | - | if !errors.Is(err, context.Canceled) { | |
| 124 | - | t.Fatalf("Dial() error = %v, want context canceled", err) | |
| 125 | - | } | |
| 126 | - | if transport != nil { | |
| 127 | - | t.Fatalf("Dial() transport = %T, want nil", transport) | |
| 128 | - | } | |
| 129 | - | } | |
D internal/adapters/remote/runtime.go +0 -215
| @@ -1,215 +0,0 @@ | |||
| 1 | - | package remote | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "context" | |
| 5 | - | "errors" | |
| 6 | - | "log/slog" | |
| 7 | - | "sync" | |
| 8 | - | "time" | |
| 9 | - | ||
| 10 | - | "github.com/bnema/vev/internal/domain" | |
| 11 | - | appports "github.com/bnema/vev/internal/ports" | |
| 12 | - | ) | |
| 13 | - | ||
| 14 | - | const ( | |
| 15 | - | // observeWorkerCount bounds concurrent catalogue observations so four | |
| 16 | - | // hung checks can never starve the registry, cache, or store lanes. | |
| 17 | - | observeWorkerCount = 4 | |
| 18 | - | // defaultObserveTimeout bounds one catalogue observation when the | |
| 19 | - | // admitted job carries no deadline. | |
| 20 | - | defaultObserveTimeout = 10 * time.Second | |
| 21 | - | ) | |
| 22 | - | ||
| 23 | - | // errRemoteLaneBusy retires a job the service admitted without credit. The | |
| 24 | - | // service never issues more jobs of a kind than its credits; this is | |
| 25 | - | // defense-in-depth so a wedged admission cannot wedge the runtime. | |
| 26 | - | var errRemoteLaneBusy = errors.New("remote runtime: bounded lane occupied") | |
| 27 | - | ||
| 28 | - | // Runtime is the bounded infrastructure seam executing remote jobs. It owns | |
| 29 | - | // subprocesses, storage calls and per-lane dispatch; its workers never call | |
| 30 | - | // into the use case. Lanes are independent: one registry reader, one startup | |
| 31 | - | // cache reader, one cache writer, four catalogue observers. | |
| 32 | - | type Runtime struct { | |
| 33 | - | store appports.RemoteHostStore | |
| 34 | - | catalog appports.RemoteCatalogClient | |
| 35 | - | cache appports.RemoteCatalogCache | |
| 36 | - | log *slog.Logger | |
| 37 | - | } | |
| 38 | - | ||
| 39 | - | // NewRuntime composes a Runtime from the existing registry, catalogue and | |
| 40 | - | // cache adapters. All arguments must be non-nil. | |
| 41 | - | func NewRuntime(store appports.RemoteHostStore, catalog appports.RemoteCatalogClient, cache appports.RemoteCatalogCache, log *slog.Logger) *Runtime { | |
| 42 | - | if log == nil { | |
| 43 | - | log = slog.Default() | |
| 44 | - | } | |
| 45 | - | return &Runtime{store: store, catalog: catalog, cache: cache, log: log} | |
| 46 | - | } | |
| 47 | - | ||
| 48 | - | var _ appports.RemoteRuntime = (*Runtime)(nil) | |
| 49 | - | ||
| 50 | - | // Run executes admitted jobs until ctx is cancelled or the job channel | |
| 51 | - | // closes. Dispatch never blocks forwarding into an occupied lane; cancelled | |
| 52 | - | // completions select on context cancellation. It returns nil on cooperative | |
| 53 | - | // shutdown; lane failures travel through results, never through Run. | |
| 54 | - | // Cancellation is owned explicitly: workers run under a derived context so | |
| 55 | - | // closing the job channel retires in-flight delivery and reaps every worker | |
| 56 | - | // instead of hanging behind idle lanes with a live parent context. | |
| 57 | - | func (r *Runtime) Run(ctx context.Context, jobs <-chan appports.RemoteJob, results chan<- appports.RemoteJobResult) error { | |
| 58 | - | runCtx, stop := context.WithCancel(ctx) | |
| 59 | - | defer stop() | |
| 60 | - | registryLane := make(chan appports.RemoteJob, 1) | |
| 61 | - | cacheLoadLane := make(chan appports.RemoteJob, 1) | |
| 62 | - | storeLane := make(chan appports.RemoteJob, 1) | |
| 63 | - | // The observe buffer matches the worker count: under correct service | |
| 64 | - | // credits forwarding never blocks, and scheduling jitter between rapid | |
| 65 | - | // admissions cannot retire a properly admitted job as lane-busy. | |
| 66 | - | observeLane := make(chan appports.RemoteJob, observeWorkerCount) | |
| 67 | - | ||
| 68 | - | var workers sync.WaitGroup | |
| 69 | - | ready := make(chan struct{}, observeWorkerCount+3) | |
| 70 | - | park := func(lane <-chan appports.RemoteJob, work func(appports.RemoteJob)) { | |
| 71 | - | defer workers.Done() | |
| 72 | - | ready <- struct{}{} | |
| 73 | - | for { | |
| 74 | - | select { | |
| 75 | - | case <-runCtx.Done(): | |
| 76 | - | return | |
| 77 | - | case job, ok := <-lane: | |
| 78 | - | if !ok { | |
| 79 | - | return | |
| 80 | - | } | |
| 81 | - | work(job) | |
| 82 | - | } | |
| 83 | - | } | |
| 84 | - | } | |
| 85 | - | workers.Add(1) | |
| 86 | - | go park(registryLane, func(job appports.RemoteJob) { | |
| 87 | - | pinned, learned, err := r.store.Hosts() | |
| 88 | - | r.complete(runCtx, results, appports.RemoteJobResult{ | |
| 89 | - | Kind: job.Kind, Endpoint: job.Endpoint, | |
| 90 | - | Registration: job.Registration, Generation: job.Generation, | |
| 91 | - | Registrations: combineRegistrations(pinned, learned), Err: err, | |
| 92 | - | }) | |
| 93 | - | }) | |
| 94 | - | workers.Add(1) | |
| 95 | - | go park(cacheLoadLane, func(job appports.RemoteJob) { | |
| 96 | - | entries, err := r.cache.Load() | |
| 97 | - | r.complete(runCtx, results, appports.RemoteJobResult{ | |
| 98 | - | Kind: job.Kind, Endpoint: job.Endpoint, | |
| 99 | - | Registration: job.Registration, Generation: job.Generation, | |
| 100 | - | Entries: entries, Err: err, | |
| 101 | - | }) | |
| 102 | - | }) | |
| 103 | - | workers.Add(1) | |
| 104 | - | go park(storeLane, func(job appports.RemoteJob) { | |
| 105 | - | err := r.cache.Store(job.Entries) | |
| 106 | - | r.complete(runCtx, results, appports.RemoteJobResult{ | |
| 107 | - | Kind: job.Kind, Endpoint: job.Endpoint, | |
| 108 | - | Registration: job.Registration, Generation: job.Generation, | |
| 109 | - | Err: err, | |
| 110 | - | }) | |
| 111 | - | }) | |
| 112 | - | for i := 0; i < observeWorkerCount; i++ { | |
| 113 | - | workers.Add(1) | |
| 114 | - | go park(observeLane, func(job appports.RemoteJob) { | |
| 115 | - | timeout := job.Deadline | |
| 116 | - | if timeout <= 0 { | |
| 117 | - | timeout = defaultObserveTimeout | |
| 118 | - | } | |
| 119 | - | observeCtx, cancel := context.WithTimeout(runCtx, timeout) | |
| 120 | - | catalog, err := r.catalog.List(observeCtx, job.Endpoint) | |
| 121 | - | cancel() | |
| 122 | - | r.complete(runCtx, results, appports.RemoteJobResult{ | |
| 123 | - | Kind: job.Kind, Endpoint: job.Endpoint, | |
| 124 | - | Registration: job.Registration, Generation: job.Generation, | |
| 125 | - | FailureKind: ClassifyCatalogError(err), Catalog: catalog, Err: err, | |
| 126 | - | }) | |
| 127 | - | }) | |
| 128 | - | } | |
| 129 | - | // All lanes parked before the first admission: an immediate send can | |
| 130 | - | // never retire as lane-busy for lack of a ready receiver. | |
| 131 | - | for i := 0; i < observeWorkerCount+3; i++ { | |
| 132 | - | select { | |
| 133 | - | case <-ready: | |
| 134 | - | case <-ctx.Done(): | |
| 135 | - | workers.Wait() | |
| 136 | - | return nil | |
| 137 | - | } | |
| 138 | - | } | |
| 139 | - | ||
| 140 | - | for { | |
| 141 | - | select { | |
| 142 | - | case <-ctx.Done(): | |
| 143 | - | workers.Wait() | |
| 144 | - | return nil | |
| 145 | - | case job, ok := <-jobs: | |
| 146 | - | if !ok { | |
| 147 | - | stop() | |
| 148 | - | workers.Wait() | |
| 149 | - | return nil | |
| 150 | - | } | |
| 151 | - | var lane chan appports.RemoteJob | |
| 152 | - | switch job.Kind { | |
| 153 | - | case appports.RemoteJobRegistryRead: | |
| 154 | - | lane = registryLane | |
| 155 | - | case appports.RemoteJobCacheLoad: | |
| 156 | - | lane = cacheLoadLane | |
| 157 | - | case appports.RemoteJobCacheStore: | |
| 158 | - | lane = storeLane | |
| 159 | - | case appports.RemoteJobCatalogObserve: | |
| 160 | - | lane = observeLane | |
| 161 | - | default: | |
| 162 | - | r.complete(runCtx, results, appports.RemoteJobResult{ | |
| 163 | - | Kind: job.Kind, Endpoint: job.Endpoint, | |
| 164 | - | Registration: job.Registration, Generation: job.Generation, | |
| 165 | - | Err: errors.New("remote runtime: unknown job kind"), | |
| 166 | - | }) | |
| 167 | - | continue | |
| 168 | - | } | |
| 169 | - | select { | |
| 170 | - | case lane <- job: | |
| 171 | - | case <-ctx.Done(): | |
| 172 | - | workers.Wait() | |
| 173 | - | return nil | |
| 174 | - | default: | |
| 175 | - | r.log.Debug("remote runtime lane occupied, retiring job", "kind", job.Kind.String(), "endpoint", job.Endpoint) | |
| 176 | - | r.complete(runCtx, results, appports.RemoteJobResult{ | |
| 177 | - | Kind: job.Kind, Endpoint: job.Endpoint, | |
| 178 | - | Registration: job.Registration, Generation: job.Generation, | |
| 179 | - | Err: errRemoteLaneBusy, | |
| 180 | - | }) | |
| 181 | - | } | |
| 182 | - | } | |
| 183 | - | } | |
| 184 | - | } | |
| 185 | - | ||
| 186 | - | // complete delivers exactly one completion, abandoning delivery when the | |
| 187 | - | // service is already gone so shutdown never wedges on an unread result. | |
| 188 | - | func (r *Runtime) complete(ctx context.Context, results chan<- appports.RemoteJobResult, result appports.RemoteJobResult) { | |
| 189 | - | select { | |
| 190 | - | case results <- result: | |
| 191 | - | case <-ctx.Done(): | |
| 192 | - | } | |
| 193 | - | } | |
| 194 | - | ||
| 195 | - | // combineRegistrations orders pinned records first in stored order, then | |
| 196 | - | // learned-only records, so the monitor can assign stable presentation ranks. | |
| 197 | - | func combineRegistrations(pinned, learned []domain.RemoteRegistration) []domain.RemoteRegistration { | |
| 198 | - | out := make([]domain.RemoteRegistration, 0, len(pinned)+len(learned)) | |
| 199 | - | seen := make(map[string]struct{}, len(pinned)+len(learned)) | |
| 200 | - | for _, record := range pinned { | |
| 201 | - | if _, dup := seen[record.Endpoint]; dup { | |
| 202 | - | continue | |
| 203 | - | } | |
| 204 | - | seen[record.Endpoint] = struct{}{} | |
| 205 | - | out = append(out, record) | |
| 206 | - | } | |
| 207 | - | for _, record := range learned { | |
| 208 | - | if _, dup := seen[record.Endpoint]; dup { | |
| 209 | - | continue | |
| 210 | - | } | |
| 211 | - | seen[record.Endpoint] = struct{}{} | |
| 212 | - | out = append(out, record) | |
| 213 | - | } | |
| 214 | - | return out | |
| 215 | - | } | |
D internal/adapters/remote/runtime_test.go +0 -350
| @@ -1,350 +0,0 @@ | |||
| 1 | - | package remote | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "context" | |
| 5 | - | "errors" | |
| 6 | - | "sync" | |
| 7 | - | "testing" | |
| 8 | - | "time" | |
| 9 | - | ||
| 10 | - | "github.com/bnema/vev/internal/domain" | |
| 11 | - | appports "github.com/bnema/vev/internal/ports" | |
| 12 | - | "github.com/bnema/vev/internal/protocol/catalogue" | |
| 13 | - | ) | |
| 14 | - | ||
| 15 | - | type blockingCache struct { | |
| 16 | - | release chan struct{} | |
| 17 | - | done chan struct{} | |
| 18 | - | mu sync.Mutex | |
| 19 | - | stores [][]catalogue.RemoteCatalogCacheEntry | |
| 20 | - | } | |
| 21 | - | ||
| 22 | - | func newBlockingCache(t *testing.T) *blockingCache { | |
| 23 | - | t.Helper() | |
| 24 | - | cache := &blockingCache{release: make(chan struct{}), done: make(chan struct{})} | |
| 25 | - | t.Cleanup(func() { | |
| 26 | - | select { | |
| 27 | - | case <-cache.done: | |
| 28 | - | default: | |
| 29 | - | close(cache.done) | |
| 30 | - | } | |
| 31 | - | }) | |
| 32 | - | return cache | |
| 33 | - | } | |
| 34 | - | ||
| 35 | - | func (c *blockingCache) Load() ([]catalogue.RemoteCatalogCacheEntry, error) { return nil, nil } | |
| 36 | - | ||
| 37 | - | func (c *blockingCache) Store(entries []catalogue.RemoteCatalogCacheEntry) error { | |
| 38 | - | c.mu.Lock() | |
| 39 | - | c.stores = append(c.stores, entries) | |
| 40 | - | c.mu.Unlock() | |
| 41 | - | select { | |
| 42 | - | case <-c.release: | |
| 43 | - | return nil | |
| 44 | - | case <-c.done: | |
| 45 | - | return errors.New("test teardown releases blocked store") | |
| 46 | - | } | |
| 47 | - | } | |
| 48 | - | ||
| 49 | - | type instantCatalog struct { | |
| 50 | - | mu sync.Mutex | |
| 51 | - | calls []string | |
| 52 | - | deadline []time.Duration | |
| 53 | - | } | |
| 54 | - | ||
| 55 | - | func (c *instantCatalog) List(ctx context.Context, target string) (catalogue.RemoteCatalog, error) { | |
| 56 | - | deadline, _ := ctx.Deadline() | |
| 57 | - | c.mu.Lock() | |
| 58 | - | c.calls = append(c.calls, target) | |
| 59 | - | c.deadline = append(c.deadline, time.Until(deadline)) | |
| 60 | - | c.mu.Unlock() | |
| 61 | - | return catalogue.RemoteCatalog{Sessions: []catalogue.RemoteCatalogSession{{Name: "work"}}}, nil | |
| 62 | - | } | |
| 63 | - | ||
| 64 | - | type hangingCatalog struct{ started chan struct{} } | |
| 65 | - | ||
| 66 | - | func (c *hangingCatalog) List(ctx context.Context, target string) (catalogue.RemoteCatalog, error) { | |
| 67 | - | close(c.started) | |
| 68 | - | <-ctx.Done() | |
| 69 | - | return catalogue.RemoteCatalog{}, ctx.Err() | |
| 70 | - | } | |
| 71 | - | ||
| 72 | - | type recordStore struct { | |
| 73 | - | pinned []domain.RemoteRegistration | |
| 74 | - | learned []domain.RemoteRegistration | |
| 75 | - | err error | |
| 76 | - | } | |
| 77 | - | ||
| 78 | - | func testRecord(endpoint string, incarnation byte) domain.RemoteRegistration { | |
| 79 | - | return domain.RemoteRegistration{Endpoint: endpoint, Incarnation: [16]byte{incarnation}, Generation: 1} | |
| 80 | - | } | |
| 81 | - | ||
| 82 | - | func (s *recordStore) Hosts() ([]domain.RemoteRegistration, []domain.RemoteRegistration, error) { | |
| 83 | - | return s.pinned, s.learned, s.err | |
| 84 | - | } | |
| 85 | - | func (*recordStore) AddPinned(string) error { return nil } | |
| 86 | - | func (*recordStore) RemovePinned(string) error { return nil } | |
| 87 | - | func (*recordStore) Remember(string) error { return nil } | |
| 88 | - | func (*recordStore) Forget(string) error { return nil } | |
| 89 | - | func (*recordStore) Remove(string) (bool, error) { | |
| 90 | - | return false, nil | |
| 91 | - | } | |
| 92 | - | ||
| 93 | - | func startRuntime(t *testing.T, runtime *Runtime) (chan<- appports.RemoteJob, <-chan appports.RemoteJobResult, context.CancelFunc) { | |
| 94 | - | t.Helper() | |
| 95 | - | ctx, cancel := context.WithCancel(context.Background()) | |
| 96 | - | jobs := make(chan appports.RemoteJob, 16) | |
| 97 | - | results := make(chan appports.RemoteJobResult, 16) | |
| 98 | - | done := make(chan error, 1) | |
| 99 | - | go func() { done <- runtime.Run(ctx, jobs, results) }() | |
| 100 | - | t.Cleanup(func() { | |
| 101 | - | cancel() | |
| 102 | - | select { | |
| 103 | - | case <-done: | |
| 104 | - | case <-time.After(5 * time.Second): | |
| 105 | - | t.Fatalf("runtime did not stop") | |
| 106 | - | } | |
| 107 | - | }) | |
| 108 | - | return jobs, results, cancel | |
| 109 | - | } | |
| 110 | - | ||
| 111 | - | func receiveResult(t *testing.T, ch <-chan appports.RemoteJobResult, what string) appports.RemoteJobResult { | |
| 112 | - | t.Helper() | |
| 113 | - | select { | |
| 114 | - | case result := <-ch: | |
| 115 | - | return result | |
| 116 | - | case <-time.After(5 * time.Second): | |
| 117 | - | t.Fatalf("timed out waiting for %s", what) | |
| 118 | - | return appports.RemoteJobResult{} | |
| 119 | - | } | |
| 120 | - | } | |
| 121 | - | ||
| 122 | - | func TestRuntimeBlockedWriterDoesNotStallOtherLanes(t *testing.T) { | |
| 123 | - | t.Parallel() | |
| 124 | - | ||
| 125 | - | cache := &blockingCache{release: make(chan struct{})} | |
| 126 | - | catalog := &instantCatalog{} | |
| 127 | - | store := &recordStore{ | |
| 128 | - | pinned: []domain.RemoteRegistration{testRecord("user@arch", 1)}, | |
| 129 | - | learned: []domain.RemoteRegistration{testRecord("user@mule", 2)}, | |
| 130 | - | } | |
| 131 | - | runtime := NewRuntime(store, catalog, cache, nil) | |
| 132 | - | jobs, results, _ := startRuntime(t, runtime) | |
| 133 | - | // Release the blocked writer on any early failure so the runtime | |
| 134 | - | // cleanup's bounded join never wedges behind this test's Fatalf. | |
| 135 | - | t.Cleanup(func() { | |
| 136 | - | select { | |
| 137 | - | case <-cache.release: | |
| 138 | - | default: | |
| 139 | - | close(cache.release) | |
| 140 | - | } | |
| 141 | - | }) | |
| 142 | - | ||
| 143 | - | // Occupy the single cache writer, then prove registry reads and all | |
| 144 | - | // four observation lanes make progress around the blocked write. | |
| 145 | - | jobs <- appports.RemoteJob{Kind: appports.RemoteJobCacheStore, Entries: []catalogue.RemoteCatalogCacheEntry{{Host: "user@arch"}}} | |
| 146 | - | jobs <- appports.RemoteJob{Kind: appports.RemoteJobRegistryRead} | |
| 147 | - | for _, endpoint := range []string{"a", "b", "c", "d"} { | |
| 148 | - | jobs <- appports.RemoteJob{Kind: appports.RemoteJobCatalogObserve, Endpoint: endpoint} | |
| 149 | - | } | |
| 150 | - | ||
| 151 | - | registry := appports.RemoteJobResult{} | |
| 152 | - | observes := 0 | |
| 153 | - | // Lanes run independently: the registry read and the four | |
| 154 | - | // observations complete in arrival order, not admission order. | |
| 155 | - | for i := 0; i < 5; i++ { | |
| 156 | - | result := receiveResult(t, results, "registry or observe") | |
| 157 | - | switch result.Kind { | |
| 158 | - | case appports.RemoteJobRegistryRead: | |
| 159 | - | if registry.Kind != 0 { | |
| 160 | - | t.Fatalf("duplicate registry result = %+v", result) | |
| 161 | - | } | |
| 162 | - | registry = result | |
| 163 | - | case appports.RemoteJobCatalogObserve: | |
| 164 | - | if result.Err != nil || result.FailureKind != domain.RemoteFailureNone { | |
| 165 | - | t.Fatalf("observe result = %+v", result) | |
| 166 | - | } | |
| 167 | - | if len(result.Catalog.Sessions) != 1 { | |
| 168 | - | t.Fatalf("observe catalog = %+v", result.Catalog) | |
| 169 | - | } | |
| 170 | - | observes++ | |
| 171 | - | default: | |
| 172 | - | t.Fatalf("unexpected result kind = %+v", result) | |
| 173 | - | } | |
| 174 | - | } | |
| 175 | - | if registry.Err != nil { | |
| 176 | - | t.Fatalf("registry result = %+v", registry) | |
| 177 | - | } | |
| 178 | - | if len(registry.Registrations) != 2 || registry.Registrations[0].Endpoint != "user@arch" || registry.Registrations[1].Endpoint != "user@mule" { | |
| 179 | - | t.Fatalf("registrations = %+v, want pinned-then-learned order", registry.Registrations) | |
| 180 | - | } | |
| 181 | - | if observes != 4 { | |
| 182 | - | t.Fatalf("observes = %d, want 4", observes) | |
| 183 | - | } | |
| 184 | - | ||
| 185 | - | // Two further stores arrive while the writer is occupied. The lane admits | |
| 186 | - | // exactly one of them and retires the other as lane-busy rather than | |
| 187 | - | // queueing without bound; which of the two wins is admission timing, so | |
| 188 | - | // the assertions follow the reported outcome instead of naming a winner. | |
| 189 | - | second := appports.RemoteJob{Kind: appports.RemoteJobCacheStore, Endpoint: "second", Entries: []catalogue.RemoteCatalogCacheEntry{{Host: "second"}}} | |
| 190 | - | third := appports.RemoteJob{Kind: appports.RemoteJobCacheStore, Endpoint: "third", Entries: []catalogue.RemoteCatalogCacheEntry{{Host: "third"}}} | |
| 191 | - | jobs <- second | |
| 192 | - | jobs <- third | |
| 193 | - | busy := receiveResult(t, results, "lane-busy store") | |
| 194 | - | if busy.Kind != appports.RemoteJobCacheStore || !errors.Is(busy.Err, errRemoteLaneBusy) { | |
| 195 | - | t.Fatalf("bounded-lane store result = %+v, want lane-busy failure", busy) | |
| 196 | - | } | |
| 197 | - | if busy.Endpoint != second.Endpoint && busy.Endpoint != third.Endpoint { | |
| 198 | - | t.Fatalf("lane-busy store endpoint = %q, want one of the two admitted stores", busy.Endpoint) | |
| 199 | - | } | |
| 200 | - | admitted := second.Endpoint | |
| 201 | - | if busy.Endpoint == second.Endpoint { | |
| 202 | - | admitted = third.Endpoint | |
| 203 | - | } | |
| 204 | - | ||
| 205 | - | close(cache.release) | |
| 206 | - | firstStored := receiveResult(t, results, "cache store") | |
| 207 | - | if firstStored.Kind != appports.RemoteJobCacheStore || firstStored.Err != nil { | |
| 208 | - | t.Fatalf("store result = %+v", firstStored) | |
| 209 | - | } | |
| 210 | - | admittedStored := receiveResult(t, results, "admitted cache store") | |
| 211 | - | if admittedStored.Kind != appports.RemoteJobCacheStore || admittedStored.Err != nil || admittedStored.Endpoint != admitted { | |
| 212 | - | t.Fatalf("admitted store result = %+v, want the store the lane accepted (%s)", admittedStored, admitted) | |
| 213 | - | } | |
| 214 | - | cache.mu.Lock() | |
| 215 | - | defer cache.mu.Unlock() | |
| 216 | - | if len(cache.stores) != 2 || len(cache.stores[0]) != 1 || cache.stores[0][0].Host != "user@arch" || | |
| 217 | - | len(cache.stores[1]) != 1 || cache.stores[1][0].Host != admitted { | |
| 218 | - | t.Fatalf("stored payloads = %+v, want the blocked store first then %s", cache.stores, admitted) | |
| 219 | - | } | |
| 220 | - | } | |
| 221 | - | ||
| 222 | - | func TestRuntimeObserveHonorsDeadlineAndCancel(t *testing.T) { | |
| 223 | - | t.Parallel() | |
| 224 | - | ||
| 225 | - | catalog := &instantCatalog{} | |
| 226 | - | runtime := NewRuntime(&recordStore{}, catalog, &blockingCache{release: make(chan struct{})}, nil) | |
| 227 | - | jobs, results, _ := startRuntime(t, runtime) | |
| 228 | - | ||
| 229 | - | jobs <- appports.RemoteJob{Kind: appports.RemoteJobCatalogObserve, Endpoint: "user@arch", Deadline: 5 * time.Second} | |
| 230 | - | result := receiveResult(t, results, "bounded observe") | |
| 231 | - | if result.Err != nil { | |
| 232 | - | t.Fatalf("observe result = %+v", result) | |
| 233 | - | } | |
| 234 | - | catalog.mu.Lock() | |
| 235 | - | defer catalog.mu.Unlock() | |
| 236 | - | if len(catalog.calls) != 1 || catalog.calls[0] != "user@arch" { | |
| 237 | - | t.Fatalf("catalog calls = %v", catalog.calls) | |
| 238 | - | } | |
| 239 | - | if catalog.deadline[0] <= 0 || catalog.deadline[0] > 5*time.Second { | |
| 240 | - | t.Fatalf("observe ctx deadline in %v, want within (0, 5s]", catalog.deadline[0]) | |
| 241 | - | } | |
| 242 | - | } | |
| 243 | - | ||
| 244 | - | func TestRuntimeObserveCancelReapsWorker(t *testing.T) { | |
| 245 | - | t.Parallel() | |
| 246 | - | ||
| 247 | - | catalog := &hangingCatalog{started: make(chan struct{})} | |
| 248 | - | runtime := NewRuntime(&recordStore{}, catalog, &blockingCache{release: make(chan struct{})}, nil) | |
| 249 | - | ctx, cancel := context.WithCancel(context.Background()) | |
| 250 | - | jobs := make(chan appports.RemoteJob, 4) | |
| 251 | - | results := make(chan appports.RemoteJobResult, 4) | |
| 252 | - | done := make(chan error, 1) | |
| 253 | - | go func() { done <- runtime.Run(ctx, jobs, results) }() | |
| 254 | - | ||
| 255 | - | jobs <- appports.RemoteJob{Kind: appports.RemoteJobCatalogObserve, Endpoint: "user@arch", Deadline: time.Minute} | |
| 256 | - | select { | |
| 257 | - | case <-catalog.started: | |
| 258 | - | case <-time.After(5 * time.Second): | |
| 259 | - | t.Fatal("observation did not start") | |
| 260 | - | } | |
| 261 | - | cancel() | |
| 262 | - | // Delivery is abandoned once the runtime context is cancelled, so a | |
| 263 | - | // cancelled observation either reports context.Canceled or never | |
| 264 | - | // reports at all; either way the runtime itself must stop. Note done | |
| 265 | - | // is buffered depth one: once observed stopped, do not wait on it | |
| 266 | - | // again. | |
| 267 | - | stopped := false | |
| 268 | - | select { | |
| 269 | - | case result := <-results: | |
| 270 | - | if !errors.Is(result.Err, context.Canceled) { | |
| 271 | - | t.Fatalf("cancelled observe err = %v, want context.Canceled", result.Err) | |
| 272 | - | } | |
| 273 | - | case <-done: | |
| 274 | - | stopped = true | |
| 275 | - | case <-time.After(5 * time.Second): | |
| 276 | - | t.Fatal("cancelled observation neither completed nor stopped the runtime") | |
| 277 | - | } | |
| 278 | - | if !stopped { | |
| 279 | - | select { | |
| 280 | - | case <-done: | |
| 281 | - | case <-time.After(5 * time.Second): | |
| 282 | - | t.Fatal("runtime did not stop after cancel") | |
| 283 | - | } | |
| 284 | - | } | |
| 285 | - | } | |
| 286 | - | ||
| 287 | - | func TestRuntimeJobsCloseReapsWorkers(t *testing.T) { | |
| 288 | - | t.Parallel() | |
| 289 | - | ||
| 290 | - | // Closing the job channel with a live parent context must still | |
| 291 | - | // reap every worker: the runtime owns cancellation explicitly | |
| 292 | - | // instead of hanging behind idle lanes. | |
| 293 | - | runtime := NewRuntime(&recordStore{}, &instantCatalog{}, &blockingCache{release: make(chan struct{})}, nil) | |
| 294 | - | jobs := make(chan appports.RemoteJob, 4) | |
| 295 | - | results := make(chan appports.RemoteJobResult, 4) | |
| 296 | - | done := make(chan error, 1) | |
| 297 | - | go func() { done <- runtime.Run(context.Background(), jobs, results) }() | |
| 298 | - | ||
| 299 | - | close(jobs) | |
| 300 | - | select { | |
| 301 | - | case err := <-done: | |
| 302 | - | if err != nil { | |
| 303 | - | t.Fatalf("Run() error = %v, want nil", err) | |
| 304 | - | } | |
| 305 | - | case <-time.After(5 * time.Second): | |
| 306 | - | t.Fatal("runtime did not stop after jobs closed with live context") | |
| 307 | - | } | |
| 308 | - | } | |
| 309 | - | ||
| 310 | - | func TestRuntimeCancelledCompletionDoesNotWedge(t *testing.T) { | |
| 311 | - | t.Parallel() | |
| 312 | - | ||
| 313 | - | runtime := NewRuntime(&recordStore{}, &instantCatalog{}, &blockingCache{release: make(chan struct{})}, nil) | |
| 314 | - | ctx, cancel := context.WithCancel(context.Background()) | |
| 315 | - | jobs := make(chan appports.RemoteJob, 4) | |
| 316 | - | // Unbuffered, unconsumed results: completion must abandon delivery on | |
| 317 | - | // cancellation instead of wedging shutdown. | |
| 318 | - | results := make(chan appports.RemoteJobResult) | |
| 319 | - | done := make(chan error, 1) | |
| 320 | - | go func() { done <- runtime.Run(ctx, jobs, results) }() | |
| 321 | - | ||
| 322 | - | jobs <- appports.RemoteJob{Kind: appports.RemoteJobRegistryRead} | |
| 323 | - | // Let the worker finish and park in completion delivery with no | |
| 324 | - | // consumer on the unbuffered results channel. | |
| 325 | - | time.Sleep(200 * time.Millisecond) | |
| 326 | - | cancel() | |
| 327 | - | select { | |
| 328 | - | case <-done: | |
| 329 | - | case <-time.After(5 * time.Second): | |
| 330 | - | t.Fatal("runtime wedged on unread result") | |
| 331 | - | } | |
| 332 | - | } | |
| 333 | - | ||
| 334 | - | func TestCombineRegistrations(t *testing.T) { | |
| 335 | - | t.Parallel() | |
| 336 | - | ||
| 337 | - | archPinned := testRecord("user@arch", 1) | |
| 338 | - | archLearned := testRecord("user@arch", 9) | |
| 339 | - | mule := testRecord("user@mule", 2) | |
| 340 | - | got := combineRegistrations( | |
| 341 | - | []domain.RemoteRegistration{archPinned}, | |
| 342 | - | []domain.RemoteRegistration{mule, archLearned}, | |
| 343 | - | ) | |
| 344 | - | if len(got) != 2 || got[0] != archPinned || got[1] != mule { | |
| 345 | - | t.Fatalf("combine = %+v, want pinned-then-learned-only", got) | |
| 346 | - | } | |
| 347 | - | if len(combineRegistrations(nil, nil)) != 0 { | |
| 348 | - | t.Fatal("empty combine must stay empty") | |
| 349 | - | } | |
| 350 | - | } | |
M internal/adapters/sessionwire/proto_convert_session.go +46 -8
| @@ -8,6 +8,8 @@ import ( | |||
| 8 | 8 | "math" | |
| 9 | 9 | "sync" | |
| 10 | 10 | ||
| 11 | + | "google.golang.org/protobuf/proto" | |
| 12 | + | ||
| 11 | 13 | "github.com/bnema/vev/internal/domain" | |
| 12 | 14 | "github.com/bnema/vev/internal/protocol" | |
| 13 | 15 | "github.com/bnema/vev/internal/protocol/wire" | |
| @@ -22,7 +24,7 @@ func helloToWire(message protocol.Hello) (*wire.Hello, error) { | |||
| 22 | 24 | if err != nil { | |
| 23 | 25 | return nil, err | |
| 24 | 26 | } | |
| 25 | - | remote, err := remoteTargetToWire(message.RemoteTarget) | |
| 27 | + | sessionTarget, err := sessionAttachTargetToWire(message.SessionTarget) | |
| 26 | 28 | if err != nil { | |
| 27 | 29 | return nil, err | |
| 28 | 30 | } | |
| @@ -42,7 +44,7 @@ func helloToWire(message protocol.Hello) (*wire.Hello, error) { | |||
| 42 | 44 | TrueColor: message.TrueColor, | |
| 43 | 45 | MaxOutputInFlight: uint32(message.MaxOutputInFlight), | |
| 44 | 46 | Env: append([]string(nil), message.Env...), | |
| 45 | - | RemoteTarget: remote, | |
| 47 | + | SessionTarget: sessionTarget, | |
| 46 | 48 | EnvironmentPolicy: uint32(message.EnvironmentPolicy), | |
| 47 | 49 | ExactTarget: exact, | |
| 48 | 50 | PreferredTabId: string(message.PreferredTabID), | |
| @@ -110,11 +112,11 @@ func helloFromWire(message *wire.Hello) (protocol.Hello, error) { | |||
| 110 | 112 | } | |
| 111 | 113 | hello.MaxOutputInFlight = window | |
| 112 | 114 | hello.Env = append([]string(nil), message.GetEnv()...) | |
| 113 | - | remote, err := remoteTargetFromWire(message.GetRemoteTarget()) | |
| 115 | + | sessionTarget, err := sessionAttachTargetFromWire(message.GetSessionTarget()) | |
| 114 | 116 | if err != nil { | |
| 115 | 117 | return protocol.Hello{}, err | |
| 116 | 118 | } | |
| 117 | - | hello.RemoteTarget = remote | |
| 119 | + | hello.SessionTarget = sessionTarget | |
| 118 | 120 | hello.EnvironmentPolicy, err = enum8[protocol.EnvironmentPolicy](message.GetEnvironmentPolicy()) | |
| 119 | 121 | if err != nil { | |
| 120 | 122 | return protocol.Hello{}, err | |
| @@ -203,11 +205,47 @@ func committedIdentityFromWire(message *wire.CommittedRouteIdentity) (protocol.C | |||
| 203 | 205 | return identity, nil | |
| 204 | 206 | } | |
| 205 | 207 | ||
| 208 | + | func sessionAttachTargetToWire(target *protocol.SessionAttachTarget) (*wire.SessionAttachTarget, error) { | |
| 209 | + | if target == nil { | |
| 210 | + | return nil, nil | |
| 211 | + | } | |
| 212 | + | if err := target.Validate(); err != nil { | |
| 213 | + | return nil, err | |
| 214 | + | } | |
| 215 | + | message := &wire.SessionAttachTarget{SessionId: string(target.SessionID), LifecycleId: lifecycleToWire(target.LifecycleID), SessionName: target.SessionName, TabId: string(target.TabID), TabRawName: target.TabRawName, TabExpectedCount: uint32(target.TabExpectedCount), Stopped: target.Stopped} | |
| 216 | + | if target.TabIndex != protocol.NoTabIndex { | |
| 217 | + | message.TabIndex = proto.Int32(target.TabIndex) | |
| 218 | + | } | |
| 219 | + | return message, nil | |
| 220 | + | } | |
| 221 | + | ||
| 222 | + | func sessionAttachTargetFromWire(message *wire.SessionAttachTarget) (*protocol.SessionAttachTarget, error) { | |
| 223 | + | if message == nil { | |
| 224 | + | return nil, nil | |
| 225 | + | } | |
| 226 | + | if message.GetTabExpectedCount() > math.MaxUint16 { | |
| 227 | + | return nil, protocol.ErrInvalidAttachTarget | |
| 228 | + | } | |
| 229 | + | lifecycle, err := lifecycleFromWire(message.GetLifecycleId()) | |
| 230 | + | if err != nil { | |
| 231 | + | return nil, err | |
| 232 | + | } | |
| 233 | + | tabIndex := protocol.NoTabIndex | |
| 234 | + | if message.TabIndex != nil { | |
| 235 | + | tabIndex = message.GetTabIndex() | |
| 236 | + | } | |
| 237 | + | target := &protocol.SessionAttachTarget{SessionID: domain.SessionID(message.GetSessionId()), LifecycleID: lifecycle, SessionName: message.GetSessionName(), TabID: domain.TabStableID(message.GetTabId()), TabIndex: tabIndex, TabRawName: message.GetTabRawName(), TabExpectedCount: uint16(message.GetTabExpectedCount()), Stopped: message.GetStopped()} | |
| 238 | + | if err := target.Validate(); err != nil { | |
| 239 | + | return nil, err | |
| 240 | + | } | |
| 241 | + | return target, nil | |
| 242 | + | } | |
| 243 | + | ||
| 206 | 244 | func attachTargetToWire(message protocol.AttachTarget) (*wire.AttachTarget, error) { | |
| 207 | 245 | if err := protocol.ValidateAttachTarget(message); err != nil { | |
| 208 | 246 | return nil, err | |
| 209 | 247 | } | |
| 210 | - | remote, err := remoteTargetToWire(message.RemoteTarget) | |
| 248 | + | sessionTarget, err := sessionAttachTargetToWire(message.SessionTarget) | |
| 211 | 249 | if err != nil { | |
| 212 | 250 | return nil, err | |
| 213 | 251 | } | |
| @@ -216,7 +254,7 @@ func attachTargetToWire(message protocol.AttachTarget) (*wire.AttachTarget, erro | |||
| 216 | 254 | Endpoint: message.Endpoint, | |
| 217 | 255 | Session: message.Session, | |
| 218 | 256 | Intent: uint32(message.Intent), | |
| 219 | - | RemoteTarget: remote, | |
| 257 | + | SessionTarget: sessionTarget, | |
| 220 | 258 | EnvironmentPolicy: uint32(message.EnvironmentPolicy), | |
| 221 | 259 | ExactTarget: exactTargetToWire(message.ExactTarget), | |
| 222 | 260 | SamePeer: message.SamePeer, | |
| @@ -238,11 +276,11 @@ func attachTargetFromWire(message *wire.AttachTarget) (protocol.AttachTarget, er | |||
| 238 | 276 | return protocol.AttachTarget{}, protocol.ErrInvalidAttachTarget | |
| 239 | 277 | } | |
| 240 | 278 | target.Intent = intent | |
| 241 | - | remote, err := remoteTargetFromWire(message.GetRemoteTarget()) | |
| 279 | + | sessionTarget, err := sessionAttachTargetFromWire(message.GetSessionTarget()) | |
| 242 | 280 | if err != nil { | |
| 243 | 281 | return protocol.AttachTarget{}, protocol.ErrInvalidAttachTarget | |
| 244 | 282 | } | |
| 245 | - | target.RemoteTarget = remote | |
| 283 | + | target.SessionTarget = sessionTarget | |
| 246 | 284 | target.EnvironmentPolicy, err = enum8[protocol.EnvironmentPolicy](message.GetEnvironmentPolicy()) | |
| 247 | 285 | if err != nil { | |
| 248 | 286 | return protocol.AttachTarget{}, protocol.ErrInvalidAttachTarget | |
M internal/adapters/sessionwire/proto_convert_test.go +24 -0
| @@ -134,6 +134,30 @@ func TestProtoServerRoundTrips(t *testing.T) { | |||
| 134 | 134 | } | |
| 135 | 135 | } | |
| 136 | 136 | ||
| 137 | + | func TestSessionAttachTargetFromWireRejectsInvalidRawName(t *testing.T) { | |
| 138 | + | lifecycle := lifecycleToWire(domain.SessionLifecycleID{1}) | |
| 139 | + | for _, rawName := range []string{string(make([]byte, 257)), "bad\nname"} { | |
| 140 | + | _, err := sessionAttachTargetFromWire(&wire.SessionAttachTarget{LifecycleId: lifecycle, SessionName: "work", TabIndex: proto.Int32(0), TabRawName: rawName, TabExpectedCount: 1, Stopped: true}) | |
| 141 | + | require.Error(t, err) | |
| 142 | + | } | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | func TestSessionAttachTargetTabIndexPresence(t *testing.T) { | |
| 146 | + | lifecycle := lifecycleToWire(domain.SessionLifecycleID{1}) | |
| 147 | + | ||
| 148 | + | zero, err := sessionAttachTargetFromWire(&wire.SessionAttachTarget{LifecycleId: lifecycle, SessionName: "work", TabIndex: proto.Int32(0), TabRawName: "first", TabExpectedCount: 1, Stopped: true}) | |
| 149 | + | require.NoError(t, err) | |
| 150 | + | require.Equal(t, int32(0), zero.TabIndex) | |
| 151 | + | ||
| 152 | + | absent, err := sessionAttachTargetFromWire(&wire.SessionAttachTarget{LifecycleId: lifecycle, SessionName: "work", Stopped: true}) | |
| 153 | + | require.NoError(t, err) | |
| 154 | + | require.Equal(t, protocol.NoTabIndex, absent.TabIndex) | |
| 155 | + | ||
| 156 | + | encoded, err := sessionAttachTargetToWire(zero) | |
| 157 | + | require.NoError(t, err) | |
| 158 | + | require.Zero(t, encoded.GetTabIndex()) | |
| 159 | + | } | |
| 160 | + | ||
| 137 | 161 | // TestProtoWrongDirectionRejects proves a server payload in a client | |
| 138 | 162 | // envelope (and vice versa) fails before any mutation. | |
| 139 | 163 | func TestProtoWrongDirectionRejects(t *testing.T) { | |
M internal/adapters/sessionwire/server.go +66 -4
| @@ -34,6 +34,14 @@ type serverConnection struct { | |||
| 34 | 34 | preambleErr error | |
| 35 | 35 | deadline time.Time | |
| 36 | 36 | ||
| 37 | + | // admission is the provisioned admission metadata the accepting side | |
| 38 | + | // stamped, or the zero value with hasAdmission=false for a connection built | |
| 39 | + | // by a legacy constructor that carried no admission. It is never mutated | |
| 40 | + | // after construction and is exposed only through SessionAdmission, which | |
| 41 | + | // returns a deep copy. | |
| 42 | + | admission ports.SessionAdmission | |
| 43 | + | hasAdmission bool | |
| 44 | + | ||
| 37 | 45 | // preambleDone closes exactly once when the handshake has run, whether it | |
| 38 | 46 | // succeeded or failed. It backs the handshake completion seam a mux listener | |
| 39 | 47 | // exposes to the daemon; the absolute deadline is never restarted. | |
| @@ -45,14 +53,18 @@ type serverConnection struct { | |||
| 45 | 53 | hooks []func() | |
| 46 | 54 | } | |
| 47 | 55 | ||
| 48 | - | var _ ports.ServerConnection = (*serverConnection)(nil) | |
| 56 | + | var ( | |
| 57 | + | _ ports.ServerConnection = (*serverConnection)(nil) | |
| 58 | + | _ ports.SessionAdmissionProvider = (*serverConnection)(nil) | |
| 59 | + | ) | |
| 49 | 60 | ||
| 50 | 61 | // NewServerConnection wraps one raw connection incarnation exactly once. The | |
| 51 | 62 | // server preamble runs on the first ReceiveClient, bounded by the handshake | |
| 52 | 63 | // deadline started here; the daemon's transport watcher closes the link on | |
| 53 | 64 | // timeout, which unblocks the preamble read. Prefer | |
| 54 | 65 | // NewServerConnectionWithDeadline when the caller already owns the absolute | |
| 55 | - | // deadline, as the daemon-side mux listener does at Open admission. | |
| 66 | + | // deadline, as the daemon-side mux listener does at Open admission. The | |
| 67 | + | // returned connection carries no admission metadata. | |
| 56 | 68 | func NewServerConnection(raw wire.Transport) ports.ServerConnection { | |
| 57 | 69 | return NewServerConnectionWithDeadline(raw, time.Now().Add(protocol.HandshakeTimeout)) | |
| 58 | 70 | } | |
| @@ -63,15 +75,65 @@ func NewServerConnection(raw wire.Transport) ports.ServerConnection { | |||
| 63 | 75 | // so a stream delayed in an accept queue spends that one budget instead of | |
| 64 | 76 | // starting a second one. The preamble still runs lazily on the first | |
| 65 | 77 | // ReceiveClient; a deadline already in the past fails it immediately. A zero | |
| 66 | - | // deadline falls back to a fresh protocol.HandshakeTimeout from now. | |
| 78 | + | // deadline falls back to a fresh protocol.HandshakeTimeout from now. The | |
| 79 | + | // returned connection carries no admission metadata. | |
| 67 | 80 | func NewServerConnectionWithDeadline(raw wire.Transport, deadline time.Time) ports.ServerConnection { | |
| 81 | + | return newServerConnection(raw, deadline, ports.SessionAdmission{}, false) | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | // NewServerConnectionWithAdmission wraps one raw connection incarnation exactly | |
| 85 | + | // once with an absolute local handshake deadline and the provisioned admission | |
| 86 | + | // metadata its accepting side stamped (the exact accepted policy and origin, | |
| 87 | + | // plus the peer's declared purpose, admission variant, name, target, and | |
| 88 | + | // bounded environment). The admission is stored as a deep copy and exposed only | |
| 89 | + | // through SessionAdmission, which returns a fresh copy so a consumer can never | |
| 90 | + | // mutate admitted authority. An entirely zero admission is reported as ok=false, | |
| 91 | + | // exactly as the legacy constructors do, so a caller cannot accidentally | |
| 92 | + | // publish an unvalidated admission. | |
| 93 | + | func NewServerConnectionWithAdmission(raw wire.Transport, deadline time.Time, admission ports.SessionAdmission) ports.ServerConnection { | |
| 94 | + | return newServerConnection(raw, deadline, admission, !admissionAbsent(admission)) | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | // admissionAbsent reports whether an admission is entirely unset. It is the | |
| 98 | + | // presence test for NewServerConnectionWithAdmission: an admission that names no | |
| 99 | + | // origin, policy, purpose, or payload is indistinguishable from the legacy | |
| 100 | + | // constructors' absent metadata and is reported as absent rather than trusted. | |
| 101 | + | func admissionAbsent(admission ports.SessionAdmission) bool { | |
| 102 | + | return admission.Origin == 0 && admission.Policy == (ports.BrokerPolicy{}) && | |
| 103 | + | admission.Purpose == 0 && admission.Admission == 0 && admission.Name == "" && | |
| 104 | + | admission.Target == (protocol.ExactSessionTarget{}) && len(admission.Env) == 0 | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | // newServerConnection is the shared constructor: it clones the admission and | |
| 108 | + | // records whether it is present, so every exported constructor funnels through | |
| 109 | + | // one place that fixes the deadline and allocates the completion signal. | |
| 110 | + | func newServerConnection(raw wire.Transport, deadline time.Time, admission ports.SessionAdmission, hasAdmission bool) ports.ServerConnection { | |
| 68 | 111 | if raw == nil { | |
| 69 | 112 | return nil | |
| 70 | 113 | } | |
| 71 | 114 | if deadline.IsZero() { | |
| 72 | 115 | deadline = time.Now().Add(protocol.HandshakeTimeout) | |
| 73 | 116 | } | |
| 74 | - | return &serverConnection{raw: raw, ceilings: defaultProtoCeilings(), deadline: deadline, preambleDone: make(chan struct{})} | |
| 117 | + | return &serverConnection{ | |
| 118 | + | raw: raw, | |
| 119 | + | ceilings: defaultProtoCeilings(), | |
| 120 | + | deadline: deadline, | |
| 121 | + | admission: admission.Clone(), | |
| 122 | + | hasAdmission: hasAdmission, | |
| 123 | + | preambleDone: make(chan struct{}), | |
| 124 | + | } | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | // SessionAdmission returns a deep copy of the provisioned admission metadata | |
| 128 | + | // stamped on this connection, implementing ports.SessionAdmissionProvider. It | |
| 129 | + | // reports ok=false for a connection built by a constructor that carried no | |
| 130 | + | // admission, so a consumer distinguishes an absent admission from an admitted | |
| 131 | + | // one instead of trusting a zero value. | |
| 132 | + | func (c *serverConnection) SessionAdmission() (ports.SessionAdmission, bool) { | |
| 133 | + | if c == nil || !c.hasAdmission { | |
| 134 | + | return ports.SessionAdmission{}, false | |
| 135 | + | } | |
| 136 | + | return c.admission.Clone(), true | |
| 75 | 137 | } | |
| 76 | 138 | ||
| 77 | 139 | // HandshakeDeadline returns the absolute local deadline of the session | |
A internal/adapters/sessionwire/server_admission_test.go +115 -0
| @@ -0,0 +1,115 @@ | |||
| 1 | + | package sessionwire | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | "time" | |
| 6 | + | ||
| 7 | + | "github.com/stretchr/testify/require" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/vev/internal/domain" | |
| 10 | + | "github.com/bnema/vev/internal/ports" | |
| 11 | + | "github.com/bnema/vev/internal/protocol" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | func wireTestPolicy() ports.BrokerPolicy { | |
| 15 | + | return ports.BrokerPolicy{ | |
| 16 | + | ProtocolVersion: protocol.Version, | |
| 17 | + | CatalogSchemaVersion: 3, | |
| 18 | + | EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned, | |
| 19 | + | Transport: "unix-mux", | |
| 20 | + | Trust: "same-user", | |
| 21 | + | Launch: "explicit", | |
| 22 | + | Isolation: "per-user", | |
| 23 | + | } | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | func wireTestAdmission() ports.SessionAdmission { | |
| 27 | + | return ports.SessionAdmission{ | |
| 28 | + | Origin: ports.SessionOriginLocal, | |
| 29 | + | Policy: wireTestPolicy(), | |
| 30 | + | Purpose: ports.BrokerStreamAttachment, | |
| 31 | + | Admission: ports.BrokerAdmissionExact, | |
| 32 | + | Target: protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{4, 5, 6}, SessionName: "work"}, | |
| 33 | + | Env: []string{"TERM=xterm-256color"}, | |
| 34 | + | } | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | // TestServerConnectionAdmissionProviderPreservedWithoutWireChange proves the | |
| 38 | + | // admission-aware constructor stores the provisioned metadata and exposes it | |
| 39 | + | // through the optional provider, while a successful typed round trip over the | |
| 40 | + | // same connection is byte-for-byte the ordinary session traffic: the admission | |
| 41 | + | // never leaks onto the session wire. | |
| 42 | + | func TestServerConnectionAdmissionProviderPreservedWithoutWireChange(t *testing.T) { | |
| 43 | + | raw := &scriptedTransport{recv: mustServerPreambleQueue(t, mustEncodeClient(t, protocol.Ping{}))} | |
| 44 | + | deadline := time.Now().Add(protocol.HandshakeTimeout) | |
| 45 | + | connection := NewServerConnectionWithAdmission(raw, deadline, wireTestAdmission()) | |
| 46 | + | ||
| 47 | + | provider, ok := connection.(ports.SessionAdmissionProvider) | |
| 48 | + | require.True(t, ok, "the admission-aware connection implements the optional provider") | |
| 49 | + | admission, ok := provider.SessionAdmission() | |
| 50 | + | require.True(t, ok) | |
| 51 | + | require.Equal(t, wireTestAdmission(), admission) | |
| 52 | + | require.NoError(t, admission.Validate()) | |
| 53 | + | ||
| 54 | + | // A typed exchange on the same connection succeeds and the sent application | |
| 55 | + | // frame is the ordinary Pong encoding: the admission is not part of the | |
| 56 | + | // session wire. (The preamble response precedes it.) | |
| 57 | + | message, err := connection.ReceiveClient() | |
| 58 | + | require.NoError(t, err) | |
| 59 | + | require.Equal(t, protocol.Ping{}, message) | |
| 60 | + | require.NoError(t, connection.SendServer(protocol.Pong{})) | |
| 61 | + | require.Equal(t, 2, raw.sentLen()) | |
| 62 | + | got, err := DecodeServerEnvelope(raw.sentPayload(1)) | |
| 63 | + | require.NoError(t, err) | |
| 64 | + | require.Equal(t, protocol.Pong{}, got) | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | // TestServerConnectionAdmissionProviderIsDefensiveCopy proves the provider hands | |
| 68 | + | // out an independent copy on every read: a consumer that mutates what it holds | |
| 69 | + | // never alters the connection's stored admission or a sibling's view. | |
| 70 | + | func TestServerConnectionAdmissionProviderIsDefensiveCopy(t *testing.T) { | |
| 71 | + | raw := &scriptedTransport{} | |
| 72 | + | connection := NewServerConnectionWithAdmission(raw, time.Now().Add(protocol.HandshakeTimeout), wireTestAdmission()) | |
| 73 | + | provider := connection.(ports.SessionAdmissionProvider) | |
| 74 | + | ||
| 75 | + | mutated, ok := provider.SessionAdmission() | |
| 76 | + | require.True(t, ok) | |
| 77 | + | mutated.Env[0] = "TERM=mutated" | |
| 78 | + | mutated.Policy.Trust = "mutated" | |
| 79 | + | mutated.Target.SessionName = "mutated" | |
| 80 | + | ||
| 81 | + | reread, ok := provider.SessionAdmission() | |
| 82 | + | require.True(t, ok) | |
| 83 | + | require.Equal(t, wireTestAdmission(), reread) | |
| 84 | + | ||
| 85 | + | left, _ := provider.SessionAdmission() | |
| 86 | + | right, _ := provider.SessionAdmission() | |
| 87 | + | left.Env[0] = "TERM=left" | |
| 88 | + | require.Equal(t, "TERM=xterm-256color", right.Env[0]) | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | // TestServerConnectionAdmissionAbsentForLegacyConstructors proves the legacy | |
| 92 | + | // constructors carry no admission metadata: a consumer that asserts the | |
| 93 | + | // provider is told ok=false rather than trusted with a zero admission. | |
| 94 | + | func TestServerConnectionAdmissionAbsentForLegacyConstructors(t *testing.T) { | |
| 95 | + | for _, tc := range []struct { | |
| 96 | + | name string | |
| 97 | + | build func() ports.ServerConnection | |
| 98 | + | }{ | |
| 99 | + | {name: "NewServerConnection", build: func() ports.ServerConnection { return NewServerConnection(&scriptedTransport{}) }}, | |
| 100 | + | {name: "NewServerConnectionWithDeadline", build: func() ports.ServerConnection { | |
| 101 | + | return NewServerConnectionWithDeadline(&scriptedTransport{}, time.Now().Add(protocol.HandshakeTimeout)) | |
| 102 | + | }}, | |
| 103 | + | {name: "NewServerConnectionWithAdmission zero", build: func() ports.ServerConnection { | |
| 104 | + | return NewServerConnectionWithAdmission(&scriptedTransport{}, time.Now().Add(protocol.HandshakeTimeout), ports.SessionAdmission{}) | |
| 105 | + | }}, | |
| 106 | + | } { | |
| 107 | + | t.Run(tc.name, func(t *testing.T) { | |
| 108 | + | provider, ok := tc.build().(ports.SessionAdmissionProvider) | |
| 109 | + | require.True(t, ok) | |
| 110 | + | admission, present := provider.SessionAdmission() | |
| 111 | + | require.False(t, present) | |
| 112 | + | require.Equal(t, ports.SessionAdmission{}, admission) | |
| 113 | + | }) | |
| 114 | + | } | |
| 115 | + | } | |
M internal/adapters/uidriver/request.go +1 -6
| @@ -163,12 +163,7 @@ func decodeExpect(data []byte) (ports.UIExpect, error) { | |||
| 163 | 163 | result.TextContains = &text | |
| 164 | 164 | case "status": | |
| 165 | 165 | var status ports.UIPresentationStatus | |
| 166 | - | if json.Unmarshal(raw, &status) != nil { | |
| 167 | - | return result, invalidRequest() | |
| 168 | - | } | |
| 169 | - | switch status { | |
| 170 | - | case ports.UIStatusAttached, ports.UIStatusTransitioning, ports.UIStatusReconnecting, ports.UIStatusDetached: | |
| 171 | - | default: | |
| 166 | + | if json.Unmarshal(raw, &status) != nil || !status.Valid() { | |
| 172 | 167 | return result, invalidRequest() | |
| 173 | 168 | } | |
| 174 | 169 | result.Status = &status | |
M internal/adapters/uidriver/request_test.go +6 -0
| @@ -23,6 +23,12 @@ func TestDecodeRequestStrictOperationSchemas(t *testing.T) { | |||
| 23 | 23 | {"duplicate", `{"version":1,"id":1,"id":2,"op":"capture","attachment":"a"}`, ports.UIErrInvalidRequest}, | |
| 24 | 24 | {"escaped duplicate", `{"version":1,"id":1,"\u0069d":2,"op":"capture","attachment":"a"}`, ports.UIErrInvalidRequest}, | |
| 25 | 25 | {"nested duplicate", `{"version":1,"id":1,"op":"wait","attachment":"a","expect":{"status":"attached","status":"detached"}}`, ports.UIErrInvalidRequest}, | |
| 26 | + | {"picker predicate", `{"version":1,"id":1,"op":"wait","attachment":"a","expect":{"status":"picker"}}`, ""}, | |
| 27 | + | {"connecting predicate", `{"version":1,"id":1,"op":"wait","attachment":"a","expect":{"status":"connecting"}}`, ""}, | |
| 28 | + | {"removed detached predicate", `{"version":1,"id":1,"op":"wait","attachment":"a","expect":{"status":"detached"}}`, ports.UIErrInvalidRequest}, | |
| 29 | + | {"removed reconnecting predicate", `{"version":1,"id":1,"op":"wait","attachment":"a","expect":{"status":"reconnecting"}}`, ports.UIErrInvalidRequest}, | |
| 30 | + | {"removed transitioning predicate", `{"version":1,"id":1,"op":"wait","attachment":"a","expect":{"status":"transitioning"}}`, ports.UIErrInvalidRequest}, | |
| 31 | + | {"unknown status predicate", `{"version":1,"id":1,"op":"wait","attachment":"a","expect":{"status":"terminating"}}`, ports.UIErrInvalidRequest}, | |
| 26 | 32 | {"nested unknown", `{"version":1,"id":1,"op":"wait","attachment":"a","expect":{"focus":{"tab_id":"t","pane_id":"p","extra":"x"}}}`, ports.UIErrInvalidRequest}, | |
| 27 | 33 | {"unknown predicate", `{"version":1,"id":1,"op":"wait","attachment":"a","expect":{"regex":".*"}}`, ports.UIErrInvalidRequest}, | |
| 28 | 34 | {"empty predicate", `{"version":1,"id":1,"op":"wait","attachment":"a","expect":{}}`, ports.UIErrInvalidRequest}, | |
M internal/adapters/uiterm/terminal.go +19 -10
| @@ -82,7 +82,7 @@ func newTerminal(ctx context.Context, geometry domain.Geometry, attachmentHandle | |||
| 82 | 82 | screen: screen, | |
| 83 | 83 | mirror: allowOversized, | |
| 84 | 84 | geometry: geometry, | |
| 85 | - | context: ports.UIContext{AttachmentHandle: attachmentHandle, Generation: 1}, | |
| 85 | + | context: ports.UIContext{AttachmentHandle: attachmentHandle}, | |
| 86 | 86 | available: true, | |
| 87 | 87 | captureTooLarge: oversized, | |
| 88 | 88 | changes: make(chan struct{}, 1), | |
| @@ -195,12 +195,7 @@ func (t *Terminal) BeginOutput(context ports.UIContext) { | |||
| 195 | 195 | t.mu.Lock() | |
| 196 | 196 | defer t.mu.Unlock() | |
| 197 | 197 | if t.txDepth == 0 { | |
| 198 | - | if context.AttachmentHandle == "" { | |
| 199 | - | context.AttachmentHandle = t.context.AttachmentHandle | |
| 200 | - | } | |
| 201 | - | if context.Generation == 0 { | |
| 202 | - | context.Generation = t.context.Generation | |
| 203 | - | } | |
| 198 | + | context = t.completeContextLocked(context) | |
| 204 | 199 | t.context = context | |
| 205 | 200 | t.txFailed = false | |
| 206 | 201 | } | |
| @@ -234,15 +229,29 @@ func (t *Terminal) PublishContext(context ports.UIContext) error { | |||
| 234 | 229 | if t.closed || !t.available { | |
| 235 | 230 | return ports.ErrUIUnavailable | |
| 236 | 231 | } | |
| 232 | + | t.context = t.completeContextLocked(context) | |
| 233 | + | t.publishLocked() | |
| 234 | + | return nil | |
| 235 | + | } | |
| 236 | + | ||
| 237 | + | // completeContextLocked applies the published-context shape rule for one | |
| 238 | + | // published context. Only an Attached presentation may carry a session | |
| 239 | + | // identity, a committed output boundary, or an actionable generation, and only | |
| 240 | + | // it inherits a missing handle or generation from the previous context. Picker | |
| 241 | + | // and Connecting are normalised to the handle and status they state, so an | |
| 242 | + | // unattached publication can never expose an old attachment's identity or a | |
| 243 | + | // generation no attachment committed. | |
| 244 | + | func (t *Terminal) completeContextLocked(context ports.UIContext) ports.UIContext { | |
| 245 | + | if context.Status != ports.UIStatusAttached { | |
| 246 | + | return ports.UIContext{AttachmentHandle: context.AttachmentHandle, Status: context.Status} | |
| 247 | + | } | |
| 237 | 248 | if context.AttachmentHandle == "" { | |
| 238 | 249 | context.AttachmentHandle = t.context.AttachmentHandle | |
| 239 | 250 | } | |
| 240 | 251 | if context.Generation == 0 { | |
| 241 | 252 | context.Generation = t.context.Generation | |
| 242 | 253 | } | |
| 243 | - | t.context = context | |
| 244 | - | t.publishLocked() | |
| 245 | - | return nil | |
| 254 | + | return context | |
| 246 | 255 | } | |
| 247 | 256 | ||
| 248 | 257 | func (t *Terminal) Snapshot() (ports.UISnapshot, error) { | |
M internal/adapters/uiterm/terminal_test.go +38 -0
| @@ -218,3 +218,41 @@ func newTestTerminal(t *testing.T, columns, rows int) *Terminal { | |||
| 218 | 218 | t.Cleanup(terminal.Close) | |
| 219 | 219 | return terminal | |
| 220 | 220 | } | |
| 221 | + | ||
| 222 | + | // TestTerminalUnattachedPublicationKeepsStatedIdentity pins the published-context | |
| 223 | + | // shape rule at the terminal: only an Attached publication inherits a missing | |
| 224 | + | // handle or generation. A Picker or Connecting publication is recorded exactly | |
| 225 | + | // as stated, so an unattached capture never shows an old attachment's handle or | |
| 226 | + | // a generation no attachment committed. | |
| 227 | + | func TestTerminalUnattachedPublicationKeepsStatedIdentity(t *testing.T) { | |
| 228 | + | terminal := newTestTerminal(t, 4, 2) | |
| 229 | + | ||
| 230 | + | // Establish an attached context with a real handle and generation. | |
| 231 | + | if err := terminal.PublishContext(ports.UIContext{AttachmentHandle: "attached-handle", Generation: 6, Status: ports.UIStatusAttached}); err != nil { | |
| 232 | + | t.Fatal(err) | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | if err := terminal.PublishContext(ports.UIContext{AttachmentHandle: "picker-handle", Status: ports.UIStatusPicker}); err != nil { | |
| 236 | + | t.Fatal(err) | |
| 237 | + | } | |
| 238 | + | snapshot, err := terminal.Snapshot() | |
| 239 | + | if err != nil { | |
| 240 | + | t.Fatal(err) | |
| 241 | + | } | |
| 242 | + | if snapshot.Context.AttachmentHandle != "picker-handle" || snapshot.Context.Generation != 0 || snapshot.Context.Status != ports.UIStatusPicker { | |
| 243 | + | t.Fatalf("picker context = %#v", snapshot.Context) | |
| 244 | + | } | |
| 245 | + | ||
| 246 | + | // An Attached publication with a missing handle and generation still inherits | |
| 247 | + | // them, exactly as the attachment foreground relies on. | |
| 248 | + | if err := terminal.PublishContext(ports.UIContext{Status: ports.UIStatusAttached, OutputState: 3}); err != nil { | |
| 249 | + | t.Fatal(err) | |
| 250 | + | } | |
| 251 | + | snapshot, err = terminal.Snapshot() | |
| 252 | + | if err != nil { | |
| 253 | + | t.Fatal(err) | |
| 254 | + | } | |
| 255 | + | if snapshot.Context.AttachmentHandle != "picker-handle" || snapshot.Context.Generation != 0 { | |
| 256 | + | t.Fatalf("attached continuity context = %#v", snapshot.Context) | |
| 257 | + | } | |
| 258 | + | } | |
D internal/app/attach_preflight.go +0 -142
| @@ -1,142 +0,0 @@ | |||
| 1 | - | package app | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "context" | |
| 5 | - | "fmt" | |
| 6 | - | "io" | |
| 7 | - | "log/slog" | |
| 8 | - | "os" | |
| 9 | - | ||
| 10 | - | "github.com/bnema/vev/internal/ports" | |
| 11 | - | "github.com/bnema/vev/internal/protocol" | |
| 12 | - | "github.com/bnema/vev/internal/usecase/confirm" | |
| 13 | - | "github.com/bnema/vev/pkg/rawterm" | |
| 14 | - | ) | |
| 15 | - | ||
| 16 | - | const maxAttachTargetHandoffs = 32 | |
| 17 | - | ||
| 18 | - | // resolveMissingSessionAttach decides the attach intent for a direct local | |
| 19 | - | // attach before any client attempt runs. When the session exists, or when | |
| 20 | - | // existence cannot be determined, it keeps IntentAttach and the daemon | |
| 21 | - | // rejection (if any) surfaces unchanged. When the session is absent it | |
| 22 | - | // offers creation on an interactive console and returns IntentNew on | |
| 23 | - | // confirmation; decline keeps IntentAttach, and a cancelled wait surfaces | |
| 24 | - | // the cancellation error. | |
| 25 | - | // | |
| 26 | - | // The check runs before the client owns any console reader, so the create | |
| 27 | - | // prompt is always the sole stdin consumer: no failed attempt can leave a | |
| 28 | - | // blocked input-pump read behind to steal the answer. Provenance comes from | |
| 29 | - | // the listing itself, so no error-text matching is needed and in-client | |
| 30 | - | // navigation handoffs can never trigger creation of the requested session. | |
| 31 | - | func resolveMissingSessionAttach(ctx context.Context, name string, deps runAttachDeps, log *slog.Logger) (uint8, error) { | |
| 32 | - | switch exists, err := sessionExists(ctx, name, deps); { | |
| 33 | - | case err != nil: | |
| 34 | - | if log != nil { | |
| 35 | - | log.Debug("missing-session preflight unavailable; proceeding with attach", "err", err) | |
| 36 | - | } | |
| 37 | - | case !exists: | |
| 38 | - | return confirmMissingSessionCreate(ctx, name, deps) | |
| 39 | - | } | |
| 40 | - | return protocol.IntentAttach, nil | |
| 41 | - | } | |
| 42 | - | ||
| 43 | - | // terminalIsInteractive reports whether the prompt can read its answer from | |
| 44 | - | // a console. The client terminal's input has to be a real terminal file: | |
| 45 | - | // pipes, redirected files, and detached readers are not consoles, so the | |
| 46 | - | // prompt never blocks on a stream nobody reads from the keyboard. | |
| 47 | - | func terminalIsInteractive(terminal ports.Terminal) bool { | |
| 48 | - | if terminal == nil { | |
| 49 | - | return false | |
| 50 | - | } | |
| 51 | - | file, ok := terminal.In().(*os.File) | |
| 52 | - | return ok && rawterm.IsTerminal(int(file.Fd())) | |
| 53 | - | } | |
| 54 | - | ||
| 55 | - | // consoleProbe returns the console probe used by the create prompt. | |
| 56 | - | // Production probes the client terminal's input; tests inject the decision | |
| 57 | - | // so the prompt matrix runs without owning a real TTY. | |
| 58 | - | func consoleProbe(deps runAttachDeps) func(ports.Terminal) bool { | |
| 59 | - | if deps.interactiveConsole != nil { | |
| 60 | - | return deps.interactiveConsole | |
| 61 | - | } | |
| 62 | - | return terminalIsInteractive | |
| 63 | - | } | |
| 64 | - | ||
| 65 | - | // flushingWriter pushes every prompt write to the console before the | |
| 66 | - | // confirmer blocks on the answer. Terminal output is buffered, so without | |
| 67 | - | // this flush the user waits in front of a blank console until the client | |
| 68 | - | // repaints. | |
| 69 | - | type flushingWriter struct { | |
| 70 | - | out io.Writer | |
| 71 | - | flush func() error | |
| 72 | - | } | |
| 73 | - | ||
| 74 | - | func (w flushingWriter) Write(data []byte) (int, error) { | |
| 75 | - | written, err := w.out.Write(data) | |
| 76 | - | if err != nil { | |
| 77 | - | return written, err | |
| 78 | - | } | |
| 79 | - | if err := w.flush(); err != nil { | |
| 80 | - | return written, err | |
| 81 | - | } | |
| 82 | - | return written, nil | |
| 83 | - | } | |
| 84 | - | ||
| 85 | - | // confirmMissingSessionCreate prompts to create an absent session and maps | |
| 86 | - | // the answer to the attach intent: IntentNew on confirmation, IntentAttach | |
| 87 | - | // on decline. A cancelled wait surfaces the cancellation error. | |
| 88 | - | // Non-interactive consoles, a declined answer, or unreadable input keep | |
| 89 | - | // IntentAttach. Only y/yes answers confirm; empty, no, and unknown answers | |
| 90 | - | // decline. | |
| 91 | - | func confirmMissingSessionCreate(ctx context.Context, name string, deps runAttachDeps) (uint8, error) { | |
| 92 | - | if name == "" || ctx.Err() != nil { | |
| 93 | - | return protocol.IntentAttach, nil | |
| 94 | - | } | |
| 95 | - | terminal := clientTerminal(deps) | |
| 96 | - | if !consoleProbe(deps)(terminal) { | |
| 97 | - | return protocol.IntentAttach, nil | |
| 98 | - | } | |
| 99 | - | in, out := terminal.In(), terminal.Out() | |
| 100 | - | if in == nil || out == nil { | |
| 101 | - | return protocol.IntentAttach, nil | |
| 102 | - | } | |
| 103 | - | prompt := confirm.NewConfirmer(in, flushingWriter{out: out, flush: terminal.Flush}) | |
| 104 | - | create, err := prompt.ConfirmContext(ctx, fmt.Sprintf("vev: session %q doesn't exist, want to create and attach to it?", name)) | |
| 105 | - | if err != nil { | |
| 106 | - | if ctxErr := ctx.Err(); ctxErr != nil { | |
| 107 | - | return protocol.IntentAttach, ctxErr | |
| 108 | - | } | |
| 109 | - | // A console that cannot show the question must not consume an | |
| 110 | - | // answer: keep the plain attach path and let the daemon decide. | |
| 111 | - | return protocol.IntentAttach, nil | |
| 112 | - | } | |
| 113 | - | if !create { | |
| 114 | - | return protocol.IntentAttach, nil | |
| 115 | - | } | |
| 116 | - | if err := ctx.Err(); err != nil { | |
| 117 | - | return protocol.IntentAttach, err | |
| 118 | - | } | |
| 119 | - | return protocol.IntentNew, nil | |
| 120 | - | } | |
| 121 | - | ||
| 122 | - | // sessionExists reports whether a directly attached local session exists. | |
| 123 | - | // An error means existence could not be determined. | |
| 124 | - | func sessionExists(ctx context.Context, name string, deps runAttachDeps) (bool, error) { | |
| 125 | - | if name == "" { | |
| 126 | - | return false, nil | |
| 127 | - | } | |
| 128 | - | dial := deps.localDialer | |
| 129 | - | if dial == nil { | |
| 130 | - | dial = defaultLocalDialer | |
| 131 | - | } | |
| 132 | - | sessions, err := listSessionsWithDialer(ctx, dial().Dial) | |
| 133 | - | if err != nil { | |
| 134 | - | return false, err | |
| 135 | - | } | |
| 136 | - | for _, session := range sessions { | |
| 137 | - | if session.Name == name { | |
| 138 | - | return true, nil | |
| 139 | - | } | |
| 140 | - | } | |
| 141 | - | return false, nil | |
| 142 | - | } | |
A internal/app/broker_client.go +535 -0
| @@ -0,0 +1,535 @@ | |||
| 1 | + | package app | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "fmt" | |
| 7 | + | "os" | |
| 8 | + | "sync" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/vev/internal/adapters/clock" | |
| 11 | + | "github.com/bnema/vev/internal/adapters/term" | |
| 12 | + | "github.com/bnema/vev/internal/domain" | |
| 13 | + | "github.com/bnema/vev/internal/domain/terminalcap" | |
| 14 | + | "github.com/bnema/vev/internal/ports" | |
| 15 | + | "github.com/bnema/vev/internal/protocol" | |
| 16 | + | "github.com/bnema/vev/internal/protocol/catalogue" | |
| 17 | + | "github.com/bnema/vev/internal/usecase/client" | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | // Common broker client composition (Plan 001 P7.4a). | |
| 21 | + | // | |
| 22 | + | // runBrokerClient is the single terminal composition every frontend promotes: | |
| 23 | + | // the sandbox terminal harness, the sandbox UI-driver harness, and the ordinary | |
| 24 | + | // production terminal attach all build one brokerClientConfig and delegate | |
| 25 | + | // here. It constructs exactly one client.Picker and exactly one | |
| 26 | + | // client.Supervisor and hands the whole run to Supervisor.Run, so no frontend | |
| 27 | + | // keeps a parallel picker, supervisor, attachment stream, or daemon dialer. | |
| 28 | + | // | |
| 29 | + | // The composition owns only what the supervisor cannot: the connector (or the | |
| 30 | + | // production connect-or-spawn behind it), the terminal, the clock, the optional | |
| 31 | + | // UI publication/action seam, the one-shot initial navigation intent (or the | |
| 32 | + | // single-shot resolver that translates a CLI target into it), the copied | |
| 33 | + | // attachment environment, the optional lifecycle-action channel, and the | |
| 34 | + | // presentation callbacks. It never opens a stream, resolves a key, or drives | |
| 35 | + | // picker input itself. | |
| 36 | + | ||
| 37 | + | // brokerClientConfig is the common client configuration shared by every | |
| 38 | + | // frontend. Connector, Terminal, and Clock are required (a nil Clock falls back | |
| 39 | + | // to the process clock); the remaining fields are optional and keep the | |
| 40 | + | // supervisor defaults when they are left zero. | |
| 41 | + | type brokerClientConfig struct { | |
| 42 | + | Connector ports.BrokerConnector | |
| 43 | + | Terminal ports.Terminal | |
| 44 | + | Clock ports.Clock | |
| 45 | + | UI *client.UI | |
| 46 | + | // InitialNavigation is the one-shot intent decided before the connection. | |
| 47 | + | // Its zero value is InitialNavigationPicker, which opens nothing. | |
| 48 | + | InitialNavigation client.InitialNavigation | |
| 49 | + | // ResolveInitialNavigation translates a composition-owned CLI target (an | |
| 50 | + | // attach name or a remote endpoint) into an exact navigation identity | |
| 51 | + | // against the first committed broker publication. It is mutually exclusive | |
| 52 | + | // with a non-zero InitialNavigation. | |
| 53 | + | ResolveInitialNavigation client.InitialNavigationResolver | |
| 54 | + | // AttachmentEnvironment is the copied process context of each attachment | |
| 55 | + | // Hello. It is never read from the broker process. | |
| 56 | + | AttachmentEnvironment client.AttachmentEnvironment | |
| 57 | + | // SessionEnvironment is the process snapshot for session creation. The | |
| 58 | + | // production frontends capture one LocalCLI snapshot before NewSupervisor; | |
| 59 | + | // offline and test compositions pass an explicit valid value (LocalCLI | |
| 60 | + | // with empty Env/Cwd unless the test asserts env). A zero value falls | |
| 61 | + | // back to the production LocalCLI snapshot inside runBrokerClient so the | |
| 62 | + | // ordinary terminal path keeps no second capture. | |
| 63 | + | SessionEnvironment client.SessionEnvironment | |
| 64 | + | // LifecycleActions carries explicit attachment lifecycle requests. A nil | |
| 65 | + | // channel disables external lifecycle actions. | |
| 66 | + | LifecycleActions <-chan client.AttachmentLifecycleAction | |
| 67 | + | // OnState observes every supervisor state. Optional. | |
| 68 | + | OnState func(client.State) | |
| 69 | + | // OnFailure surfaces one typed attachment or connectivity failure without | |
| 70 | + | // ending the run. Optional. | |
| 71 | + | OnFailure func(error) | |
| 72 | + | // OnLifecycle reports typed, bounded lifecycle transitions. Optional. | |
| 73 | + | OnLifecycle func(client.LifecycleNotice) | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | // brokerClientPresentation is the single terminal writer of one broker client | |
| 77 | + | // run. Rendering the picker and reporting a bounded failure both go through it, | |
| 78 | + | // so a failure notice can never interleave bytes with a picker frame. | |
| 79 | + | type brokerClientPresentation struct { | |
| 80 | + | terminal ports.Terminal | |
| 81 | + | picker *client.Picker | |
| 82 | + | // ui is the run's UI owner. When set, the picker frame is written inside the | |
| 83 | + | // terminal's UI output transaction under the Picker presentation, exactly like | |
| 84 | + | // daemon output, so the committed snapshot and the painted bytes describe the | |
| 85 | + | // same unattached state. Optional: a test may paint without an observation | |
| 86 | + | // channel. | |
| 87 | + | ui *client.UI | |
| 88 | + | onState func(client.State) | |
| 89 | + | ||
| 90 | + | mu sync.Mutex | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | // Render observes the state and paints the picker frame and notice while the | |
| 94 | + | // shared client.PickerPresentation fence admits the presentation. That is | |
| 95 | + | // exactly PresentPicker: an admitted attachment foreground owns the same | |
| 96 | + | // terminal writer before its attached marker and initial publication, and a | |
| 97 | + | // terminating process is leaving the terminal, so neither is ever painted over. | |
| 98 | + | // Picker is therefore never painted while the supervisor presents Connecting. | |
| 99 | + | // | |
| 100 | + | // The frame is committed through the terminal's UI output transaction under the | |
| 101 | + | // Picker presentation, so the published context and the painted bytes are one | |
| 102 | + | // coherent unattached state; Picker carries the run's handle with no session | |
| 103 | + | // identity and no actionable generation. An unavailable observation channel | |
| 104 | + | // leaves the frame written and flushed, exactly like the attachment foreground. | |
| 105 | + | // | |
| 106 | + | // ResizeEvents has exactly one consumer for the whole supervisor run (its | |
| 107 | + | // attachment geometry collector); rendering samples Geometry here at render | |
| 108 | + | // time instead of competing for that single event channel, so a repaint always | |
| 109 | + | // observes the latest size. | |
| 110 | + | func (p *brokerClientPresentation) Render(state client.State) { | |
| 111 | + | if p == nil { | |
| 112 | + | return | |
| 113 | + | } | |
| 114 | + | if p.onState != nil { | |
| 115 | + | p.onState(state) | |
| 116 | + | } | |
| 117 | + | if !client.PickerPresentation(state) { | |
| 118 | + | return | |
| 119 | + | } | |
| 120 | + | geometry, err := p.terminal.Geometry() | |
| 121 | + | if err != nil { | |
| 122 | + | return | |
| 123 | + | } | |
| 124 | + | frame := p.picker.Render(geometry.Size) | |
| 125 | + | notice := p.picker.RenderNotice(geometry.Size) | |
| 126 | + | if len(frame) == 0 && len(notice) == 0 { | |
| 127 | + | return | |
| 128 | + | } | |
| 129 | + | p.mu.Lock() | |
| 130 | + | defer p.mu.Unlock() | |
| 131 | + | writer := p.terminal.Out() | |
| 132 | + | if writer == nil { | |
| 133 | + | return | |
| 134 | + | } | |
| 135 | + | transaction, _ := p.terminal.(ports.UIOutputTransaction) | |
| 136 | + | if transaction != nil { | |
| 137 | + | transaction.BeginOutput(p.pickerContext()) | |
| 138 | + | if err := transaction.PublishContext(p.pickerContext()); err != nil && !errors.Is(err, ports.ErrUIUnavailable) { | |
| 139 | + | transaction.EndOutput(false) | |
| 140 | + | return | |
| 141 | + | } | |
| 142 | + | } | |
| 143 | + | success := true | |
| 144 | + | if len(frame) != 0 { | |
| 145 | + | if _, writeErr := writer.Write(frame); writeErr != nil { | |
| 146 | + | success = false | |
| 147 | + | } | |
| 148 | + | } | |
| 149 | + | if success && len(notice) != 0 { | |
| 150 | + | if _, writeErr := writer.Write(notice); writeErr != nil { | |
| 151 | + | success = false | |
| 152 | + | } | |
| 153 | + | } | |
| 154 | + | if success && p.terminal.Flush() != nil { | |
| 155 | + | success = false | |
| 156 | + | } | |
| 157 | + | if transaction != nil { | |
| 158 | + | transaction.EndOutput(success) | |
| 159 | + | } | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | // pickerContext is the Picker presentation of this run: the stable UI handle and | |
| 163 | + | // the status alone. It is the shape every unattached publication uses, so a | |
| 164 | + | // capture of the painted picker never shows an old attachment's session | |
| 165 | + | // identity or generation. | |
| 166 | + | func (p *brokerClientPresentation) pickerContext() ports.UIContext { | |
| 167 | + | context := ports.UIContext{Status: ports.UIStatusPicker} | |
| 168 | + | if p.ui != nil { | |
| 169 | + | context.AttachmentHandle = p.ui.Handle() | |
| 170 | + | } | |
| 171 | + | return context | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | // Failure reports one bounded attachment or connectivity failure on the same | |
| 175 | + | // terminal writer. The supervisor already reports a typed, bounded lifecycle | |
| 176 | + | // notice through the picker, so this is the complementary diagnostic: one | |
| 177 | + | // bounded line under the shared writer lock, never a raw cause chain and never | |
| 178 | + | // a competing terminal owner. | |
| 179 | + | func (p *brokerClientPresentation) Failure(err error) { | |
| 180 | + | if p == nil || err == nil { | |
| 181 | + | return | |
| 182 | + | } | |
| 183 | + | text := err.Error() | |
| 184 | + | if len(text) > ports.BrokerMaxErrorBytes { | |
| 185 | + | text = text[:ports.BrokerMaxErrorBytes] | |
| 186 | + | } | |
| 187 | + | p.mu.Lock() | |
| 188 | + | defer p.mu.Unlock() | |
| 189 | + | writer := p.terminal.Out() | |
| 190 | + | if writer == nil { | |
| 191 | + | return | |
| 192 | + | } | |
| 193 | + | if _, writeErr := writer.Write([]byte("\r\nvev: " + text + "\r\n")); writeErr != nil { | |
| 194 | + | return | |
| 195 | + | } | |
| 196 | + | _ = p.terminal.Flush() | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | // runBrokerClient builds exactly one picker and one supervisor over cfg and | |
| 200 | + | // delegates the whole run to the supervisor. It returns the supervisor's | |
| 201 | + | // terminal cause: a clean terminal EOF, an explicit exit, or the run's | |
| 202 | + | // cancellation, joined with any failure the supervisor classified as terminal. | |
| 203 | + | func runBrokerClient(ctx context.Context, cfg brokerClientConfig) error { | |
| 204 | + | if cfg.Terminal == nil { | |
| 205 | + | return errors.New("vev: broker client requires a terminal") | |
| 206 | + | } | |
| 207 | + | clk := cfg.Clock | |
| 208 | + | if clk == nil { | |
| 209 | + | clk = clock.New() | |
| 210 | + | } | |
| 211 | + | sessionEnv := cfg.SessionEnvironment | |
| 212 | + | if sessionEnv.Provenance == client.SessionEnvironmentUnspecified { | |
| 213 | + | // Production fallback: capture one LocalCLI process snapshot before | |
| 214 | + | // NewSupervisor so ordinary terminal attach keeps no second capture. | |
| 215 | + | sessionEnv = terminalSessionEnvironment() | |
| 216 | + | } | |
| 217 | + | attachmentEnv := cfg.AttachmentEnvironment | |
| 218 | + | if sessionEnv.Cwd != "" { | |
| 219 | + | attachmentEnv.Cwd = sessionEnv.Cwd | |
| 220 | + | } | |
| 221 | + | picker := client.NewPicker(clk, 0) | |
| 222 | + | presentation := &brokerClientPresentation{terminal: cfg.Terminal, picker: picker, ui: cfg.UI, onState: cfg.OnState} | |
| 223 | + | supervisor, err := client.NewSupervisor(client.SupervisorConfig{ | |
| 224 | + | Connector: cfg.Connector, | |
| 225 | + | Terminal: cfg.Terminal, | |
| 226 | + | Clock: clk, | |
| 227 | + | UI: cfg.UI, | |
| 228 | + | Picker: picker, | |
| 229 | + | InitialNavigation: cfg.InitialNavigation, | |
| 230 | + | ResolveInitialNavigation: cfg.ResolveInitialNavigation, | |
| 231 | + | AttachmentEnvironment: attachmentEnv, | |
| 232 | + | SessionEnvironment: sessionEnv, | |
| 233 | + | LifecycleActions: cfg.LifecycleActions, | |
| 234 | + | Render: presentation.Render, | |
| 235 | + | Notify: func(_ client.State, err error) { | |
| 236 | + | if cfg.OnFailure != nil { | |
| 237 | + | cfg.OnFailure(err) | |
| 238 | + | return | |
| 239 | + | } | |
| 240 | + | presentation.Failure(err) | |
| 241 | + | }, | |
| 242 | + | NotifyLifecycle: cfg.OnLifecycle, | |
| 243 | + | }) | |
| 244 | + | if err != nil { | |
| 245 | + | return err | |
| 246 | + | } | |
| 247 | + | return supervisor.Run(ctx) | |
| 248 | + | } | |
| 249 | + | ||
| 250 | + | // localEphemeralNavigation is the no-argument product intent: create one | |
| 251 | + | // ephemeral session on the broker-owned local daemon. It is decided before the | |
| 252 | + | // connection, so it carries no epoch and needs no resolver. | |
| 253 | + | func localEphemeralNavigation() client.InitialNavigation { | |
| 254 | + | return client.InitialNavigation{ | |
| 255 | + | Kind: client.InitialNavigationCreateEphemeral, | |
| 256 | + | Destination: ports.BrokerEndpointFence{Local: true}, | |
| 257 | + | } | |
| 258 | + | } | |
| 259 | + | ||
| 260 | + | // errTerminalNavigationUnresolved reports that a CLI target could not be | |
| 261 | + | // translated into the exact identity a snapshot carries. It is a local refusal: | |
| 262 | + | // no destination was dialed, so the supervisor surfaces it as a bounded | |
| 263 | + | // selection-unavailable notice and returns to the picker instead of attaching a | |
| 264 | + | // fallback or creating implicitly. | |
| 265 | + | var errTerminalNavigationUnresolved = errors.New("vev: attach target is not in the broker catalogue") | |
| 266 | + | ||
| 267 | + | // terminalBrokerNavigation translates the parsed terminal CLI intent into the | |
| 268 | + | // closed initial-navigation union. An intent that is fully determined before | |
| 269 | + | // the connection (local creation) is a value; an intent whose exact identity | |
| 270 | + | // only exists in a broker publication (`attach`, and any remote target) is a | |
| 271 | + | // single-shot resolver that runs once against the first committed snapshot. | |
| 272 | + | // | |
| 273 | + | // The translation never invents identity: a remote destination is only ever the | |
| 274 | + | // complete registration a snapshot carries, and an exact target is only ever | |
| 275 | + | // the lifecycle/name pair the snapshot publishes. A target the snapshot does not | |
| 276 | + | // carry is a refusal, never an implicit create and never a same-name fallback. | |
| 277 | + | func terminalBrokerNavigation(intent uint8, name, remoteTarget string) (client.InitialNavigation, client.InitialNavigationResolver, error) { | |
| 278 | + | if remoteTarget == "" { | |
| 279 | + | switch intent { | |
| 280 | + | case protocol.IntentEphemeral: | |
| 281 | + | return localEphemeralNavigation(), nil, nil | |
| 282 | + | case protocol.IntentNew: | |
| 283 | + | if err := domain.ValidateSessionName(name); err != nil { | |
| 284 | + | return client.InitialNavigation{}, nil, err | |
| 285 | + | } | |
| 286 | + | return client.InitialNavigation{ | |
| 287 | + | Kind: client.InitialNavigationCreateNamed, | |
| 288 | + | Destination: ports.BrokerEndpointFence{Local: true}, | |
| 289 | + | Name: name, | |
| 290 | + | }, nil, nil | |
| 291 | + | case protocol.IntentAttach: | |
| 292 | + | if err := domain.ValidateSessionName(name); err != nil { | |
| 293 | + | return client.InitialNavigation{}, nil, err | |
| 294 | + | } | |
| 295 | + | return client.InitialNavigation{}, localExactAttachResolver(name), nil | |
| 296 | + | default: | |
| 297 | + | return client.InitialNavigation{}, nil, usagef("unsupported attach intent %d", intent) | |
| 298 | + | } | |
| 299 | + | } | |
| 300 | + | if err := domain.ValidateRemoteHostTarget(remoteTarget); err != nil { | |
| 301 | + | return client.InitialNavigation{}, nil, err | |
| 302 | + | } | |
| 303 | + | switch intent { | |
| 304 | + | case protocol.IntentEphemeral: | |
| 305 | + | // `attach user@host` without a session keeps its product behavior: a | |
| 306 | + | // fresh ephemeral session on that remote daemon. | |
| 307 | + | return client.InitialNavigation{}, remoteEphemeralCreateResolver(remoteTarget), nil | |
| 308 | + | case protocol.IntentNew: | |
| 309 | + | if err := domain.ValidateSessionName(name); err != nil { | |
| 310 | + | return client.InitialNavigation{}, nil, err | |
| 311 | + | } | |
| 312 | + | return client.InitialNavigation{}, remoteNamedCreateResolver(remoteTarget, name), nil | |
| 313 | + | case protocol.IntentAttach: | |
| 314 | + | if err := domain.ValidateSessionName(name); err != nil { | |
| 315 | + | return client.InitialNavigation{}, nil, err | |
| 316 | + | } | |
| 317 | + | return client.InitialNavigation{}, remoteExactAttachResolver(remoteTarget, name), nil | |
| 318 | + | default: | |
| 319 | + | return client.InitialNavigation{}, nil, usagef("unsupported attach intent %d", intent) | |
| 320 | + | } | |
| 321 | + | } | |
| 322 | + | ||
| 323 | + | // localExactAttachResolver resolves one local session name to its exact | |
| 324 | + | // lifecycle in the committed publication. `attach <name>` is a target | |
| 325 | + | // translation only: a name the local observation does not carry is refused, so | |
| 326 | + | // the client returns to the picker with a notice instead of creating a session | |
| 327 | + | // implicitly. | |
| 328 | + | func localExactAttachResolver(name string) client.InitialNavigationResolver { | |
| 329 | + | return func(snapshot ports.BrokerSnapshot) (client.InitialNavigation, error) { | |
| 330 | + | observation, ok := localBrokerObservation(snapshot) | |
| 331 | + | if !ok { | |
| 332 | + | return client.InitialNavigation{}, fmt.Errorf("%w: no local daemon observation", errTerminalNavigationUnresolved) | |
| 333 | + | } | |
| 334 | + | target, ok := brokerObservationExactTarget(observation, name) | |
| 335 | + | if !ok { | |
| 336 | + | return client.InitialNavigation{}, fmt.Errorf("%w: %q", errTerminalNavigationUnresolved, name) | |
| 337 | + | } | |
| 338 | + | return client.InitialNavigation{ | |
| 339 | + | Kind: client.InitialNavigationAttachExact, | |
| 340 | + | Epoch: snapshot.Epoch, | |
| 341 | + | Destination: ports.BrokerEndpointFence{Local: true}, | |
| 342 | + | Target: target, | |
| 343 | + | }, nil | |
| 344 | + | } | |
| 345 | + | } | |
| 346 | + | ||
| 347 | + | // remoteExactAttachResolver resolves one remote session name to its exact | |
| 348 | + | // lifecycle under the endpoint's committed registration. The destination is the | |
| 349 | + | // complete observed registration (endpoint, incarnation, and generation), never | |
| 350 | + | // a bare hostname and never an invented daemon identity; an endpoint the broker | |
| 351 | + | // does not carry is refused rather than dialed. | |
| 352 | + | func remoteExactAttachResolver(endpoint, name string) client.InitialNavigationResolver { | |
| 353 | + | return func(snapshot ports.BrokerSnapshot) (client.InitialNavigation, error) { | |
| 354 | + | observation, ok := snapshot.Find(endpoint) | |
| 355 | + | if !ok { | |
| 356 | + | return client.InitialNavigation{}, fmt.Errorf("%w: %q is not a configured broker host", errTerminalNavigationUnresolved, endpoint) | |
| 357 | + | } | |
| 358 | + | target, ok := brokerObservationExactTarget(observation, name) | |
| 359 | + | if !ok { | |
| 360 | + | return client.InitialNavigation{}, fmt.Errorf("%w: %q on %q", errTerminalNavigationUnresolved, name, endpoint) | |
| 361 | + | } | |
| 362 | + | return client.InitialNavigation{ | |
| 363 | + | Kind: client.InitialNavigationAttachExact, | |
| 364 | + | Epoch: snapshot.Epoch, | |
| 365 | + | Destination: ports.BrokerEndpointFence{Registration: observation.Registration}, | |
| 366 | + | Target: target, | |
| 367 | + | }, nil | |
| 368 | + | } | |
| 369 | + | } | |
| 370 | + | ||
| 371 | + | func remoteNamedCreateResolver(endpoint, name string) client.InitialNavigationResolver { | |
| 372 | + | return func(snapshot ports.BrokerSnapshot) (client.InitialNavigation, error) { | |
| 373 | + | observation, ok := snapshot.Find(endpoint) | |
| 374 | + | if !ok { | |
| 375 | + | return client.InitialNavigation{}, fmt.Errorf("%w: %q is not a configured broker host", errTerminalNavigationUnresolved, endpoint) | |
| 376 | + | } | |
| 377 | + | return client.InitialNavigation{ | |
| 378 | + | Kind: client.InitialNavigationCreateNamed, | |
| 379 | + | Epoch: snapshot.Epoch, | |
| 380 | + | Destination: ports.BrokerEndpointFence{Registration: observation.Registration}, | |
| 381 | + | Name: name, | |
| 382 | + | }, nil | |
| 383 | + | } | |
| 384 | + | } | |
| 385 | + | ||
| 386 | + | // remoteEphemeralCreateResolver resolves `attach user@host` (no session) into a | |
| 387 | + | // remote ephemeral creation fenced to the endpoint's committed registration and | |
| 388 | + | // the epoch of the snapshot that resolved it. | |
| 389 | + | func remoteEphemeralCreateResolver(endpoint string) client.InitialNavigationResolver { | |
| 390 | + | return func(snapshot ports.BrokerSnapshot) (client.InitialNavigation, error) { | |
| 391 | + | observation, ok := snapshot.Find(endpoint) | |
| 392 | + | if !ok { | |
| 393 | + | return client.InitialNavigation{}, fmt.Errorf("%w: %q is not a configured broker host", errTerminalNavigationUnresolved, endpoint) | |
| 394 | + | } | |
| 395 | + | return client.InitialNavigation{ | |
| 396 | + | Kind: client.InitialNavigationCreateEphemeral, | |
| 397 | + | Epoch: snapshot.Epoch, | |
| 398 | + | Destination: ports.BrokerEndpointFence{Registration: observation.Registration}, | |
| 399 | + | }, nil | |
| 400 | + | } | |
| 401 | + | } | |
| 402 | + | ||
| 403 | + | // localBrokerObservation returns the single local daemon observation of one | |
| 404 | + | // publication. | |
| 405 | + | func localBrokerObservation(snapshot ports.BrokerSnapshot) (ports.BrokerDaemonObservation, bool) { | |
| 406 | + | for _, observation := range snapshot.Daemons { | |
| 407 | + | if observation.Local { | |
| 408 | + | return observation, true | |
| 409 | + | } | |
| 410 | + | } | |
| 411 | + | return ports.BrokerDaemonObservation{}, false | |
| 412 | + | } | |
| 413 | + | ||
| 414 | + | // brokerObservationExactTarget resolves one session name to the exact | |
| 415 | + | // lifecycle/name pair the observation publishes. A broken session is refused: | |
| 416 | + | // its identity is known but not attachable, so an explicit attempt would only be | |
| 417 | + | // a guaranteed refusal. | |
| 418 | + | func brokerObservationExactTarget(observation ports.BrokerDaemonObservation, name string) (protocol.ExactSessionTarget, bool) { | |
| 419 | + | for _, session := range observation.Sessions { | |
| 420 | + | if session.Name != name || session.State == catalogue.RemoteCatalogSessionBroken { | |
| 421 | + | continue | |
| 422 | + | } | |
| 423 | + | target := protocol.ExactSessionTarget{LifecycleID: session.LifecycleID, SessionName: session.Name} | |
| 424 | + | if err := target.Validate(); err != nil { | |
| 425 | + | return protocol.ExactSessionTarget{}, false | |
| 426 | + | } | |
| 427 | + | return target, true | |
| 428 | + | } | |
| 429 | + | return protocol.ExactSessionTarget{}, false | |
| 430 | + | } | |
| 431 | + | ||
| 432 | + | // productionBrokerConnector is every frontend's production broker connector. It | |
| 433 | + | // performs no I/O at construction: Connect connect-or-spawns the per-user | |
| 434 | + | // broker on demand, so a broker that is absent or incompatible is an ordinary | |
| 435 | + | // attempt failure under the supervisor's own backoff instead of a fatal startup | |
| 436 | + | // error. It never dials a daemon and never selects a transport. | |
| 437 | + | // | |
| 438 | + | // It is stateless beyond its connect function: the supervisor owns and closes | |
| 439 | + | // every service it adopts, so the connector keeps no eager connection and no | |
| 440 | + | // close path of its own. | |
| 441 | + | type productionBrokerConnector struct { | |
| 442 | + | connect func(context.Context) (ports.BrokerService, error) | |
| 443 | + | } | |
| 444 | + | ||
| 445 | + | func localBrokerOperationRoute(snapshot ports.BrokerSnapshot) client.BrokerOperationRoute { | |
| 446 | + | return client.BrokerOperationRoute{ | |
| 447 | + | Epoch: snapshot.Epoch, | |
| 448 | + | Destination: ports.BrokerEndpointFence{Local: true}, | |
| 449 | + | } | |
| 450 | + | } | |
| 451 | + | ||
| 452 | + | var _ ports.BrokerConnector = (*productionBrokerConnector)(nil) | |
| 453 | + | ||
| 454 | + | // newProductionBrokerConnector returns a lazy production connector. The | |
| 455 | + | // connect function is a composition seam for tests; production uses the shared | |
| 456 | + | // connect-or-spawn. | |
| 457 | + | func newProductionBrokerConnector() ports.BrokerConnector { | |
| 458 | + | return &productionBrokerConnector{connect: func(ctx context.Context) (ports.BrokerService, error) { | |
| 459 | + | return connectProductionClientBroker(ctx) | |
| 460 | + | }} | |
| 461 | + | } | |
| 462 | + | ||
| 463 | + | // Connect connect-or-spawns one broker connection for the calling attempt, | |
| 464 | + | // including the first one, so every attempt is under the supervisor's retry | |
| 465 | + | // cadence. | |
| 466 | + | func (c *productionBrokerConnector) Connect(ctx context.Context) (ports.BrokerService, error) { | |
| 467 | + | if c == nil || c.connect == nil { | |
| 468 | + | return nil, errors.New("vev: broker connector is not configured") | |
| 469 | + | } | |
| 470 | + | return c.connect(ctx) | |
| 471 | + | } | |
| 472 | + | ||
| 473 | + | // brokerClientRender adapts the shared presentation to the plain render callback | |
| 474 | + | // the composition tests exercise directly. | |
| 475 | + | func brokerClientRender(terminal ports.Terminal, picker *client.Picker, onState func(client.State)) func(client.State) { | |
| 476 | + | presentation := &brokerClientPresentation{terminal: terminal, picker: picker, onState: onState} | |
| 477 | + | return presentation.Render | |
| 478 | + | } | |
| 479 | + | ||
| 480 | + | // terminalBrokerSeams are the optional observation callbacks of one terminal | |
| 481 | + | // run. Production supplies none: runBrokerClient reports a failure on the | |
| 482 | + | // terminal itself through the same writer the picker paints with. The terminal | |
| 483 | + | // composition tests substitute every callback to observe the same run's states, | |
| 484 | + | // lifecycle notices, and failures. | |
| 485 | + | type terminalBrokerSeams struct { | |
| 486 | + | OnState func(client.State) | |
| 487 | + | OnLifecycle func(client.LifecycleNotice) | |
| 488 | + | OnFailure func(error) | |
| 489 | + | } | |
| 490 | + | ||
| 491 | + | // terminalBrokerCallbacks is the composition-root seam producing one terminal | |
| 492 | + | // run's callbacks. | |
| 493 | + | var terminalBrokerCallbacks = func() terminalBrokerSeams { return terminalBrokerSeams{} } | |
| 494 | + | ||
| 495 | + | // Composition-root seams for the terminal client. Production supplies the | |
| 496 | + | // production connect-or-spawn, the physical terminal, and the detached local | |
| 497 | + | // creation; tests substitute them so the terminal composition is exercised | |
| 498 | + | // without a production broker process, a real controlling terminal, or a real | |
| 499 | + | // daemon. | |
| 500 | + | var ( | |
| 501 | + | connectProductionClientBroker = connectProductionBroker | |
| 502 | + | terminalForAttach = func() ports.Terminal { return term.New() } | |
| 503 | + | createDetachedTerminalSession = createDetachedLocalSession | |
| 504 | + | ) | |
| 505 | + | ||
| 506 | + | // terminalAttachmentEnvironment copies the composition-owned process context an | |
| 507 | + | // attachment Hello carries. The supervisor and the broker never inspect the | |
| 508 | + | // process environment themselves. | |
| 509 | + | func terminalAttachmentEnvironment() client.AttachmentEnvironment { | |
| 510 | + | return client.AttachmentEnvironment{ | |
| 511 | + | TermEnv: os.Getenv("TERM"), | |
| 512 | + | Cwd: currentWorkingDirectory(), | |
| 513 | + | TrueColor: terminalcap.DetectTrueColor(os.Getenv("TERM"), os.Getenv("COLORTERM"), os.Environ()), | |
| 514 | + | } | |
| 515 | + | } | |
| 516 | + | ||
| 517 | + | // terminalSessionEnvironment captures one LocalCLI process snapshot for | |
| 518 | + | // session creation before NewSupervisor. The supervisor remaps each remote | |
| 519 | + | // request to Remote, so the base provenance stays LocalCLI without branching | |
| 520 | + | // in the app. | |
| 521 | + | func terminalSessionEnvironment() client.SessionEnvironment { | |
| 522 | + | return client.SessionEnvironment{ | |
| 523 | + | Provenance: client.SessionEnvironmentLocalCLI, | |
| 524 | + | Env: append([]string(nil), os.Environ()...), | |
| 525 | + | Cwd: currentWorkingDirectory(), | |
| 526 | + | } | |
| 527 | + | } | |
| 528 | + | ||
| 529 | + | func currentWorkingDirectory() string { | |
| 530 | + | cwd, err := os.Getwd() | |
| 531 | + | if err != nil { | |
| 532 | + | return "" | |
| 533 | + | } | |
| 534 | + | return cwd | |
| 535 | + | } | |
A internal/app/broker_client_test.go +1033 -0
| @@ -0,0 +1,1033 @@ | |||
| 1 | + | //go:build linux | |
| 2 | + | ||
| 3 | + | package app | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "errors" | |
| 8 | + | "io" | |
| 9 | + | "os" | |
| 10 | + | "sync" | |
| 11 | + | "testing" | |
| 12 | + | "time" | |
| 13 | + | ||
| 14 | + | "github.com/stretchr/testify/require" | |
| 15 | + | ||
| 16 | + | "github.com/bnema/vev/internal/adapters/brokeripc" | |
| 17 | + | "github.com/bnema/vev/internal/adapters/clock" | |
| 18 | + | "github.com/bnema/vev/internal/domain" | |
| 19 | + | "github.com/bnema/vev/internal/ports" | |
| 20 | + | "github.com/bnema/vev/internal/protocol" | |
| 21 | + | "github.com/bnema/vev/internal/protocol/catalogue" | |
| 22 | + | "github.com/bnema/vev/internal/usecase/client" | |
| 23 | + | ) | |
| 24 | + | ||
| 25 | + | // Terminal composition tests (Plan 001 P7.4a). | |
| 26 | + | // | |
| 27 | + | // runAttach is the ordinary terminal entry point. These tests drive it through | |
| 28 | + | // the composition-root seams only: connectProductionClientBroker supplies one | |
| 29 | + | // scripted broker connection and terminalForAttach supplies a recording | |
| 30 | + | // terminal, so the whole client supervisor runs without a production broker | |
| 31 | + | // process, a daemon dialer, or a controlling terminal. Every assertion is about | |
| 32 | + | // the composition contract: which broker stream the parsed CLI target resolves | |
| 33 | + | // to, that a refused target opens nothing, and that an admitted attachment | |
| 34 | + | // commits a real session publication to the terminal. | |
| 35 | + | ||
| 36 | + | const ( | |
| 37 | + | terminalCompositionEpoch = ports.BrokerEpoch(41) | |
| 38 | + | terminalCompositionGeneration = domain.RemoteGeneration(3) | |
| 39 | + | ) | |
| 40 | + | ||
| 41 | + | func terminalCompositionPolicy() ports.BrokerPolicy { | |
| 42 | + | return ports.BrokerPolicy{ | |
| 43 | + | ProtocolVersion: protocol.Version, | |
| 44 | + | CatalogSchemaVersion: catalogue.RemoteCatalogSchemaVersion, | |
| 45 | + | EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned, | |
| 46 | + | Transport: "unix-mux", | |
| 47 | + | Trust: "same-user", | |
| 48 | + | Launch: "explicit", | |
| 49 | + | Isolation: "per-user", | |
| 50 | + | } | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | func terminalCompositionRegistration(endpoint string) domain.RemoteRegistration { | |
| 54 | + | var incarnation [16]byte | |
| 55 | + | for i := range incarnation { | |
| 56 | + | incarnation[i] = byte(i + 1) | |
| 57 | + | } | |
| 58 | + | return domain.RemoteRegistration{Endpoint: endpoint, Incarnation: incarnation, Generation: terminalCompositionGeneration} | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | // terminalCompositionSnapshot publishes the local daemon plus the given remote | |
| 62 | + | // endpoints, each carrying exactly the sessions named. | |
| 63 | + | func terminalCompositionSnapshot(localSessions []string, remotes map[string][]string) ports.BrokerSnapshot { | |
| 64 | + | policy := terminalCompositionPolicy() | |
| 65 | + | observations := []ports.BrokerDaemonObservation{{ | |
| 66 | + | Local: true, | |
| 67 | + | DisplayOrigin: "local", | |
| 68 | + | Policy: policy, | |
| 69 | + | Identity: "local-daemon", | |
| 70 | + | Incarnation: ports.BrokerDaemonIncarnation{9}, | |
| 71 | + | ProtocolVersion: protocol.Version, | |
| 72 | + | Availability: domain.RemoteAvailabilityReachable, | |
| 73 | + | LastSuccess: time.Now(), | |
| 74 | + | InventoryKnown: true, | |
| 75 | + | Sessions: terminalCompositionSessions(localSessions), | |
| 76 | + | }} | |
| 77 | + | endpoints := make([]string, 0, len(remotes)) | |
| 78 | + | for endpoint := range remotes { | |
| 79 | + | endpoints = append(endpoints, endpoint) | |
| 80 | + | } | |
| 81 | + | sortStrings(endpoints) | |
| 82 | + | for _, endpoint := range endpoints { | |
| 83 | + | observations = append(observations, ports.BrokerDaemonObservation{ | |
| 84 | + | Endpoint: endpoint, | |
| 85 | + | DisplayOrigin: endpoint, | |
| 86 | + | Policy: policy, | |
| 87 | + | Registration: terminalCompositionRegistration(endpoint), | |
| 88 | + | Identity: ports.BrokerDaemonIdentity("identity-" + endpoint), | |
| 89 | + | Incarnation: ports.BrokerDaemonIncarnation{7}, | |
| 90 | + | ProtocolVersion: protocol.Version, | |
| 91 | + | Availability: domain.RemoteAvailabilityReachable, | |
| 92 | + | LastSuccess: time.Now(), | |
| 93 | + | InventoryKnown: true, | |
| 94 | + | Sessions: terminalCompositionSessions(remotes[endpoint]), | |
| 95 | + | }) | |
| 96 | + | } | |
| 97 | + | return ports.BrokerSnapshot{Epoch: terminalCompositionEpoch, Revision: 1, Daemons: observations} | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | func terminalCompositionSessions(names []string) []catalogue.RemoteCatalogSession { | |
| 101 | + | sessions := make([]catalogue.RemoteCatalogSession, 0, len(names)) | |
| 102 | + | for i, name := range names { | |
| 103 | + | var lifecycle domain.SessionLifecycleID | |
| 104 | + | lifecycle[0] = byte(i + 3) | |
| 105 | + | lifecycle[1] = 0x5a | |
| 106 | + | sessions = append(sessions, catalogue.RemoteCatalogSession{ | |
| 107 | + | LifecycleID: lifecycle, Name: name, State: catalogue.RemoteCatalogSessionUp, | |
| 108 | + | }) | |
| 109 | + | } | |
| 110 | + | return sessions | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | func sortStrings(values []string) { | |
| 114 | + | for i := 1; i < len(values); i++ { | |
| 115 | + | for j := i; j > 0 && values[j] < values[j-1]; j-- { | |
| 116 | + | values[j], values[j-1] = values[j-1], values[j] | |
| 117 | + | } | |
| 118 | + | } | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | // terminalCompositionService is the scripted broker service one terminal client | |
| 122 | + | // adopts. It records every opened stream request, serves one committed | |
| 123 | + | // publication, and answers each admitted attachment with a Welcome and one | |
| 124 | + | // initial full session frame so the supervisor reaches its attached | |
| 125 | + | // presentation. | |
| 126 | + | type terminalCompositionService struct { | |
| 127 | + | mu sync.Mutex | |
| 128 | + | snapshot ports.BrokerSnapshot | |
| 129 | + | closed bool | |
| 130 | + | next ports.BrokerStreamID | |
| 131 | + | opened []ports.BrokerOpenStreamRequest | |
| 132 | + | streams []*terminalCompositionStream | |
| 133 | + | done chan struct{} | |
| 134 | + | changed chan struct{} | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | func newTerminalCompositionService(snapshot ports.BrokerSnapshot) *terminalCompositionService { | |
| 138 | + | return &terminalCompositionService{ | |
| 139 | + | snapshot: snapshot, | |
| 140 | + | done: make(chan struct{}), | |
| 141 | + | changed: make(chan struct{}, 1), | |
| 142 | + | } | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | func (s *terminalCompositionService) ConnectionID() ports.BrokerConnectionID { | |
| 146 | + | return ports.BrokerConnectionID{4} | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | func (s *terminalCompositionService) NextStreamID() (ports.BrokerStreamID, error) { | |
| 150 | + | s.mu.Lock() | |
| 151 | + | defer s.mu.Unlock() | |
| 152 | + | if s.closed { | |
| 153 | + | return 0, ports.BrokerAdmissionClosed | |
| 154 | + | } | |
| 155 | + | s.next++ | |
| 156 | + | return s.next, nil | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | func (s *terminalCompositionService) Done() <-chan struct{} { return s.done } | |
| 160 | + | func (s *terminalCompositionService) Err() error { return nil } | |
| 161 | + | ||
| 162 | + | func (s *terminalCompositionService) Snapshot() ports.BrokerSnapshot { return s.snapshot.Clone() } | |
| 163 | + | ||
| 164 | + | func (s *terminalCompositionService) Subscribe() (ports.BrokerSubscription, error) { | |
| 165 | + | return terminalCompositionSubscription{changed: s.changed}, nil | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | func (s *terminalCompositionService) OpenStream(_ context.Context, request ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) { | |
| 169 | + | s.mu.Lock() | |
| 170 | + | if s.closed { | |
| 171 | + | s.mu.Unlock() | |
| 172 | + | return nil, ports.BrokerAdmissionClosed | |
| 173 | + | } | |
| 174 | + | s.opened = append(s.opened, request) | |
| 175 | + | stream := newTerminalCompositionStream(request) | |
| 176 | + | s.streams = append(s.streams, stream) | |
| 177 | + | s.mu.Unlock() | |
| 178 | + | return stream, nil | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | func (s *terminalCompositionService) CloseStream(ports.BrokerConnectionID, ports.BrokerStreamID) error { | |
| 182 | + | return nil | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | func (s *terminalCompositionService) AddHost(context.Context, string, ports.BrokerPolicy) (domain.RemoteRegistration, error) { | |
| 186 | + | return domain.RemoteRegistration{}, nil | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | func (s *terminalCompositionService) RemoveHost(context.Context, domain.RemoteRegistration) (bool, error) { | |
| 190 | + | return false, nil | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | func (s *terminalCompositionService) UpdateHostPolicy(context.Context, domain.RemoteRegistration, ports.BrokerPolicy) (domain.RemoteRegistration, error) { | |
| 194 | + | return domain.RemoteRegistration{}, nil | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | func (s *terminalCompositionService) RequestReconcile(string) {} | |
| 198 | + | ||
| 199 | + | func (s *terminalCompositionService) Close() error { | |
| 200 | + | s.mu.Lock() | |
| 201 | + | defer s.mu.Unlock() | |
| 202 | + | if !s.closed { | |
| 203 | + | s.closed = true | |
| 204 | + | close(s.done) | |
| 205 | + | } | |
| 206 | + | return nil | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | func (s *terminalCompositionService) openedRequests() []ports.BrokerOpenStreamRequest { | |
| 210 | + | s.mu.Lock() | |
| 211 | + | defer s.mu.Unlock() | |
| 212 | + | return append([]ports.BrokerOpenStreamRequest(nil), s.opened...) | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | // streamHello returns the Hello the supervisor sent on the single admitted | |
| 216 | + | // stream, proving the composition carried the CLI intent through to the typed | |
| 217 | + | // session handshake. | |
| 218 | + | func (s *terminalCompositionService) streamHello(t *testing.T) protocol.Hello { | |
| 219 | + | t.Helper() | |
| 220 | + | s.mu.Lock() | |
| 221 | + | streams := append([]*terminalCompositionStream(nil), s.streams...) | |
| 222 | + | s.mu.Unlock() | |
| 223 | + | require.Len(t, streams, 1) | |
| 224 | + | for _, message := range streams[0].sentMessages() { | |
| 225 | + | if hello, ok := message.(protocol.Hello); ok { | |
| 226 | + | return hello | |
| 227 | + | } | |
| 228 | + | } | |
| 229 | + | t.Fatal("the composed client never sent a session Hello") | |
| 230 | + | return protocol.Hello{} | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | type terminalCompositionSubscription struct{ changed chan struct{} } | |
| 234 | + | ||
| 235 | + | func (s terminalCompositionSubscription) Changed() <-chan struct{} { return s.changed } | |
| 236 | + | func (s terminalCompositionSubscription) Close() {} | |
| 237 | + | ||
| 238 | + | // terminalCompositionStream answers one admitted attachment with a Welcome and | |
| 239 | + | // one initial full session frame carrying real terminal bytes, so a test can | |
| 240 | + | // prove the committed publication reached the terminal. | |
| 241 | + | type terminalCompositionStream struct { | |
| 242 | + | request ports.BrokerOpenStreamRequest | |
| 243 | + | ||
| 244 | + | mu sync.Mutex | |
| 245 | + | sent []protocol.ClientMessage | |
| 246 | + | queue []protocol.ServerMessage | |
| 247 | + | closed bool | |
| 248 | + | done chan struct{} | |
| 249 | + | } | |
| 250 | + | ||
| 251 | + | func newTerminalCompositionStream(request ports.BrokerOpenStreamRequest) *terminalCompositionStream { | |
| 252 | + | stream := &terminalCompositionStream{request: request, done: make(chan struct{})} | |
| 253 | + | target := terminalCompositionCommittedTarget(request) | |
| 254 | + | stream.queue = append(stream.queue, protocol.Welcome{ | |
| 255 | + | SessionID: "sess", SessionName: request.Name, | |
| 256 | + | CommittedIdentity: &protocol.CommittedRouteIdentity{Target: target}, | |
| 257 | + | }) | |
| 258 | + | stream.queue = append(stream.queue, protocol.Output{ | |
| 259 | + | Epoch: 1, Full: true, New: 1, | |
| 260 | + | Size: domain.Size{Cols: 80, Rows: 24}, | |
| 261 | + | Context: &protocol.ViewContext{ | |
| 262 | + | Publication: 1, | |
| 263 | + | Route: protocol.CommittedRouteIdentity{Target: target}, | |
| 264 | + | TabID: domain.TabStableID("tab-1"), | |
| 265 | + | FocusedPaneID: domain.PaneStableID("pane-1"), | |
| 266 | + | }, | |
| 267 | + | Data: []byte("terminal-composition-ready\r\n"), | |
| 268 | + | }) | |
| 269 | + | return stream | |
| 270 | + | } | |
| 271 | + | ||
| 272 | + | // terminalCompositionCommittedTarget is the exact identity the scripted daemon | |
| 273 | + | // commits for one admitted request. An ephemeral creation has no requested name, | |
| 274 | + | // so the scripted daemon allocates one exactly as the real daemon does. | |
| 275 | + | func terminalCompositionCommittedTarget(request ports.BrokerOpenStreamRequest) protocol.ExactSessionTarget { | |
| 276 | + | if request.Admission == ports.BrokerAdmissionExact { | |
| 277 | + | return request.Target | |
| 278 | + | } | |
| 279 | + | name := request.Name | |
| 280 | + | if name == "" { | |
| 281 | + | name = "1" | |
| 282 | + | } | |
| 283 | + | var lifecycle domain.SessionLifecycleID | |
| 284 | + | lifecycle[0] = 0x11 | |
| 285 | + | return protocol.ExactSessionTarget{LifecycleID: lifecycle, SessionName: name} | |
| 286 | + | } | |
| 287 | + | ||
| 288 | + | func (s *terminalCompositionStream) SendClient(message protocol.ClientMessage) error { | |
| 289 | + | s.mu.Lock() | |
| 290 | + | defer s.mu.Unlock() | |
| 291 | + | s.sent = append(s.sent, message) | |
| 292 | + | return nil | |
| 293 | + | } | |
| 294 | + | ||
| 295 | + | func (s *terminalCompositionStream) ReceiveServer() (protocol.ServerMessage, error) { | |
| 296 | + | s.mu.Lock() | |
| 297 | + | if len(s.queue) != 0 { | |
| 298 | + | message := s.queue[0] | |
| 299 | + | s.queue = s.queue[1:] | |
| 300 | + | s.mu.Unlock() | |
| 301 | + | return message, nil | |
| 302 | + | } | |
| 303 | + | s.mu.Unlock() | |
| 304 | + | <-s.done | |
| 305 | + | return nil, io.EOF | |
| 306 | + | } | |
| 307 | + | ||
| 308 | + | func (s *terminalCompositionStream) Capabilities() protocol.ConnectionCapabilities { | |
| 309 | + | return protocol.ConnectionCapabilities{} | |
| 310 | + | } | |
| 311 | + | ||
| 312 | + | func (s *terminalCompositionStream) LinkState() ports.LinkState { return ports.LinkStateConnected } | |
| 313 | + | func (s *terminalCompositionStream) LinkEvents() <-chan ports.LinkEvent { | |
| 314 | + | return nil | |
| 315 | + | } | |
| 316 | + | ||
| 317 | + | func (s *terminalCompositionStream) Done() <-chan struct{} { return s.done } | |
| 318 | + | func (s *terminalCompositionStream) Err() error { return nil } | |
| 319 | + | ||
| 320 | + | func (s *terminalCompositionStream) Close() error { | |
| 321 | + | s.mu.Lock() | |
| 322 | + | defer s.mu.Unlock() | |
| 323 | + | if !s.closed { | |
| 324 | + | s.closed = true | |
| 325 | + | close(s.done) | |
| 326 | + | } | |
| 327 | + | return nil | |
| 328 | + | } | |
| 329 | + | ||
| 330 | + | func (s *terminalCompositionStream) sentMessages() []protocol.ClientMessage { | |
| 331 | + | s.mu.Lock() | |
| 332 | + | defer s.mu.Unlock() | |
| 333 | + | return append([]protocol.ClientMessage(nil), s.sent...) | |
| 334 | + | } | |
| 335 | + | ||
| 336 | + | // terminalCompositionTerminal is the terminal double: it records written bytes, | |
| 337 | + | // flushes, raw-mode entries/restores, and every UI publication so a test can | |
| 338 | + | // prove a committed attachment published a real frame. | |
| 339 | + | type terminalCompositionTerminal struct { | |
| 340 | + | mu sync.Mutex | |
| 341 | + | out []byte | |
| 342 | + | flushes int | |
| 343 | + | restores int | |
| 344 | + | statuses []ports.UIPresentationStatus | |
| 345 | + | changes chan struct{} | |
| 346 | + | input *terminalCompositionInput | |
| 347 | + | } | |
| 348 | + | ||
| 349 | + | func newTerminalCompositionTerminal() *terminalCompositionTerminal { | |
| 350 | + | return &terminalCompositionTerminal{changes: make(chan struct{}), input: newTerminalCompositionInput()} | |
| 351 | + | } | |
| 352 | + | ||
| 353 | + | // terminalCompositionInput is the supervisor's single terminal reader. It | |
| 354 | + | // yields bytes only when a test pushes them and reaches EOF when it closes, so | |
| 355 | + | // a client with no pushed input stays live until the test ends the run. | |
| 356 | + | type terminalCompositionInput struct { | |
| 357 | + | chunks chan []byte | |
| 358 | + | } | |
| 359 | + | ||
| 360 | + | func newTerminalCompositionInput() *terminalCompositionInput { | |
| 361 | + | return &terminalCompositionInput{chunks: make(chan []byte)} | |
| 362 | + | } | |
| 363 | + | ||
| 364 | + | func (i *terminalCompositionInput) Read(buffer []byte) (int, error) { | |
| 365 | + | chunk, ok := <-i.chunks | |
| 366 | + | if !ok { | |
| 367 | + | return 0, io.EOF | |
| 368 | + | } | |
| 369 | + | return copy(buffer, chunk), nil | |
| 370 | + | } | |
| 371 | + | ||
| 372 | + | func (t *terminalCompositionTerminal) EnterRaw() (func() error, error) { | |
| 373 | + | return func() error { | |
| 374 | + | t.mu.Lock() | |
| 375 | + | t.restores++ | |
| 376 | + | t.mu.Unlock() | |
| 377 | + | return nil | |
| 378 | + | }, nil | |
| 379 | + | } | |
| 380 | + | ||
| 381 | + | func (t *terminalCompositionTerminal) Geometry() (domain.Geometry, error) { | |
| 382 | + | return domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}, nil | |
| 383 | + | } | |
| 384 | + | ||
| 385 | + | func (t *terminalCompositionTerminal) ResizeEvents() <-chan domain.Geometry { return nil } | |
| 386 | + | ||
| 387 | + | // In blocks until the run ends: the supervisor owns exactly one terminal read | |
| 388 | + | // lifetime, and an immediately-empty reader would look like a terminal EOF and | |
| 389 | + | // end an otherwise live client. | |
| 390 | + | func (t *terminalCompositionTerminal) In() io.Reader { return t.input } | |
| 391 | + | ||
| 392 | + | func (t *terminalCompositionTerminal) Out() io.Writer { return t } | |
| 393 | + | ||
| 394 | + | func (t *terminalCompositionTerminal) Write(data []byte) (int, error) { | |
| 395 | + | t.mu.Lock() | |
| 396 | + | defer t.mu.Unlock() | |
| 397 | + | t.out = append(t.out, data...) | |
| 398 | + | return len(data), nil | |
| 399 | + | } | |
| 400 | + | ||
| 401 | + | func (t *terminalCompositionTerminal) Flush() error { | |
| 402 | + | t.mu.Lock() | |
| 403 | + | defer t.mu.Unlock() | |
| 404 | + | t.flushes++ | |
| 405 | + | return nil | |
| 406 | + | } | |
| 407 | + | ||
| 408 | + | func (t *terminalCompositionTerminal) BeginOutput(ports.UIContext) {} | |
| 409 | + | func (t *terminalCompositionTerminal) EndOutput(bool) {} | |
| 410 | + | ||
| 411 | + | func (t *terminalCompositionTerminal) PublishContext(context ports.UIContext) error { | |
| 412 | + | t.mu.Lock() | |
| 413 | + | defer t.mu.Unlock() | |
| 414 | + | t.statuses = append(t.statuses, context.Status) | |
| 415 | + | return nil | |
| 416 | + | } | |
| 417 | + | ||
| 418 | + | func (t *terminalCompositionTerminal) written() string { | |
| 419 | + | t.mu.Lock() | |
| 420 | + | defer t.mu.Unlock() | |
| 421 | + | return string(t.out) | |
| 422 | + | } | |
| 423 | + | ||
| 424 | + | func (t *terminalCompositionTerminal) publishes() []ports.UIPresentationStatus { | |
| 425 | + | t.mu.Lock() | |
| 426 | + | defer t.mu.Unlock() | |
| 427 | + | return append([]ports.UIPresentationStatus(nil), t.statuses...) | |
| 428 | + | } | |
| 429 | + | ||
| 430 | + | // startTerminalComposition drives the real runAttach entry point over one | |
| 431 | + | // scripted broker service and recording terminal. It substitutes only the two | |
| 432 | + | // composition seams (connect-or-spawn and the terminal) plus the callback seam, | |
| 433 | + | // so every assertion holds for the production terminal path itself and not for | |
| 434 | + | // a parallel test harness. | |
| 435 | + | type terminalCompositionRun struct { | |
| 436 | + | service *terminalCompositionService | |
| 437 | + | terminal *terminalCompositionTerminal | |
| 438 | + | states chan client.State | |
| 439 | + | notices chan client.LifecycleNotice | |
| 440 | + | failures chan error | |
| 441 | + | finished chan struct{} | |
| 442 | + | runMu sync.Mutex | |
| 443 | + | runErr error | |
| 444 | + | cancel context.CancelFunc | |
| 445 | + | } | |
| 446 | + | ||
| 447 | + | // wait returns the run's terminal error. It is idempotent: every caller waits on | |
| 448 | + | // the same closed channel, so a failure observed by one helper never consumes the | |
| 449 | + | // result another helper needs. | |
| 450 | + | func (r *terminalCompositionRun) wait(t *testing.T) error { | |
| 451 | + | t.Helper() | |
| 452 | + | select { | |
| 453 | + | case <-r.finished: | |
| 454 | + | case <-time.After(brokerTestWait): | |
| 455 | + | t.Fatal("terminal client did not stop") | |
| 456 | + | } | |
| 457 | + | r.runMu.Lock() | |
| 458 | + | defer r.runMu.Unlock() | |
| 459 | + | return r.runErr | |
| 460 | + | } | |
| 461 | + | ||
| 462 | + | func startTerminalComposition(t *testing.T, intent uint8, name, remoteTarget string, snapshot ports.BrokerSnapshot) *terminalCompositionRun { | |
| 463 | + | t.Helper() | |
| 464 | + | // The ordinary terminal path reads the nested-session and remote-transport | |
| 465 | + | // environment; a test drives the broker composition with both unset. | |
| 466 | + | t.Setenv("VEV", "") | |
| 467 | + | t.Setenv(envRemoteTransport, "") | |
| 468 | + | service := newTerminalCompositionService(snapshot) | |
| 469 | + | terminal := newTerminalCompositionTerminal() | |
| 470 | + | previousConnect := connectProductionClientBroker | |
| 471 | + | previousTerminal := terminalForAttach | |
| 472 | + | previousCallbacks := terminalBrokerCallbacks | |
| 473 | + | connectProductionClientBroker = func(context.Context) (ports.BrokerService, error) { | |
| 474 | + | return service, nil | |
| 475 | + | } | |
| 476 | + | terminalForAttach = func() ports.Terminal { return terminal } | |
| 477 | + | ||
| 478 | + | run := &terminalCompositionRun{ | |
| 479 | + | service: service, | |
| 480 | + | terminal: terminal, | |
| 481 | + | states: make(chan client.State, 64), | |
| 482 | + | notices: make(chan client.LifecycleNotice, 16), | |
| 483 | + | failures: make(chan error, 16), | |
| 484 | + | finished: make(chan struct{}), | |
| 485 | + | } | |
| 486 | + | terminalBrokerCallbacks = func() terminalBrokerSeams { | |
| 487 | + | return terminalBrokerSeams{ | |
| 488 | + | OnState: func(state client.State) { | |
| 489 | + | select { | |
| 490 | + | case run.states <- state: | |
| 491 | + | default: | |
| 492 | + | } | |
| 493 | + | }, | |
| 494 | + | OnLifecycle: func(notice client.LifecycleNotice) { | |
| 495 | + | select { | |
| 496 | + | case run.notices <- notice: | |
| 497 | + | default: | |
| 498 | + | } | |
| 499 | + | }, | |
| 500 | + | OnFailure: func(err error) { | |
| 501 | + | select { | |
| 502 | + | case run.failures <- err: | |
| 503 | + | default: | |
| 504 | + | } | |
| 505 | + | }, | |
| 506 | + | } | |
| 507 | + | } | |
| 508 | + | t.Cleanup(func() { | |
| 509 | + | connectProductionClientBroker = previousConnect | |
| 510 | + | terminalForAttach = previousTerminal | |
| 511 | + | terminalBrokerCallbacks = previousCallbacks | |
| 512 | + | }) | |
| 513 | + | ||
| 514 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 515 | + | run.cancel = cancel | |
| 516 | + | go func() { | |
| 517 | + | err := runAttach(ctx, intent, name, remoteTarget) | |
| 518 | + | run.runMu.Lock() | |
| 519 | + | run.runErr = err | |
| 520 | + | run.runMu.Unlock() | |
| 521 | + | close(run.finished) | |
| 522 | + | }() | |
| 523 | + | t.Cleanup(func() { | |
| 524 | + | cancel() | |
| 525 | + | select { | |
| 526 | + | case <-run.finished: | |
| 527 | + | case <-time.After(brokerTestWait): | |
| 528 | + | t.Error("terminal client did not stop") | |
| 529 | + | } | |
| 530 | + | }) | |
| 531 | + | return run | |
| 532 | + | } | |
| 533 | + | ||
| 534 | + | // awaitTerminalCompositionAttachment waits for the supervisor to present an | |
| 535 | + | // attached state and returns it. | |
| 536 | + | func awaitTerminalCompositionAttachment(t *testing.T, run *terminalCompositionRun) client.State { | |
| 537 | + | t.Helper() | |
| 538 | + | deadline := time.NewTimer(brokerTestWait) | |
| 539 | + | defer deadline.Stop() | |
| 540 | + | for { | |
| 541 | + | select { | |
| 542 | + | case state := <-run.states: | |
| 543 | + | if state.Presentation == client.PresentAttached { | |
| 544 | + | return state | |
| 545 | + | } | |
| 546 | + | case <-run.finished: | |
| 547 | + | t.Fatalf("terminal client ended before attaching: %v", run.wait(t)) | |
| 548 | + | case <-deadline.C: | |
| 549 | + | t.Fatal("terminal client never attached") | |
| 550 | + | } | |
| 551 | + | } | |
| 552 | + | } | |
| 553 | + | ||
| 554 | + | // awaitTerminalCompositionNotice waits for one bounded lifecycle notice. | |
| 555 | + | func awaitTerminalCompositionNotice(t *testing.T, run *terminalCompositionRun) client.LifecycleNotice { | |
| 556 | + | t.Helper() | |
| 557 | + | select { | |
| 558 | + | case notice := <-run.notices: | |
| 559 | + | return notice | |
| 560 | + | case <-time.After(brokerTestWait): | |
| 561 | + | t.Fatal("terminal client reported no lifecycle notice") | |
| 562 | + | return client.LifecycleNotice{} | |
| 563 | + | } | |
| 564 | + | } | |
| 565 | + | ||
| 566 | + | func TestTerminalNavigationTranslationClosesTheIntent(t *testing.T) { | |
| 567 | + | tests := []struct { | |
| 568 | + | name string | |
| 569 | + | intent uint8 | |
| 570 | + | session string | |
| 571 | + | remote string | |
| 572 | + | wantKind client.InitialNavigationKind | |
| 573 | + | wantLocal bool | |
| 574 | + | wantName string | |
| 575 | + | wantResolver bool | |
| 576 | + | wantError bool | |
| 577 | + | }{ | |
| 578 | + | {name: "no arguments creates a local ephemeral session", intent: protocol.IntentEphemeral, wantKind: client.InitialNavigationCreateEphemeral, wantLocal: true}, | |
| 579 | + | {name: "new creates a local named session", intent: protocol.IntentNew, session: "scratch", wantKind: client.InitialNavigationCreateNamed, wantLocal: true, wantName: "scratch"}, | |
| 580 | + | {name: "attach resolves a local exact target", intent: protocol.IntentAttach, session: "work", wantResolver: true}, | |
| 581 | + | {name: "remote attach resolves an exact remote target", intent: protocol.IntentAttach, session: "work", remote: "user@example.test", wantResolver: true}, | |
| 582 | + | {name: "remote new resolves a named remote creation", intent: protocol.IntentNew, session: "work", remote: "user@example.test", wantResolver: true}, | |
| 583 | + | {name: "remote ephemeral attach resolves a remote creation", intent: protocol.IntentEphemeral, remote: "user@example.test", wantResolver: true}, | |
| 584 | + | {name: "invalid local name is refused", intent: protocol.IntentNew, session: "bad name", wantError: true}, | |
| 585 | + | {name: "invalid remote target is refused", intent: protocol.IntentAttach, session: "work", remote: "bad target", wantError: true}, | |
| 586 | + | } | |
| 587 | + | for _, tc := range tests { | |
| 588 | + | t.Run(tc.name, func(t *testing.T) { | |
| 589 | + | navigation, resolver, err := terminalBrokerNavigation(tc.intent, tc.session, tc.remote) | |
| 590 | + | if tc.wantError { | |
| 591 | + | require.Error(t, err) | |
| 592 | + | require.Nil(t, resolver) | |
| 593 | + | require.Equal(t, client.InitialNavigation{}, navigation) | |
| 594 | + | return | |
| 595 | + | } | |
| 596 | + | require.NoError(t, err) | |
| 597 | + | if tc.wantResolver { | |
| 598 | + | require.NotNil(t, resolver) | |
| 599 | + | require.Equal(t, client.InitialNavigation{}, navigation) | |
| 600 | + | return | |
| 601 | + | } | |
| 602 | + | require.Nil(t, resolver) | |
| 603 | + | require.Equal(t, tc.wantKind, navigation.Kind) | |
| 604 | + | require.Equal(t, tc.wantLocal, navigation.Destination.Local) | |
| 605 | + | require.Equal(t, tc.wantName, navigation.Name) | |
| 606 | + | require.NoError(t, navigation.Validate()) | |
| 607 | + | }) | |
| 608 | + | } | |
| 609 | + | } | |
| 610 | + | ||
| 611 | + | // TestTerminalCompositionOpensTheExactBrokerStream pins the ordinary terminal | |
| 612 | + | // entry point to the broker: each parsed CLI target resolves to exactly one | |
| 613 | + | // admission on one broker logical stream, and the composition dials no daemon. | |
| 614 | + | func TestTerminalCompositionOpensTheExactBrokerStream(t *testing.T) { | |
| 615 | + | localSnapshot := func() ports.BrokerSnapshot { | |
| 616 | + | return terminalCompositionSnapshot([]string{"work"}, nil) | |
| 617 | + | } | |
| 618 | + | remoteSnapshot := func() ports.BrokerSnapshot { | |
| 619 | + | return terminalCompositionSnapshot(nil, map[string][]string{"user@example.test": {"work"}}) | |
| 620 | + | } | |
| 621 | + | tests := []struct { | |
| 622 | + | name string | |
| 623 | + | intent uint8 | |
| 624 | + | session string | |
| 625 | + | remote string | |
| 626 | + | snapshot ports.BrokerSnapshot | |
| 627 | + | wantAdmission ports.BrokerStreamAdmission | |
| 628 | + | wantLocal bool | |
| 629 | + | wantName string | |
| 630 | + | wantHello uint8 | |
| 631 | + | }{ | |
| 632 | + | {name: "no arguments creates a local ephemeral session", intent: protocol.IntentEphemeral, snapshot: localSnapshot(), wantAdmission: ports.BrokerAdmissionCreateEphemeral, wantLocal: true, wantHello: protocol.IntentEphemeral}, | |
| 633 | + | {name: "new creates a local named session", intent: protocol.IntentNew, session: "scratch", snapshot: localSnapshot(), wantAdmission: ports.BrokerAdmissionCreateNamed, wantLocal: true, wantName: "scratch", wantHello: protocol.IntentNew}, | |
| 634 | + | {name: "attach resolves the local session lifecycle", intent: protocol.IntentAttach, session: "work", snapshot: localSnapshot(), wantAdmission: ports.BrokerAdmissionExact, wantLocal: true, wantHello: protocol.IntentAttach}, | |
| 635 | + | {name: "remote attach resolves the remote session lifecycle", intent: protocol.IntentAttach, session: "work", remote: "user@example.test", snapshot: remoteSnapshot(), wantAdmission: ports.BrokerAdmissionExact, wantHello: protocol.IntentAttach}, | |
| 636 | + | } | |
| 637 | + | for _, tc := range tests { | |
| 638 | + | t.Run(tc.name, func(t *testing.T) { | |
| 639 | + | run := startTerminalComposition(t, tc.intent, tc.session, tc.remote, tc.snapshot) | |
| 640 | + | awaitTerminalCompositionAttachment(t, run) | |
| 641 | + | opened := run.service.openedRequests() | |
| 642 | + | require.Len(t, opened, 1, "one CLI target opens exactly one broker stream") | |
| 643 | + | request := opened[0] | |
| 644 | + | require.NoError(t, request.Validate()) | |
| 645 | + | require.Equal(t, terminalCompositionEpoch, request.Epoch) | |
| 646 | + | require.Equal(t, ports.BrokerStreamAttachment, request.Purpose) | |
| 647 | + | require.Equal(t, tc.wantAdmission, request.Admission) | |
| 648 | + | require.Equal(t, tc.wantLocal, request.Local) | |
| 649 | + | require.Equal(t, tc.wantName, request.Name) | |
| 650 | + | require.Equal(t, ports.BrokerConnectionID{4}, request.Connection) | |
| 651 | + | require.NotZero(t, request.Stream) | |
| 652 | + | require.NotEmpty(t, request.Policy.Transport) | |
| 653 | + | require.Equal(t, ports.BrokerDaemonStartIfNeeded, request.StartMode) | |
| 654 | + | if !tc.wantLocal { | |
| 655 | + | require.Equal(t, "user@example.test", request.Endpoint) | |
| 656 | + | require.Equal(t, terminalCompositionRegistration("user@example.test"), request.Registration) | |
| 657 | + | } | |
| 658 | + | if tc.wantAdmission == ports.BrokerAdmissionExact { | |
| 659 | + | require.NoError(t, request.Target.Validate()) | |
| 660 | + | require.Equal(t, tc.session, request.Target.SessionName) | |
| 661 | + | } | |
| 662 | + | // The same intent reaches the typed session handshake, so the | |
| 663 | + | // broker admission and the daemon Hello can never disagree. | |
| 664 | + | hello := run.service.streamHello(t) | |
| 665 | + | require.Equal(t, tc.wantHello, hello.Intent) | |
| 666 | + | require.Equal(t, tc.session, hello.Name) | |
| 667 | + | require.Equal(t, tc.remote != "", hello.Remote) | |
| 668 | + | if tc.wantAdmission == ports.BrokerAdmissionExact { | |
| 669 | + | require.NotNil(t, hello.ExactTarget) | |
| 670 | + | require.Equal(t, request.Target, *hello.ExactTarget) | |
| 671 | + | } | |
| 672 | + | }) | |
| 673 | + | } | |
| 674 | + | } | |
| 675 | + | ||
| 676 | + | // TestTerminalCompositionPublishesRealSessionOutput proves an admitted | |
| 677 | + | // attachment is a committed publication: the daemon's initial full frame reached | |
| 678 | + | // the terminal through a committed UI output transaction, and the supervisor | |
| 679 | + | // presented attached only after that commit. | |
| 680 | + | func TestTerminalCompositionPublishesRealSessionOutput(t *testing.T) { | |
| 681 | + | run := startTerminalComposition(t, protocol.IntentEphemeral, "", "", terminalCompositionSnapshot([]string{"work"}, nil)) | |
| 682 | + | awaitTerminalCompositionAttachment(t, run) | |
| 683 | + | require.Contains(t, run.terminal.written(), "terminal-composition-ready") | |
| 684 | + | // The initial publication is committed before the attached marker, so its | |
| 685 | + | // recorded boundary status is the pre-attachment Connecting presentation. | |
| 686 | + | require.Contains(t, run.terminal.publishes(), ports.UIStatusConnecting) | |
| 687 | + | require.Contains(t, run.terminal.publishes(), ports.UIStatusAttached) | |
| 688 | + | _, _, restores := run.terminalCounts() | |
| 689 | + | require.Zero(t, restores) | |
| 690 | + | } | |
| 691 | + | ||
| 692 | + | func (r *terminalCompositionRun) terminalCounts() (int, int, int) { | |
| 693 | + | r.terminal.mu.Lock() | |
| 694 | + | defer r.terminal.mu.Unlock() | |
| 695 | + | return len(r.terminal.out), r.terminal.flushes, r.terminal.restores | |
| 696 | + | } | |
| 697 | + | ||
| 698 | + | // TestTerminalCompositionRefusedTargetOpensNothingAndKeepsThePicker pins the | |
| 699 | + | // failure contract: a target the committed catalogue does not carry is a local | |
| 700 | + | // refusal. No stream is opened, the supervisor reports a bounded | |
| 701 | + | // selection-unavailable notice, and the process stays in the picker instead of | |
| 702 | + | // attaching a fallback or creating implicitly. | |
| 703 | + | func TestTerminalCompositionRefusedTargetOpensNothingAndKeepsThePicker(t *testing.T) { | |
| 704 | + | tests := []struct { | |
| 705 | + | name string | |
| 706 | + | intent uint8 | |
| 707 | + | session string | |
| 708 | + | remote string | |
| 709 | + | snapshot ports.BrokerSnapshot | |
| 710 | + | }{ | |
| 711 | + | {name: "missing local session", intent: protocol.IntentAttach, session: "gone", snapshot: terminalCompositionSnapshot([]string{"work"}, nil)}, | |
| 712 | + | {name: "local daemon absent", intent: protocol.IntentEphemeral, snapshot: ports.BrokerSnapshot{Epoch: terminalCompositionEpoch, Revision: 1}}, | |
| 713 | + | {name: "unconfigured remote host", intent: protocol.IntentAttach, session: "work", remote: "user@elsewhere.test", snapshot: terminalCompositionSnapshot([]string{"work"}, nil)}, | |
| 714 | + | {name: "missing remote session", intent: protocol.IntentAttach, session: "gone", remote: "user@example.test", snapshot: terminalCompositionSnapshot(nil, map[string][]string{"user@example.test": {"work"}})}, | |
| 715 | + | } | |
| 716 | + | for _, tc := range tests { | |
| 717 | + | t.Run(tc.name, func(t *testing.T) { | |
| 718 | + | run := startTerminalComposition(t, tc.intent, tc.session, tc.remote, tc.snapshot) | |
| 719 | + | require.Equal(t, client.LifecycleNoticeSelectionUnavailable, awaitTerminalCompositionNotice(t, run).Kind) | |
| 720 | + | // A local refusal is surfaced once, with the bounded text the supervisor | |
| 721 | + | // classifies; the resolver's own sentinel is deliberately not propagated | |
| 722 | + | // past that taxonomy, so only the presence and the classification are | |
| 723 | + | // asserted. | |
| 724 | + | select { | |
| 725 | + | case failure := <-run.failures: | |
| 726 | + | require.Error(t, failure) | |
| 727 | + | case <-time.After(brokerTestWait): | |
| 728 | + | t.Fatal("the refused target reported no failure") | |
| 729 | + | } | |
| 730 | + | require.Empty(t, run.service.openedRequests(), "a refused target never opens a stream") | |
| 731 | + | for { | |
| 732 | + | select { | |
| 733 | + | case state := <-run.states: | |
| 734 | + | require.Equal(t, client.PresentPicker, state.Presentation, "a refusal keeps the picker presentation") | |
| 735 | + | case <-time.After(100 * time.Millisecond): | |
| 736 | + | return | |
| 737 | + | } | |
| 738 | + | } | |
| 739 | + | }) | |
| 740 | + | } | |
| 741 | + | } | |
| 742 | + | ||
| 743 | + | // TestTerminalCompositionClosesRawModeOnTerminalEnd pins that the shared | |
| 744 | + | // composition restores the terminal exactly once when the run ends, however the | |
| 745 | + | // attachment settled. | |
| 746 | + | func TestTerminalCompositionClosesRawModeOnTerminalEnd(t *testing.T) { | |
| 747 | + | run := startTerminalComposition(t, protocol.IntentEphemeral, "", "", terminalCompositionSnapshot([]string{"work"}, nil)) | |
| 748 | + | awaitTerminalCompositionAttachment(t, run) | |
| 749 | + | run.cancel() | |
| 750 | + | require.NoError(t, ignoreContextCancellation(run.wait(t))) | |
| 751 | + | _, _, restores := run.terminalCounts() | |
| 752 | + | require.Equal(t, 1, restores, "raw mode is restored exactly once") | |
| 753 | + | } | |
| 754 | + | ||
| 755 | + | // TestTerminalCompositionCapturesSessionEnvironmentDefensively proves the test | |
| 756 | + | // terminal composition keeps its process snapshot defensive: it captures | |
| 757 | + | // env/Cwd, mutates the process seam after construction, and the supervisor | |
| 758 | + | // still creates without failure. | |
| 759 | + | func TestTerminalCompositionCapturesSessionEnvironmentDefensively(t *testing.T) { | |
| 760 | + | t.Setenv("VEV_TEST_SESSION_ENV", "composition-value") | |
| 761 | + | snapshot := terminalSessionEnvironment() | |
| 762 | + | require.Equal(t, client.SessionEnvironmentLocalCLI, snapshot.Provenance) | |
| 763 | + | require.NoError(t, snapshot.Validate()) | |
| 764 | + | require.Contains(t, snapshot.Env, "VEV_TEST_SESSION_ENV=composition-value") | |
| 765 | + | // Mutate the process seam after capture: the snapshot must keep the | |
| 766 | + | // captured value rather than aliasing live process state. | |
| 767 | + | require.NoError(t, os.Setenv("VEV_TEST_SESSION_ENV", "mutated-after-capture")) | |
| 768 | + | require.Contains(t, snapshot.Env, "VEV_TEST_SESSION_ENV=composition-value") | |
| 769 | + | require.NotContains(t, snapshot.Env, "VEV_TEST_SESSION_ENV=mutated-after-capture") | |
| 770 | + | clone := snapshot.Clone() | |
| 771 | + | // Mutate the clone's slice after capture: the supervisor creation must | |
| 772 | + | // not fail and the source slice stays intact, so the snapshot is a copy | |
| 773 | + | // rather than a live alias of the process environment. | |
| 774 | + | for i := range clone.Env { | |
| 775 | + | clone.Env[i] = "MUTATED=x" | |
| 776 | + | } | |
| 777 | + | require.NotEqual(t, snapshot.Env, clone.Env) | |
| 778 | + | servicesnapshot := terminalCompositionSnapshot([]string{"work"}, nil) | |
| 779 | + | service := newTerminalCompositionService(servicesnapshot) | |
| 780 | + | terminal := newTerminalCompositionTerminal() | |
| 781 | + | navigation := localEphemeralNavigation() | |
| 782 | + | _, err := client.NewSupervisor(client.SupervisorConfig{ | |
| 783 | + | Connector: scriptedBrokerConnector{service: service}, | |
| 784 | + | Terminal: terminal, | |
| 785 | + | Clock: clock.New(), | |
| 786 | + | // The picker is omitted: creation alone proves the snapshot does not | |
| 787 | + | // fail supervisor validation. | |
| 788 | + | InitialNavigation: navigation, | |
| 789 | + | SessionEnvironment: snapshot, | |
| 790 | + | }) | |
| 791 | + | require.NoError(t, err) | |
| 792 | + | } | |
| 793 | + | ||
| 794 | + | // TestTerminalCompositionReportsBrokerFailureInThePicker pins that a broker the | |
| 795 | + | // composition cannot connect to (or start) is no longer a fatal startup error: | |
| 796 | + | // the run enters raw mode, stays in the picker, and reports the typed failure | |
| 797 | + | // without opening any stream. It remains live until the terminal ends. | |
| 798 | + | func TestTerminalCompositionReportsBrokerFailureInThePicker(t *testing.T) { | |
| 799 | + | t.Setenv("VEV", "") | |
| 800 | + | t.Setenv(envRemoteTransport, "") | |
| 801 | + | startupErr := errors.New("broker not reachable") | |
| 802 | + | previousConnect := connectProductionClientBroker | |
| 803 | + | terminal := newTerminalCompositionTerminal() | |
| 804 | + | previousTerminal := terminalForAttach | |
| 805 | + | connectProductionClientBroker = func(context.Context) (ports.BrokerService, error) { return nil, startupErr } | |
| 806 | + | terminalForAttach = func() ports.Terminal { return terminal } | |
| 807 | + | t.Cleanup(func() { | |
| 808 | + | connectProductionClientBroker = previousConnect | |
| 809 | + | terminalForAttach = previousTerminal | |
| 810 | + | }) | |
| 811 | + | ||
| 812 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 813 | + | done := make(chan error, 1) | |
| 814 | + | go func() { done <- runAttach(ctx, protocol.IntentEphemeral, "", "") }() | |
| 815 | + | ||
| 816 | + | // A broker failure is surfaced, not fatal, and no stream is opened. | |
| 817 | + | require.Eventually(t, func() bool { | |
| 818 | + | for _, status := range terminal.publishes() { | |
| 819 | + | if status == ports.UIStatusConnecting || status == ports.UIStatusAttached { | |
| 820 | + | return false | |
| 821 | + | } | |
| 822 | + | } | |
| 823 | + | return true | |
| 824 | + | }, 200*time.Millisecond, 10*time.Millisecond) | |
| 825 | + | select { | |
| 826 | + | case err := <-done: | |
| 827 | + | t.Fatalf("the composition ended on a broker failure: %v", err) | |
| 828 | + | case <-time.After(200 * time.Millisecond): | |
| 829 | + | } | |
| 830 | + | _, _, restores := terminalCounts(terminal) | |
| 831 | + | require.Zero(t, restores, "the run is still live, so raw mode is not yet restored") | |
| 832 | + | ||
| 833 | + | cancel() | |
| 834 | + | require.NoError(t, ignoreContextCancellation(<-done)) | |
| 835 | + | _, _, restores = terminalCounts(terminal) | |
| 836 | + | require.Equal(t, 1, restores, "the cancelled run restores raw mode exactly once") | |
| 837 | + | } | |
| 838 | + | ||
| 839 | + | // TestRunAttachNestedSessionBehavior pins the nested-session product contract of | |
| 840 | + | // the terminal path: an explicit `new <name>` inside an existing session still | |
| 841 | + | // creates the session without attaching, and every other intent is refused | |
| 842 | + | // without reaching the broker. | |
| 843 | + | func TestRunAttachNestedSessionBehavior(t *testing.T) { | |
| 844 | + | t.Setenv("VEV", "outer") | |
| 845 | + | t.Setenv(envRemoteTransport, "") | |
| 846 | + | connectCalls := 0 | |
| 847 | + | previousConnect := connectProductionClientBroker | |
| 848 | + | previousCreate := createDetachedTerminalSession | |
| 849 | + | var createdName string | |
| 850 | + | connectProductionClientBroker = func(context.Context) (ports.BrokerService, error) { | |
| 851 | + | connectCalls++ | |
| 852 | + | return nil, errors.New("the broker must not be reached") | |
| 853 | + | } | |
| 854 | + | createDetachedTerminalSession = func(_ context.Context, name string) error { | |
| 855 | + | createdName = name | |
| 856 | + | return nil | |
| 857 | + | } | |
| 858 | + | t.Cleanup(func() { | |
| 859 | + | connectProductionClientBroker = previousConnect | |
| 860 | + | createDetachedTerminalSession = previousCreate | |
| 861 | + | }) | |
| 862 | + | ||
| 863 | + | require.NoError(t, runAttach(context.Background(), protocol.IntentNew, "scratch", "")) | |
| 864 | + | require.Equal(t, "scratch", createdName) | |
| 865 | + | ||
| 866 | + | for _, intent := range []uint8{protocol.IntentEphemeral, protocol.IntentAttach} { | |
| 867 | + | err := runAttach(context.Background(), intent, "scratch", "") | |
| 868 | + | require.ErrorContains(t, err, "sessions should be nested with care") | |
| 869 | + | } | |
| 870 | + | require.Zero(t, connectCalls, "a nested invocation never connects to the broker") | |
| 871 | + | } | |
| 872 | + | ||
| 873 | + | func terminalCounts(terminal *terminalCompositionTerminal) (int, int, int) { | |
| 874 | + | terminal.mu.Lock() | |
| 875 | + | defer terminal.mu.Unlock() | |
| 876 | + | return len(terminal.out), terminal.flushes, terminal.restores | |
| 877 | + | } | |
| 878 | + | ||
| 879 | + | // TestTerminalCompositionUsesTheBrokerConnector pins the production terminal | |
| 880 | + | // path source: runAttach composes the shared broker client over the process' | |
| 881 | + | // production connector and keeps no direct-dialer attach composition, attach | |
| 882 | + | // preflight, or client host registry. The old pre-cutover symbols are removed | |
| 883 | + | // outright, so this fails if any is reintroduced as a bypass. | |
| 884 | + | func TestTerminalCompositionUsesTheBrokerConnector(t *testing.T) { | |
| 885 | + | sources := loadAppSources(t, false) | |
| 886 | + | body := sources["run.go"] | |
| 887 | + | require.Contains(t, body, "runBrokerClient(ctx, brokerClientConfig{") | |
| 888 | + | require.Contains(t, body, "newProductionBrokerConnector()") | |
| 889 | + | for _, forbidden := range []string{ | |
| 890 | + | "runAttachWithDeps", "runAttachDeps", "localDaemonDialer", "dialOnlyLocalDialer", | |
| 891 | + | "defaultLocalDialer", "resolveMissingSessionAttach", "newClientHostRegistry", | |
| 892 | + | "listSessionsWithDialer", "clientEndpointFactory", | |
| 893 | + | } { | |
| 894 | + | require.NotContains(t, body, forbidden, "the terminal composition must not keep %q", forbidden) | |
| 895 | + | } | |
| 896 | + | } | |
| 897 | + | ||
| 898 | + | // TestTerminalExactAttachAcceptedByRealDaemon is the end-to-end regression for | |
| 899 | + | // the exact-attach wire shape: against the real broker and a real daemon, the | |
| 900 | + | // Hello the client supervisor builds for an exact admission (Intent=attach, | |
| 901 | + | // Name equal to the exact target's session name, ExactTarget set) is accepted | |
| 902 | + | // and answered with a committed Welcome. It fails if the strict Hello contract | |
| 903 | + | // and the worker disagree about the name. | |
| 904 | + | func TestTerminalExactAttachAcceptedByRealDaemon(t *testing.T) { | |
| 905 | + | fixture := startOfflineClientFixture(t) | |
| 906 | + | ctx, cancel := context.WithTimeout(context.Background(), brokerTestWait) | |
| 907 | + | defer cancel() | |
| 908 | + | service, err := brokeripc.NewConnector(fixture.socket, brokeripc.Config{}).Connect(ctx) | |
| 909 | + | require.NoError(t, err) | |
| 910 | + | defer service.Close() | |
| 911 | + | require.NotZero(t, awaitTerminalCompositionEpoch(t, service)) | |
| 912 | + | ||
| 913 | + | createID, err := service.NextStreamID() | |
| 914 | + | require.NoError(t, err) | |
| 915 | + | create, err := service.OpenStream(ctx, ports.BrokerOpenStreamRequest{ | |
| 916 | + | Purpose: ports.BrokerStreamAttachment, Stream: createID, Local: true, Policy: fixture.policy, | |
| 917 | + | Admission: ports.BrokerAdmissionCreateNamed, Name: "attach-exact", StartMode: ports.BrokerDaemonStartIfNeeded, | |
| 918 | + | }) | |
| 919 | + | require.NoError(t, err) | |
| 920 | + | require.NoError(t, create.SendClient(protocol.Hello{Version: protocol.Version, Intent: protocol.IntentNew, Name: "attach-exact", Size: domain.Size{Cols: 80, Rows: 24}})) | |
| 921 | + | target := receiveOfflineWelcome(t, create).CommittedIdentity.Target | |
| 922 | + | require.NoError(t, create.Close()) | |
| 923 | + | ||
| 924 | + | exactID, err := service.NextStreamID() | |
| 925 | + | require.NoError(t, err) | |
| 926 | + | exact, err := service.OpenStream(ctx, ports.BrokerOpenStreamRequest{ | |
| 927 | + | Purpose: ports.BrokerStreamAttachment, Stream: exactID, Local: true, Policy: fixture.policy, | |
| 928 | + | Admission: ports.BrokerAdmissionExact, Target: target, StartMode: ports.BrokerDaemonStartIfNeeded, | |
| 929 | + | }) | |
| 930 | + | require.NoError(t, err) | |
| 931 | + | defer exact.Close() | |
| 932 | + | ||
| 933 | + | // The exact Hello shape the client supervisor's worker builds for this | |
| 934 | + | // request; it must satisfy the protocol and be accepted by the daemon. | |
| 935 | + | hello := protocol.Hello{ | |
| 936 | + | Version: protocol.Version, Intent: protocol.IntentAttach, Name: target.SessionName, | |
| 937 | + | ExactTarget: &target, Size: domain.Size{Cols: 80, Rows: 24}, | |
| 938 | + | } | |
| 939 | + | require.NoError(t, protocol.ValidateHello(hello)) | |
| 940 | + | require.NoError(t, exact.SendClient(hello)) | |
| 941 | + | require.Equal(t, "attach-exact", receiveOfflineWelcome(t, exact).SessionName) | |
| 942 | + | } | |
| 943 | + | ||
| 944 | + | // TestTerminalCompositionExactAttachReportsMissingLocalInventory documents the | |
| 945 | + | // composition's behavior when the committed broker publication carries no local | |
| 946 | + | // session inventory: an exact attach is refused locally as a bounded selection | |
| 947 | + | // notice, opens no stream, and never invents a creation or a same-name | |
| 948 | + | // fallback. It pins the honest refusal so a future broker-side local catalogue | |
| 949 | + | // is the only thing that can make this navigation succeed. | |
| 950 | + | func TestTerminalCompositionExactAttachReportsMissingLocalInventory(t *testing.T) { | |
| 951 | + | fixture := startOfflineClientFixture(t) | |
| 952 | + | ctx, cancel := context.WithTimeout(context.Background(), brokerTestWait) | |
| 953 | + | defer cancel() | |
| 954 | + | ||
| 955 | + | terminal := newTerminalCompositionTerminal() | |
| 956 | + | notices := make(chan client.LifecycleNotice, 16) | |
| 957 | + | failures := make(chan error, 16) | |
| 958 | + | var lifecycleID domain.SessionLifecycleID | |
| 959 | + | lifecycleID[0], lifecycleID[1] = 3, 0x5a | |
| 960 | + | navigation := client.InitialNavigation{ | |
| 961 | + | Kind: client.InitialNavigationAttachExact, | |
| 962 | + | Epoch: terminalCompositionEpoch, | |
| 963 | + | Destination: ports.BrokerEndpointFence{Local: true}, | |
| 964 | + | Target: protocol.ExactSessionTarget{LifecycleID: lifecycleID, SessionName: "work"}, | |
| 965 | + | } | |
| 966 | + | require.NoError(t, navigation.Validate()) | |
| 967 | + | ||
| 968 | + | done := make(chan error, 1) | |
| 969 | + | go func() { | |
| 970 | + | done <- runBrokerClient(ctx, brokerClientConfig{ | |
| 971 | + | Connector: brokeripc.NewConnector(fixture.socket, brokeripc.Config{}), | |
| 972 | + | Terminal: terminal, | |
| 973 | + | InitialNavigation: navigation, | |
| 974 | + | SessionEnvironment: client.SessionEnvironment{Provenance: client.SessionEnvironmentLocalCLI}, | |
| 975 | + | OnFailure: func(err error) { | |
| 976 | + | select { | |
| 977 | + | case failures <- err: | |
| 978 | + | default: | |
| 979 | + | } | |
| 980 | + | }, | |
| 981 | + | OnLifecycle: func(notice client.LifecycleNotice) { | |
| 982 | + | select { | |
| 983 | + | case notices <- notice: | |
| 984 | + | default: | |
| 985 | + | } | |
| 986 | + | }, | |
| 987 | + | }) | |
| 988 | + | }() | |
| 989 | + | ||
| 990 | + | select { | |
| 991 | + | case notice := <-notices: | |
| 992 | + | require.Equal(t, client.LifecycleNoticeSelectionUnavailable, notice.Kind) | |
| 993 | + | case <-time.After(brokerTestWait): | |
| 994 | + | t.Fatal("the exact attach reported no lifecycle notice") | |
| 995 | + | } | |
| 996 | + | select { | |
| 997 | + | case failure := <-failures: | |
| 998 | + | require.Error(t, failure) | |
| 999 | + | case <-time.After(brokerTestWait): | |
| 1000 | + | t.Fatal("the exact attach reported no failure") | |
| 1001 | + | } | |
| 1002 | + | // The refused navigation never writes a session frame and never attaches: | |
| 1003 | + | // only the Picker presentation may be published, never a Connecting or | |
| 1004 | + | // Attached session presentation. | |
| 1005 | + | for _, status := range terminal.publishes() { | |
| 1006 | + | require.Equal(t, ports.UIStatusPicker, status, "a refused navigation publishes only Picker") | |
| 1007 | + | } | |
| 1008 | + | cancel() | |
| 1009 | + | require.NoError(t, ignoreContextCancellation(<-done)) | |
| 1010 | + | } | |
| 1011 | + | ||
| 1012 | + | // awaitTerminalCompositionEpoch waits for one connection's first committed | |
| 1013 | + | // publication and returns its epoch. | |
| 1014 | + | func awaitTerminalCompositionEpoch(t *testing.T, service ports.BrokerService) ports.BrokerEpoch { | |
| 1015 | + | t.Helper() | |
| 1016 | + | sub, err := service.Subscribe() | |
| 1017 | + | require.NoError(t, err) | |
| 1018 | + | defer sub.Close() | |
| 1019 | + | deadline := time.NewTimer(brokerTestWait) | |
| 1020 | + | defer deadline.Stop() | |
| 1021 | + | for { | |
| 1022 | + | if snapshot := service.Snapshot(); snapshot.Epoch != 0 { | |
| 1023 | + | return snapshot.Epoch | |
| 1024 | + | } | |
| 1025 | + | select { | |
| 1026 | + | case <-sub.Changed(): | |
| 1027 | + | case <-service.Done(): | |
| 1028 | + | t.Fatalf("broker connection ended before publishing a snapshot: %v", service.Err()) | |
| 1029 | + | case <-deadline.C: | |
| 1030 | + | t.Fatal("broker never published a snapshot") | |
| 1031 | + | } | |
| 1032 | + | } | |
| 1033 | + | } | |
A internal/app/broker_daemon_start.go +360 -0
| @@ -0,0 +1,360 @@ | |||
| 1 | + | package app | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "fmt" | |
| 7 | + | "os" | |
| 8 | + | "path/filepath" | |
| 9 | + | "syscall" | |
| 10 | + | ||
| 11 | + | "github.com/bnema/vev/internal/adapters/brokerconfig" | |
| 12 | + | "github.com/bnema/vev/internal/adapters/daemonmux" | |
| 13 | + | "github.com/bnema/vev/internal/adapters/ipc" | |
| 14 | + | "github.com/bnema/vev/internal/ports" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | // Broker-owned daemon start (Plan 001 P7, UI-driver slice 1). | |
| 18 | + | // | |
| 19 | + | // The broker is the only component that decides whether a target daemon may be | |
| 20 | + | // started. Its resolved dial target already carries the closed start | |
| 21 | + | // authorization (ports.BrokerDaemonStartMode) together with the policy and the | |
| 22 | + | // endpoint fence, and this file is the single place where that authorization | |
| 23 | + | // reaches transport mechanics: | |
| 24 | + | // | |
| 25 | + | // - BrokerDaemonExistingOnly dials the private daemonmux carriage and nothing | |
| 26 | + | // else. It never probes the lifecycle lock, never takes the spawn lock, | |
| 27 | + | // never launches a process, and never dials a session: an existing-only | |
| 28 | + | // acquisition is exactly one carriage dial. | |
| 29 | + | // - BrokerDaemonStartIfNeeded dials the same carriage first. A successful dial | |
| 30 | + | // is the whole result — an already running daemon is never restarted. Only | |
| 31 | + | // when the dial proves the daemon is absent does the start proceed, and only | |
| 32 | + | // when the resolved policy explicitly authorizes launching. The start itself | |
| 33 | + | // is the existing election in spawn.go: lifecycle availability, one elected | |
| 34 | + | // spawner under the shared spawn lock, then a bounded redial of the same | |
| 35 | + | // carriage under the same budget. | |
| 36 | + | // | |
| 37 | + | // The result handed to the pool is always daemonmux: no session transport is | |
| 38 | + | // ever produced here, so no caller can fall back to the direct session dial the | |
| 39 | + | // broker replaced. Readiness is proven by the carriage the pool then | |
| 40 | + | // authenticates with the daemonmux physical preamble, never by a session dial, | |
| 41 | + | // a session control stream, or a Hello. | |
| 42 | + | // | |
| 43 | + | // Refusals are explicit and typed. A daemon that is absent may only be started | |
| 44 | + | // under an authorizing policy; a carriage that exists but refuses the peer, the | |
| 45 | + | // path, or the credentials is never "fixed" by spawning a second daemon, and a | |
| 46 | + | // start that leaves the carriage still undialable is reported rather than | |
| 47 | + | // retried with a session dial. Identity, incarnation, policy, and version are | |
| 48 | + | // verified by the daemonmux preamble after this function returns, so those | |
| 49 | + | // failures can never trigger a spawn either. | |
| 50 | + | ||
| 51 | + | // brokerLaunchAuthority is the broker-owned policy launch token that explicitly | |
| 52 | + | // authorizes starting a stopped target daemon. The ports policy carries the | |
| 53 | + | // token opaquely — it never interprets it — so the component that owns "may | |
| 54 | + | // this daemon be started?" is the composition. The broker's own production local | |
| 55 | + | // daemon authority names this token; an operator-provisioned registration that | |
| 56 | + | // names something else, and every sandbox-private token, fails closed and never | |
| 57 | + | // spawns. | |
| 58 | + | const brokerLaunchAuthority = "explicit" | |
| 59 | + | ||
| 60 | + | // brokerDaemonStarter owns the three facts a daemon start needs: the private | |
| 61 | + | // daemonmux carriage dial, the detached daemon launcher, and the exact carriage | |
| 62 | + | // the launched daemon will publish. All three are seams so tests exercise the | |
| 63 | + | // real election, policy, and refusal rules without binding a socket or | |
| 64 | + | // re-executing a daemon. | |
| 65 | + | type brokerDaemonStarter struct { | |
| 66 | + | dial func(ctx context.Context, carriage string) (daemonmux.RawFramedTransport, error) | |
| 67 | + | spawn spawnFunc | |
| 68 | + | backoff backoffConfig | |
| 69 | + | // published returns the private daemonmux carriage the spawned daemon binds. | |
| 70 | + | // Nil means the production endpoint of this process. | |
| 71 | + | published func() string | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | // localDaemonStarter is the production starter: the real private Unix daemonmux | |
| 75 | + | // carriage, the real double-fork daemon launcher, and the exact endpoint that | |
| 76 | + | // launcher publishes. It is the broker process' starter and a remote mux | |
| 77 | + | // helper's starter alike, because both start the daemon published on their own | |
| 78 | + | // machine by re-executing their own binary. A test replaces it with scripted | |
| 79 | + | // mechanics and restores it immediately; it is package state for the same reason | |
| 80 | + | // defaultBackoff is, and no test mutates it in parallel. | |
| 81 | + | var localDaemonStarter = brokerDaemonStarter{dial: dialLocalDaemonCarriage, spawn: realSpawn} | |
| 82 | + | ||
| 83 | + | // effectiveBackoff returns the starter's explicit backoff, or the production | |
| 84 | + | // default read at call time, so a test that narrows defaultBackoff still governs | |
| 85 | + | // the daemon start. | |
| 86 | + | func (s brokerDaemonStarter) effectiveBackoff() backoffConfig { | |
| 87 | + | if s.backoff.total > 0 { | |
| 88 | + | return s.backoff | |
| 89 | + | } | |
| 90 | + | return defaultBackoff | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | // effectivePublished returns the private daemonmux carriage the spawned daemon | |
| 94 | + | // binds: the starter's explicit endpoint, or this process' own production | |
| 95 | + | // daemon endpoint. The daemon launcher re-executes this binary, which always | |
| 96 | + | // binds daemonmux.SocketPath(ipc.SocketDir()), so that is the only carriage a | |
| 97 | + | // start can ever produce. | |
| 98 | + | func (s brokerDaemonStarter) effectivePublished() string { | |
| 99 | + | if s.published != nil { | |
| 100 | + | return s.published() | |
| 101 | + | } | |
| 102 | + | return daemonmux.SocketPath(ipc.SocketDir()) | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | // dialLocalDaemonCarriage dials exactly the private daemonmux carriage at | |
| 106 | + | // carriage, verifying that the connected peer is the current effective user. It | |
| 107 | + | // is deliberately the mux carriage only: the daemon's public session socket is | |
| 108 | + | // never dialed here. | |
| 109 | + | func dialLocalDaemonCarriage(ctx context.Context, carriage string) (daemonmux.RawFramedTransport, error) { | |
| 110 | + | return ipc.DialMuxContext(ctx, carriage) | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | // dialBrokerLocalDaemon dials the daemon published on this machine for one | |
| 114 | + | // resolved local target. carriage is the private daemonmux carriage of the | |
| 115 | + | // broker-owned local route: that route is its only source, and this function | |
| 116 | + | // never derives, guesses, or accepts a carriage of its own. | |
| 117 | + | // | |
| 118 | + | // A registered endpoint is refused even when it is provisioned on a local Unix | |
| 119 | + | // carriage: only the broker-owned local authority names the daemon this | |
| 120 | + | // process can start, so a configured host can never be spawned by mistake. | |
| 121 | + | func dialBrokerLocalDaemon(ctx context.Context, carriage string, target ports.BrokerDialTarget) (daemonmux.RawFramedTransport, error) { | |
| 122 | + | if !target.Fence.Local { | |
| 123 | + | return nil, localDaemonStartRefused("the target is not the broker-owned local daemon") | |
| 124 | + | } | |
| 125 | + | return dialBrokerDaemonCarriage(ctx, carriage, target) | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | // dialBrokerDaemonCarriage is the shared carriage dial and start. The target's | |
| 129 | + | // start authorization decides the whole behavior. ExistingOnly performs exactly | |
| 130 | + | // one carriage dial. StartIfNeeded performs the same dial first, returns the | |
| 131 | + | // daemon it found, and only starts a daemon when the dial proves absence and the | |
| 132 | + | // resolved policy authorizes launching; otherwise it reports the refusal without | |
| 133 | + | // touching any process. | |
| 134 | + | // | |
| 135 | + | // A start is only ever possible for the one daemon this process can actually | |
| 136 | + | // produce: the launcher re-executes this binary, which binds its private | |
| 137 | + | // daemonmux carriage at daemonmux.SocketPath(ipc.SocketDir()) and holds its | |
| 138 | + | // lifecycle and spawn locks in that same runtime directory. A carriage that is | |
| 139 | + | // anything else is dialed and, if absent, reported — never replaced by a daemon | |
| 140 | + | // this process did not start. | |
| 141 | + | func dialBrokerDaemonCarriage(ctx context.Context, carriage string, target ports.BrokerDialTarget) (daemonmux.RawFramedTransport, error) { | |
| 142 | + | if err := target.Validate(); err != nil { | |
| 143 | + | return nil, ports.BrokerError{Code: ports.BrokerErrorIncompatible, Cause: err} | |
| 144 | + | } | |
| 145 | + | if err := validateDaemonCarriage(carriage); err != nil { | |
| 146 | + | return nil, err | |
| 147 | + | } | |
| 148 | + | starter := localDaemonStarter | |
| 149 | + | if starter.dial == nil || starter.spawn == nil { | |
| 150 | + | return nil, ports.BrokerError{Code: ports.BrokerErrorIncompatible, Cause: errors.New("vev: daemon starter is incomplete")} | |
| 151 | + | } | |
| 152 | + | raw, err := starter.dial(ctx, carriage) | |
| 153 | + | if err == nil { | |
| 154 | + | return raw, nil | |
| 155 | + | } | |
| 156 | + | if target.StartMode == ports.BrokerDaemonExistingOnly { | |
| 157 | + | // An existing-only acquisition is one dial: no lifecycle probe, no spawn | |
| 158 | + | // lock, no process, and no fallback of any kind. | |
| 159 | + | return nil, err | |
| 160 | + | } | |
| 161 | + | // A refusal that spawning could never repair is reported unchanged: a | |
| 162 | + | // cancelled attempt, a foreign path, a rejected peer, or a permission | |
| 163 | + | // failure is not daemon absence. | |
| 164 | + | if !daemonCarriageAbsence(err) { | |
| 165 | + | return nil, err | |
| 166 | + | } | |
| 167 | + | if err := validateDaemonCarriageStart(carriage, starter); err != nil { | |
| 168 | + | return nil, err | |
| 169 | + | } | |
| 170 | + | if err := daemonStartRefusal(target); err != nil { | |
| 171 | + | return nil, err | |
| 172 | + | } | |
| 173 | + | published := starter.effectivePublished() | |
| 174 | + | return ensureMuxDaemon(ctx, filepath.Dir(published), carriage, starter.dial, starter.spawn, starter.effectiveBackoff()) | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | // validateDaemonCarriage refuses a carriage that cannot be a provisioned private | |
| 178 | + | // daemonmux endpoint: it must be an absolute, cleaned path. The daemon launcher | |
| 179 | + | // re-executes this binary to start one specific daemon, so a relative, unclean, | |
| 180 | + | // or empty path can never trigger a spawn. | |
| 181 | + | func validateDaemonCarriage(carriage string) error { | |
| 182 | + | if carriage == "" { | |
| 183 | + | return ports.BrokerError{Code: ports.BrokerErrorIncompatible, Cause: errors.New("vev: daemon carriage path is empty")} | |
| 184 | + | } | |
| 185 | + | if !filepath.IsAbs(carriage) || filepath.Clean(carriage) != carriage { | |
| 186 | + | return ports.BrokerError{ | |
| 187 | + | Code: ports.BrokerErrorIncompatible, | |
| 188 | + | Cause: fmt.Errorf("vev: daemon carriage %q is not an absolute cleaned path", carriage), | |
| 189 | + | } | |
| 190 | + | } | |
| 191 | + | return nil | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | // validateDaemonCarriageStart refuses starting a daemon for any carriage other | |
| 195 | + | // than the one the launched daemon will actually publish. The launcher can only | |
| 196 | + | // ever produce the daemon bound to that exact private endpoint, so starting one | |
| 197 | + | // for a foreign carriage would spawn a process the caller could never reach. | |
| 198 | + | func validateDaemonCarriageStart(carriage string, starter brokerDaemonStarter) error { | |
| 199 | + | if want := starter.effectivePublished(); carriage != want { | |
| 200 | + | return ports.BrokerError{ | |
| 201 | + | Code: ports.BrokerErrorIncompatible, | |
| 202 | + | Cause: fmt.Errorf("vev: refusing to start a daemon for carriage %q: this process publishes %q", carriage, want), | |
| 203 | + | } | |
| 204 | + | } | |
| 205 | + | return nil | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | // daemonStartRefusal refuses a start-if-needed acquisition that must never reach | |
| 209 | + | // the spawn mechanics. The condition is checked before the election, so a | |
| 210 | + | // refused start creates no lock and launches no process. | |
| 211 | + | // | |
| 212 | + | // The mode is an authorization, never an obligation, and it can only narrow what | |
| 213 | + | // the resolved policy already permits. A policy that does not name the broker's | |
| 214 | + | // launch authority therefore refuses the start outright, which is exactly how a | |
| 215 | + | // provisioned configuration that forbids launching stays authoritative. | |
| 216 | + | func daemonStartRefusal(target ports.BrokerDialTarget) error { | |
| 217 | + | if target.StartMode != ports.BrokerDaemonStartIfNeeded { | |
| 218 | + | return localDaemonStartRefused("the acquisition does not authorize starting a daemon") | |
| 219 | + | } | |
| 220 | + | if target.Policy.Launch != brokerLaunchAuthority { | |
| 221 | + | return localDaemonStartRefused("the resolved policy does not authorize launching") | |
| 222 | + | } | |
| 223 | + | return nil | |
| 224 | + | } | |
| 225 | + | ||
| 226 | + | // localDaemonStartRefused reports a refusal to start a daemon as a typed broker | |
| 227 | + | // policy conflict. The reason is local diagnostic authority; it is never a | |
| 228 | + | // message the daemon produced. | |
| 229 | + | func localDaemonStartRefused(reason string) error { | |
| 230 | + | return ports.BrokerError{ | |
| 231 | + | Code: ports.BrokerErrorConflictingPolicy, | |
| 232 | + | Cause: fmt.Errorf("vev: refusing to start the daemon: %s", reason), | |
| 233 | + | } | |
| 234 | + | } | |
| 235 | + | ||
| 236 | + | // daemonCarriageAbsence reports whether a failed carriage dial means the daemon | |
| 237 | + | // is simply not there, so starting one is the only remaining option. It is | |
| 238 | + | // deliberately fail-closed: a cancelled attempt, an invalid or foreign path, a | |
| 239 | + | // rejected peer, a permission failure, and every unclassified error report | |
| 240 | + | // false, so only proven absence can authorize a spawn. | |
| 241 | + | func daemonCarriageAbsence(err error) bool { | |
| 242 | + | switch { | |
| 243 | + | case err == nil: | |
| 244 | + | return false | |
| 245 | + | case errors.Is(err, context.Canceled), errors.Is(err, context.DeadlineExceeded): | |
| 246 | + | return false | |
| 247 | + | case errors.Is(err, ipc.ErrMuxPath), errors.Is(err, ipc.ErrMuxForeignPath), | |
| 248 | + | errors.Is(err, ipc.ErrMuxPeerRejected), errors.Is(err, ipc.ErrMuxUnsupported): | |
| 249 | + | return false | |
| 250 | + | case errors.Is(err, os.ErrPermission): | |
| 251 | + | return false | |
| 252 | + | case errors.Is(err, os.ErrNotExist), errors.Is(err, syscall.ENOENT), | |
| 253 | + | errors.Is(err, syscall.ECONNREFUSED), errors.Is(err, syscall.EWOULDBLOCK): | |
| 254 | + | return true | |
| 255 | + | default: | |
| 256 | + | return false | |
| 257 | + | } | |
| 258 | + | } | |
| 259 | + | ||
| 260 | + | // brokerDaemonStartArgvFlag renders the closed start authorization as the exact | |
| 261 | + | // helper argument value. An unknown mode is refused, never defaulted: a helper | |
| 262 | + | // that cannot read its authorization must not run with a permissive one. | |
| 263 | + | func brokerDaemonStartArgvFlag(mode ports.BrokerDaemonStartMode) (string, error) { | |
| 264 | + | switch mode { | |
| 265 | + | case ports.BrokerDaemonExistingOnly: | |
| 266 | + | return "existing-only", nil | |
| 267 | + | case ports.BrokerDaemonStartIfNeeded: | |
| 268 | + | return "if-needed", nil | |
| 269 | + | default: | |
| 270 | + | return "", fmt.Errorf("vev: invalid broker daemon start mode %s", mode) | |
| 271 | + | } | |
| 272 | + | } | |
| 273 | + | ||
| 274 | + | // parseBrokerDaemonStartArgvFlag is the strict inverse of | |
| 275 | + | // brokerDaemonStartArgvFlag. An unknown, empty, or differently spelled value is | |
| 276 | + | // refused; a helper never maps an unrecognized word onto a start authorization. | |
| 277 | + | func parseBrokerDaemonStartArgvFlag(value string) (ports.BrokerDaemonStartMode, error) { | |
| 278 | + | switch value { | |
| 279 | + | case "existing-only": | |
| 280 | + | return ports.BrokerDaemonExistingOnly, nil | |
| 281 | + | case "if-needed": | |
| 282 | + | return ports.BrokerDaemonStartIfNeeded, nil | |
| 283 | + | default: | |
| 284 | + | return 0, fmt.Errorf("vev: unknown daemon start mode %q (want existing-only or if-needed)", value) | |
| 285 | + | } | |
| 286 | + | } | |
| 287 | + | ||
| 288 | + | // helperCarriageTarget resolves the dial target for the private daemonmux | |
| 289 | + | // carriage a remote-side mux helper bridges to, strictly from that helper's own | |
| 290 | + | // broker-owned configuration. A helper owns no request and no client authority: | |
| 291 | + | // the target is the one provisioned entry whose route is exactly the carriage | |
| 292 | + | // being bridged, so the helper can only ever narrow what its own configuration | |
| 293 | + | // already permits. | |
| 294 | + | // | |
| 295 | + | // The provisioned local binding wins when it names the carriage, because the | |
| 296 | + | // local binding is the broker's own daemon authority. Otherwise the single | |
| 297 | + | // registration that provisions this carriage supplies the fence, identity, and | |
| 298 | + | // policy. A configuration that provisions neither yields no target, and a | |
| 299 | + | // start-if-needed request is then refused rather than widened. | |
| 300 | + | func helperCarriageTarget(config *brokerconfig.Config, route brokerconfig.Route) (ports.BrokerDialTarget, error) { | |
| 301 | + | if binding, ok := config.LocalBinding(); ok && binding.Route.Address() == route.Address() { | |
| 302 | + | return ports.BrokerDialTarget{ | |
| 303 | + | Fence: ports.BrokerEndpointFence{Local: true}, | |
| 304 | + | Address: binding.Route.Address(), | |
| 305 | + | Policy: binding.Policy, | |
| 306 | + | ExpectedIdentity: ports.BrokerExpectedIdentity{Identity: binding.Identity, Bound: binding.Identity != ""}, | |
| 307 | + | }, nil | |
| 308 | + | } | |
| 309 | + | for _, endpoint := range config.Endpoints() { | |
| 310 | + | registration, ok := config.Registration(endpoint) | |
| 311 | + | if !ok || registration.Route.Address() != route.Address() { | |
| 312 | + | continue | |
| 313 | + | } | |
| 314 | + | return ports.BrokerDialTarget{ | |
| 315 | + | Fence: ports.BrokerEndpointFence{Registration: registration.Registration}, | |
| 316 | + | Address: registration.Route.Address(), | |
| 317 | + | Policy: registration.Policy, | |
| 318 | + | ExpectedIdentity: ports.BrokerExpectedIdentity{Identity: registration.Identity, Bound: registration.Identity != ""}, | |
| 319 | + | }, nil | |
| 320 | + | } | |
| 321 | + | return ports.BrokerDialTarget{}, localDaemonStartRefused("the helper configuration provisions no daemon authority for its mux carriage") | |
| 322 | + | } | |
| 323 | + | ||
| 324 | + | // dialMuxHelperCarriage dials the private daemonmux carriage one remote-side mux | |
| 325 | + | // helper bridges to, under the start authorization the broker propagated. The | |
| 326 | + | // helper applies the same local election the broker does, from its own | |
| 327 | + | // broker-owned configuration, and it never starts a broker, an observer, or a | |
| 328 | + | // recursive helper: an existing-only mode is one carriage dial, and a | |
| 329 | + | // start-if-needed mode is the shared daemon start gated by the helper's own | |
| 330 | + | // provisioned policy and identity. | |
| 331 | + | func dialMuxHelperCarriage(ctx context.Context, config *brokerconfig.Config, mode ports.BrokerDaemonStartMode) (daemonmux.RawFramedTransport, error) { | |
| 332 | + | if config == nil { | |
| 333 | + | return nil, errors.New("vev: broker mux helper requires a configuration") | |
| 334 | + | } | |
| 335 | + | if err := mode.Validate(); err != nil { | |
| 336 | + | return nil, err | |
| 337 | + | } | |
| 338 | + | route, err := config.LocalMuxRoute() | |
| 339 | + | if err != nil { | |
| 340 | + | return nil, err | |
| 341 | + | } | |
| 342 | + | target, err := helperCarriageTarget(config, route) | |
| 343 | + | if err != nil { | |
| 344 | + | return nil, err | |
| 345 | + | } | |
| 346 | + | target.StartMode = mode | |
| 347 | + | if err := target.Validate(); err != nil { | |
| 348 | + | return nil, ports.BrokerError{Code: ports.BrokerErrorIncompatible, Cause: err} | |
| 349 | + | } | |
| 350 | + | // Only a policy that names the launch token may start the daemon. A carriage | |
| 351 | + | // provisioned under a non-launching policy refuses the start outright. The | |
| 352 | + | // existing-only mode never reaches this check: dialBrokerDaemonCarriage | |
| 353 | + | // returns its single dial's result immediately. | |
| 354 | + | if mode == ports.BrokerDaemonStartIfNeeded { | |
| 355 | + | if err := daemonStartRefusal(target); err != nil { | |
| 356 | + | return nil, err | |
| 357 | + | } | |
| 358 | + | } | |
| 359 | + | return dialBrokerDaemonCarriage(ctx, route.Path(), target) | |
| 360 | + | } | |
A internal/app/broker_daemon_start_test.go +854 -0
| @@ -0,0 +1,854 @@ | |||
| 1 | + | package app | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "io" | |
| 7 | + | "log/slog" | |
| 8 | + | "os" | |
| 9 | + | "path/filepath" | |
| 10 | + | "sync" | |
| 11 | + | "sync/atomic" | |
| 12 | + | "testing" | |
| 13 | + | "time" | |
| 14 | + | ||
| 15 | + | "github.com/stretchr/testify/require" | |
| 16 | + | ||
| 17 | + | "github.com/bnema/vev/internal/adapters/brokerconfig" | |
| 18 | + | "github.com/bnema/vev/internal/adapters/daemonmux" | |
| 19 | + | "github.com/bnema/vev/internal/ports" | |
| 20 | + | "github.com/bnema/vev/internal/protocol/wire" | |
| 21 | + | ) | |
| 22 | + | ||
| 23 | + | // Broker-owned daemon start coverage (Plan 001 P7, UI-driver slice 1). | |
| 24 | + | // | |
| 25 | + | // These tests drive the real start decision — the closed start authorization, | |
| 26 | + | // the launch policy, the shared election, and the carriage dial — through the | |
| 27 | + | // scripted starter seams. Nothing here re-executes a daemon, binds a socket, or | |
| 28 | + | // reads a session: every assertion is about whether a spawn was elected and | |
| 29 | + | // which carriage was dialed, which is exactly the contract the broker owns. | |
| 30 | + | ||
| 31 | + | // stubRawCarriage is one inert raw framed carriage: enough to satisfy the dial | |
| 32 | + | // seam without a socket, a peer, or a goroutine. | |
| 33 | + | type stubRawCarriage struct{ closed atomic.Bool } | |
| 34 | + | ||
| 35 | + | func (c *stubRawCarriage) Send(wire.Envelope) error { return nil } | |
| 36 | + | ||
| 37 | + | func (c *stubRawCarriage) RecvBounded(uint64) (wire.Envelope, error) { return wire.Envelope{}, io.EOF } | |
| 38 | + | ||
| 39 | + | func (c *stubRawCarriage) Close() error { | |
| 40 | + | c.closed.Store(true) | |
| 41 | + | return nil | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | // scriptedDaemonStarter is a goroutine-safe recording starter: it counts dials | |
| 45 | + | // and spawns, keeps the exact target each dial received, and fails every dial | |
| 46 | + | // until the scripted spawn (or the test) makes the carriage available. | |
| 47 | + | type scriptedDaemonStarter struct { | |
| 48 | + | mu sync.Mutex | |
| 49 | + | carriage string | |
| 50 | + | // published is the carriage the scripted daemon advertises as the endpoint | |
| 51 | + | // this process can start. Empty means the same carriage it answers at. | |
| 52 | + | published string | |
| 53 | + | dialed []ports.BrokerDialTarget | |
| 54 | + | dials int | |
| 55 | + | spawns int | |
| 56 | + | // running reports that the daemon behind the carriage is published, as the | |
| 57 | + | // elected spawner's daemon is once it binds its endpoint. | |
| 58 | + | running bool | |
| 59 | + | // available reports whether the carriage answers. A nil func means "answer | |
| 60 | + | // only once a spawn happened". | |
| 61 | + | available func() bool | |
| 62 | + | // dialErr overrides the failure a dial reports while unavailable. | |
| 63 | + | dialErr error | |
| 64 | + | // spawnErr fails the elected spawn. | |
| 65 | + | spawnErr error | |
| 66 | + | // daemonOwner is the lifecycle ownership the spawned daemon holds, exactly | |
| 67 | + | // as the real daemon does for its whole run. It is what makes a concurrent | |
| 68 | + | // second caller wait instead of electing itself. | |
| 69 | + | daemonOwner lifecycleOwnership | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | func (s *scriptedDaemonStarter) starter(backoff backoffConfig) brokerDaemonStarter { | |
| 73 | + | return brokerDaemonStarter{ | |
| 74 | + | dial: s.dial, | |
| 75 | + | spawn: s.spawn, | |
| 76 | + | backoff: backoff, | |
| 77 | + | // The scripted daemon publishes exactly the carriage the test declared, | |
| 78 | + | // so the start path sees one reachable daemon endpoint like production. | |
| 79 | + | published: func() string { return s.publishedCarriage() }, | |
| 80 | + | } | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | // releaseDaemonOwnership drops the lifecycle ownership the scripted daemon held, | |
| 84 | + | // as stopping it would. | |
| 85 | + | func (s *scriptedDaemonStarter) releaseDaemonOwnership(t *testing.T) { | |
| 86 | + | t.Helper() | |
| 87 | + | s.mu.Lock() | |
| 88 | + | owner := s.daemonOwner | |
| 89 | + | s.daemonOwner = nil | |
| 90 | + | s.mu.Unlock() | |
| 91 | + | if owner != nil { | |
| 92 | + | require.NoError(t, owner.Release()) | |
| 93 | + | } | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | func (s *scriptedDaemonStarter) dial(_ context.Context, carriage string) (daemonmux.RawFramedTransport, error) { | |
| 97 | + | s.mu.Lock() | |
| 98 | + | defer s.mu.Unlock() | |
| 99 | + | s.dials++ | |
| 100 | + | s.dialed = append(s.dialed, ports.BrokerDialTarget{Address: carriage}) | |
| 101 | + | if s.carriage != carriage { | |
| 102 | + | return nil, os.ErrNotExist | |
| 103 | + | } | |
| 104 | + | if s.available == nil { | |
| 105 | + | if s.spawns == 0 && !s.running { | |
| 106 | + | return nil, os.ErrNotExist | |
| 107 | + | } | |
| 108 | + | } else if !s.available() { | |
| 109 | + | if s.dialErr != nil { | |
| 110 | + | return nil, s.dialErr | |
| 111 | + | } | |
| 112 | + | return nil, os.ErrNotExist | |
| 113 | + | } | |
| 114 | + | return &stubRawCarriage{}, nil | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | func (s *scriptedDaemonStarter) spawn() error { | |
| 118 | + | s.mu.Lock() | |
| 119 | + | defer s.mu.Unlock() | |
| 120 | + | s.spawns++ | |
| 121 | + | if s.spawnErr != nil { | |
| 122 | + | return s.spawnErr | |
| 123 | + | } | |
| 124 | + | // A real spawned daemon immediately takes lifecycle ownership of the | |
| 125 | + | // runtime directory and holds it for its whole run; a concurrent caller | |
| 126 | + | // therefore observes ErrBusy and waits for the carriage rather than | |
| 127 | + | // electing a second spawner. | |
| 128 | + | lockDir := filepath.Dir(s.publishedCarriageLocked()) | |
| 129 | + | if owner, err := daemonLifecycleProbe.TryAcquire(lockDir); err == nil { | |
| 130 | + | s.daemonOwner = owner | |
| 131 | + | } | |
| 132 | + | return nil | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | // markRunning publishes the daemon the elected spawner started, so the callers | |
| 136 | + | // waiting on the held election converge on its carriage. | |
| 137 | + | func (s *scriptedDaemonStarter) markRunning() { | |
| 138 | + | s.mu.Lock() | |
| 139 | + | defer s.mu.Unlock() | |
| 140 | + | s.running = true | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | // isRunning reports whether the daemon is published. | |
| 144 | + | func (s *scriptedDaemonStarter) isRunning() bool { | |
| 145 | + | s.mu.Lock() | |
| 146 | + | defer s.mu.Unlock() | |
| 147 | + | return s.running | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | // publishedCarriageLocked reports the published carriage while the mutex is | |
| 151 | + | // already held. | |
| 152 | + | func (s *scriptedDaemonStarter) publishedCarriageLocked() string { | |
| 153 | + | if s.published != "" { | |
| 154 | + | return s.published | |
| 155 | + | } | |
| 156 | + | return s.carriage | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | // publishedCarriage reports the carriage the scripted daemon publishes as the | |
| 160 | + | // endpoint this process can start. | |
| 161 | + | func (s *scriptedDaemonStarter) publishedCarriage() string { | |
| 162 | + | s.mu.Lock() | |
| 163 | + | defer s.mu.Unlock() | |
| 164 | + | return s.publishedCarriageLocked() | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | func (s *scriptedDaemonStarter) counters() (dials, spawns int) { | |
| 168 | + | s.mu.Lock() | |
| 169 | + | defer s.mu.Unlock() | |
| 170 | + | return s.dials, s.spawns | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | // fastDaemonStartBackoff bounds the election the way an unresponsive daemon | |
| 174 | + | // would, without the production five-second budget. | |
| 175 | + | var fastDaemonStartBackoff = backoffConfig{initial: time.Millisecond, max: 2 * time.Millisecond, total: 150 * time.Millisecond} | |
| 176 | + | ||
| 177 | + | // withDaemonStarter swaps the package start seam for one test and restores it. | |
| 178 | + | // The start seam is process state, so no caller of this helper runs in parallel. | |
| 179 | + | func withDaemonStarter(t *testing.T, starter brokerDaemonStarter) { | |
| 180 | + | t.Helper() | |
| 181 | + | restore := localDaemonStarter | |
| 182 | + | localDaemonStarter = starter | |
| 183 | + | t.Cleanup(func() { localDaemonStarter = restore }) | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | // testDaemonCarriage returns a fresh owner-only daemon runtime directory and the | |
| 187 | + | // exact private carriage a daemon bound to it publishes. It is the carriage the | |
| 188 | + | // scripted starter declares, so the start path sees one reachable endpoint. | |
| 189 | + | func testDaemonCarriage(t *testing.T) (dir, carriage string) { | |
| 190 | + | t.Helper() | |
| 191 | + | dir = t.TempDir() | |
| 192 | + | // The lifecycle lock refuses a directory with group or other access, and | |
| 193 | + | // t.TempDir() is 0755; the daemon runtime directory is owner-only. | |
| 194 | + | require.NoError(t, os.Chmod(dir, 0o700)) | |
| 195 | + | return dir, daemonmux.SocketPath(dir) | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | // startableLocalTarget builds the resolved local target the broker publishes for | |
| 199 | + | // its own machine daemon under the supplied start authorization. The policy is | |
| 200 | + | // the production local daemon authority, which explicitly names the launch | |
| 201 | + | // token; a test that needs a non-launching policy overrides it. The address is | |
| 202 | + | // the bounded token the resolver would publish for the provisioned carriage. | |
| 203 | + | func startableLocalTarget(t *testing.T, carriage string, mode ports.BrokerDaemonStartMode) ports.BrokerDialTarget { | |
| 204 | + | t.Helper() | |
| 205 | + | _, binding := testLocalSandboxConfigForCarriage(t, carriage) | |
| 206 | + | target := ports.BrokerDialTarget{ | |
| 207 | + | Fence: ports.BrokerEndpointFence{Local: true}, | |
| 208 | + | Address: binding.Route.Address(), | |
| 209 | + | Policy: localDaemonPolicy(), | |
| 210 | + | StartMode: mode, | |
| 211 | + | ExpectedIdentity: ports.BrokerExpectedIdentity{Identity: brokerLocalTestIdentity, Bound: true}, | |
| 212 | + | } | |
| 213 | + | require.NoError(t, target.Validate()) | |
| 214 | + | return target | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | // writePrivateSandboxConfig writes one marker-valid sandbox whose single local | |
| 218 | + | // binding is the supplied private carriage under the supplied policy. | |
| 219 | + | func writePrivateSandboxConfig(t *testing.T, root, carriage string, policy ports.BrokerPolicy) { | |
| 220 | + | t.Helper() | |
| 221 | + | writeSandboxConfig(t, root, map[string]any{ | |
| 222 | + | "marker": brokerconfig.Marker, | |
| 223 | + | "registrations": []any{}, | |
| 224 | + | "local": map[string]any{ | |
| 225 | + | "identity": brokerLocalTestIdentity, | |
| 226 | + | "displayOrigin": "local", | |
| 227 | + | "route": carriage, | |
| 228 | + | "policy": map[string]any{ | |
| 229 | + | "protocolVersion": policy.ProtocolVersion, | |
| 230 | + | "catalogSchemaVersion": policy.CatalogSchemaVersion, | |
| 231 | + | "environmentPolicy": "client-owned", | |
| 232 | + | "transport": policy.Transport, | |
| 233 | + | "trust": policy.Trust, | |
| 234 | + | "launch": policy.Launch, | |
| 235 | + | "isolation": policy.Isolation, | |
| 236 | + | }, | |
| 237 | + | }, | |
| 238 | + | }) | |
| 239 | + | } | |
| 240 | + | ||
| 241 | + | // TestDaemonStartExistingOnlyNeverSpawns proves an existing-only acquisition is | |
| 242 | + | // exactly one carriage dial: no lifecycle probe, no spawn lock, no process, and | |
| 243 | + | // no fallback of any kind, even though the carriage is absent. | |
| 244 | + | func TestDaemonStartExistingOnlyNeverSpawns(t *testing.T) { | |
| 245 | + | isolateSandboxEnv(t) | |
| 246 | + | dir, carriage := testDaemonCarriage(t) | |
| 247 | + | scripted := &scriptedDaemonStarter{carriage: carriage} | |
| 248 | + | withDaemonStarter(t, scripted.starter(fastDaemonStartBackoff)) | |
| 249 | + | ||
| 250 | + | target := startableLocalTarget(t, carriage, ports.BrokerDaemonExistingOnly) | |
| 251 | + | raw, err := dialBrokerLocalDaemon(context.Background(), carriage, target) | |
| 252 | + | require.ErrorIs(t, err, os.ErrNotExist) | |
| 253 | + | require.Nil(t, raw) | |
| 254 | + | ||
| 255 | + | dials, spawns := scripted.counters() | |
| 256 | + | require.Equal(t, 1, dials, "existing-only is exactly one carriage dial") | |
| 257 | + | require.Zero(t, spawns, "existing-only must never spawn") | |
| 258 | + | _, statErr := os.Stat(filepath.Join(dir, spawnLockName)) | |
| 259 | + | require.ErrorIs(t, statErr, os.ErrNotExist, "existing-only must not take the spawn lock") | |
| 260 | + | _, probeErr := os.Stat(filepath.Join(dir, "lifecycle.lock")) | |
| 261 | + | require.ErrorIs(t, probeErr, os.ErrNotExist, "existing-only must not probe lifecycle ownership") | |
| 262 | + | } | |
| 263 | + | ||
| 264 | + | // TestDaemonStartExistingOnlyReturnsRunningDaemonWithoutProbing proves a | |
| 265 | + | // reachable carriage is the whole result: the running daemon is never restarted | |
| 266 | + | // and no election is entered. | |
| 267 | + | func TestDaemonStartExistingOnlyReturnsRunningDaemonWithoutProbing(t *testing.T) { | |
| 268 | + | isolateSandboxEnv(t) | |
| 269 | + | dir, carriage := testDaemonCarriage(t) | |
| 270 | + | scripted := &scriptedDaemonStarter{carriage: carriage, available: func() bool { return true }} | |
| 271 | + | withDaemonStarter(t, scripted.starter(fastDaemonStartBackoff)) | |
| 272 | + | ||
| 273 | + | target := startableLocalTarget(t, carriage, ports.BrokerDaemonExistingOnly) | |
| 274 | + | raw, err := dialBrokerLocalDaemon(context.Background(), carriage, target) | |
| 275 | + | require.NoError(t, err) | |
| 276 | + | require.NotNil(t, raw) | |
| 277 | + | ||
| 278 | + | // A start-if-needed acquisition against the same running daemon also | |
| 279 | + | // returns it unchanged: the mode authorizes a start, it never demands one. | |
| 280 | + | startIfNeeded := startableLocalTarget(t, carriage, ports.BrokerDaemonStartIfNeeded) | |
| 281 | + | rawStartIfNeeded, err := dialBrokerLocalDaemon(context.Background(), carriage, startIfNeeded) | |
| 282 | + | require.NoError(t, err) | |
| 283 | + | require.NotNil(t, rawStartIfNeeded) | |
| 284 | + | ||
| 285 | + | dials, spawns := scripted.counters() | |
| 286 | + | require.Equal(t, 2, dials) | |
| 287 | + | require.Zero(t, spawns, "a running daemon is never restarted") | |
| 288 | + | _, probeErr := os.Stat(filepath.Join(dir, "lifecycle.lock")) | |
| 289 | + | require.ErrorIs(t, probeErr, os.ErrNotExist, "a reachable carriage never enters the election") | |
| 290 | + | } | |
| 291 | + | ||
| 292 | + | // TestDaemonStartIfNeededSpawnsExactlyOnce proves the authorized start path | |
| 293 | + | // elects one spawner, then returns the carriage the spawned daemon published. | |
| 294 | + | func TestDaemonStartIfNeededSpawnsExactlyOnce(t *testing.T) { | |
| 295 | + | isolateSandboxEnv(t) | |
| 296 | + | dir, carriage := testDaemonCarriage(t) | |
| 297 | + | scripted := &scriptedDaemonStarter{carriage: carriage} | |
| 298 | + | withDaemonStarter(t, scripted.starter(fastDaemonStartBackoff)) | |
| 299 | + | ||
| 300 | + | target := startableLocalTarget(t, carriage, ports.BrokerDaemonStartIfNeeded) | |
| 301 | + | raw, err := dialBrokerLocalDaemon(context.Background(), carriage, target) | |
| 302 | + | require.NoError(t, err) | |
| 303 | + | require.NotNil(t, raw) | |
| 304 | + | ||
| 305 | + | dials, spawns := scripted.counters() | |
| 306 | + | require.GreaterOrEqual(t, dials, 2, "the start path dials before and after the spawn") | |
| 307 | + | require.Equal(t, 1, spawns) | |
| 308 | + | // The election released the spawn lock. | |
| 309 | + | require.NoFileExists(t, filepath.Join(dir, spawnLockName)) | |
| 310 | + | } | |
| 311 | + | ||
| 312 | + | // TestDaemonStartRefusesPolicyWithoutLaunchAuthority proves the closed start | |
| 313 | + | // authorization can only narrow the resolved policy: a policy that does not name | |
| 314 | + | // the broker launch authority refuses the start outright, and nothing spawns. | |
| 315 | + | func TestDaemonStartRefusesPolicyWithoutLaunchAuthority(t *testing.T) { | |
| 316 | + | isolateSandboxEnv(t) | |
| 317 | + | dir, carriage := testDaemonCarriage(t) | |
| 318 | + | scripted := &scriptedDaemonStarter{carriage: carriage} | |
| 319 | + | withDaemonStarter(t, scripted.starter(fastDaemonStartBackoff)) | |
| 320 | + | ||
| 321 | + | target := startableLocalTarget(t, carriage, ports.BrokerDaemonStartIfNeeded) | |
| 322 | + | target.Policy.Launch = "sandbox-private" | |
| 323 | + | require.NoError(t, target.Validate()) | |
| 324 | + | ||
| 325 | + | raw, err := dialBrokerLocalDaemon(context.Background(), carriage, target) | |
| 326 | + | require.Error(t, err) | |
| 327 | + | require.Nil(t, raw) | |
| 328 | + | var typed ports.BrokerError | |
| 329 | + | require.ErrorAs(t, err, &typed) | |
| 330 | + | require.Equal(t, ports.BrokerErrorConflictingPolicy, typed.Code) | |
| 331 | + | ||
| 332 | + | dials, spawns := scripted.counters() | |
| 333 | + | require.Equal(t, 1, dials, "the carriage is dialed once before the refusal") | |
| 334 | + | require.Zero(t, spawns, "a non-launching policy must never spawn") | |
| 335 | + | require.NoFileExists(t, filepath.Join(dir, spawnLockName)) | |
| 336 | + | } | |
| 337 | + | ||
| 338 | + | // TestDaemonStartRefusesRegisteredEndpoint proves the local start mechanics | |
| 339 | + | // refuse a registered endpoint even on a local carriage: the broker owns the | |
| 340 | + | // start authority of its own machine daemon only. | |
| 341 | + | func TestDaemonStartRefusesRegisteredEndpoint(t *testing.T) { | |
| 342 | + | isolateSandboxEnv(t) | |
| 343 | + | _, carriage := testDaemonCarriage(t) | |
| 344 | + | scripted := &scriptedDaemonStarter{carriage: carriage} | |
| 345 | + | withDaemonStarter(t, scripted.starter(fastDaemonStartBackoff)) | |
| 346 | + | ||
| 347 | + | target := startableLocalTarget(t, carriage, ports.BrokerDaemonStartIfNeeded) | |
| 348 | + | target.Fence = ports.BrokerEndpointFence{Registration: brokerTestRegistration()} | |
| 349 | + | require.NoError(t, target.Validate()) | |
| 350 | + | ||
| 351 | + | _, err := dialBrokerLocalDaemon(context.Background(), carriage, target) | |
| 352 | + | require.Error(t, err) | |
| 353 | + | dials, spawns := scripted.counters() | |
| 354 | + | require.Zero(t, dials, "a registered endpoint is refused before any dial") | |
| 355 | + | require.Zero(t, spawns) | |
| 356 | + | } | |
| 357 | + | ||
| 358 | + | // TestDaemonStartRefusesCarriageOutsideTheProvisionedRoute proves a carriage that | |
| 359 | + | // is not an absolute cleaned private mux path is refused before any dial, so a | |
| 360 | + | // hand-built target can never make the launcher start a daemon for a foreign | |
| 361 | + | // endpoint. | |
| 362 | + | func TestDaemonStartRefusesCarriageOutsideTheProvisionedRoute(t *testing.T) { | |
| 363 | + | isolateSandboxEnv(t) | |
| 364 | + | _, carriage := testDaemonCarriage(t) | |
| 365 | + | scripted := &scriptedDaemonStarter{carriage: carriage} | |
| 366 | + | withDaemonStarter(t, scripted.starter(fastDaemonStartBackoff)) | |
| 367 | + | ||
| 368 | + | target := startableLocalTarget(t, carriage, ports.BrokerDaemonStartIfNeeded) | |
| 369 | + | for _, invalid := range []string{"", "relative/daemonmux.sock", carriage + "/.."} { | |
| 370 | + | _, err := dialBrokerLocalDaemon(context.Background(), invalid, target) | |
| 371 | + | require.Error(t, err) | |
| 372 | + | var typed ports.BrokerError | |
| 373 | + | require.ErrorAs(t, err, &typed) | |
| 374 | + | require.Equal(t, ports.BrokerErrorIncompatible, typed.Code) | |
| 375 | + | } | |
| 376 | + | dials, spawns := scripted.counters() | |
| 377 | + | require.Zero(t, dials) | |
| 378 | + | require.Zero(t, spawns) | |
| 379 | + | } | |
| 380 | + | ||
| 381 | + | // TestDaemonStartRefusesForeignCarriageStart proves a start is refused for a | |
| 382 | + | // carriage this process cannot publish: the launcher re-executes this binary, | |
| 383 | + | // which binds its own private endpoint, so starting a daemon for a foreign | |
| 384 | + | // carriage would spawn a process the caller could never reach. | |
| 385 | + | func TestDaemonStartRefusesForeignCarriageStart(t *testing.T) { | |
| 386 | + | isolateSandboxEnv(t) | |
| 387 | + | _, own := testDaemonCarriage(t) | |
| 388 | + | foreignDir := t.TempDir() | |
| 389 | + | require.NoError(t, os.Chmod(foreignDir, 0o700)) | |
| 390 | + | foreign := filepath.Join(foreignDir, "daemonmux.sock") | |
| 391 | + | ||
| 392 | + | // The scripted daemon publishes and answers at the process' own carriage; | |
| 393 | + | // the dial target names a different one. | |
| 394 | + | scripted := &scriptedDaemonStarter{carriage: own, published: own} | |
| 395 | + | withDaemonStarter(t, scripted.starter(fastDaemonStartBackoff)) | |
| 396 | + | ||
| 397 | + | target := startableLocalTarget(t, foreign, ports.BrokerDaemonStartIfNeeded) | |
| 398 | + | _, err := dialBrokerDaemonCarriage(context.Background(), foreign, target) | |
| 399 | + | require.Error(t, err) | |
| 400 | + | var typed ports.BrokerError | |
| 401 | + | require.ErrorAs(t, err, &typed) | |
| 402 | + | require.Equal(t, ports.BrokerErrorIncompatible, typed.Code) | |
| 403 | + | ||
| 404 | + | dials, spawns := scripted.counters() | |
| 405 | + | require.Equal(t, 1, dials, "the carriage is dialed once before the refusal") | |
| 406 | + | require.Zero(t, spawns, "a foreign carriage must never be started") | |
| 407 | + | require.NoFileExists(t, filepath.Join(filepath.Dir(own), spawnLockName)) | |
| 408 | + | } | |
| 409 | + | ||
| 410 | + | // TestDaemonStartDoesNotSpawnOnNonAbsenceFailure proves only proven absence can | |
| 411 | + | // authorize a start: a permission, path, peer, or cancellation failure is | |
| 412 | + | // reported unchanged and never repaired by spawning a second daemon. | |
| 413 | + | func TestDaemonStartDoesNotSpawnOnNonAbsenceFailure(t *testing.T) { | |
| 414 | + | tests := []struct { | |
| 415 | + | name string | |
| 416 | + | err error | |
| 417 | + | }{ | |
| 418 | + | {name: "permission", err: os.ErrPermission}, | |
| 419 | + | {name: "context canceled", err: context.Canceled}, | |
| 420 | + | {name: "invalid path", err: os.ErrInvalid}, | |
| 421 | + | {name: "unclassified", err: errors.New("carriage refused")}, | |
| 422 | + | } | |
| 423 | + | for _, tt := range tests { | |
| 424 | + | t.Run(tt.name, func(t *testing.T) { | |
| 425 | + | isolateSandboxEnv(t) | |
| 426 | + | dir, carriage := testDaemonCarriage(t) | |
| 427 | + | scripted := &scriptedDaemonStarter{ | |
| 428 | + | carriage: carriage, | |
| 429 | + | available: func() bool { return false }, | |
| 430 | + | dialErr: tt.err, | |
| 431 | + | } | |
| 432 | + | withDaemonStarter(t, scripted.starter(fastDaemonStartBackoff)) | |
| 433 | + | ||
| 434 | + | target := startableLocalTarget(t, carriage, ports.BrokerDaemonStartIfNeeded) | |
| 435 | + | _, err := dialBrokerLocalDaemon(context.Background(), carriage, target) | |
| 436 | + | require.ErrorIs(t, err, tt.err) | |
| 437 | + | ||
| 438 | + | dials, spawns := scripted.counters() | |
| 439 | + | require.Equal(t, 1, dials) | |
| 440 | + | require.Zero(t, spawns) | |
| 441 | + | require.NoFileExists(t, filepath.Join(dir, spawnLockName)) | |
| 442 | + | }) | |
| 443 | + | } | |
| 444 | + | } | |
| 445 | + | ||
| 446 | + | // TestDaemonStartConcurrentRequestsShareOneElection proves N concurrent | |
| 447 | + | // start-if-needed requests never elect a second spawner while one election is | |
| 448 | + | // already in progress: the spawn lock is held by the elected spawner, so every | |
| 449 | + | // other caller waits for the carriage it publishes instead of spawning again. | |
| 450 | + | // | |
| 451 | + | // The spawn lock is taken here exactly as another client's elected spawner | |
| 452 | + | // would take it, and the carriage becomes dialable once that "spawner" finishes. | |
| 453 | + | // The starter therefore records zero spawns of its own, every caller converges | |
| 454 | + | // on the published carriage, and no caller ever holds the lock itself. | |
| 455 | + | func TestDaemonStartConcurrentRequestsShareOneElection(t *testing.T) { | |
| 456 | + | isolateSandboxEnv(t) | |
| 457 | + | dir, carriage := testDaemonCarriage(t) | |
| 458 | + | scripted := &scriptedDaemonStarter{carriage: carriage} | |
| 459 | + | withDaemonStarter(t, scripted.starter(fastDaemonStartBackoff)) | |
| 460 | + | ||
| 461 | + | // Another client's elected spawner holds the lock; the daemon it starts | |
| 462 | + | // publishes the carriage shortly afterwards. | |
| 463 | + | release, acquired, err := acquireSpawnLock(dir) | |
| 464 | + | require.NoError(t, err) | |
| 465 | + | require.True(t, acquired, "the test must hold the spawn lock") | |
| 466 | + | t.Cleanup(release) | |
| 467 | + | ||
| 468 | + | // The target is resolved once: require.* is not goroutine-safe, and the | |
| 469 | + | // resolved target is immutable and shared by every caller. | |
| 470 | + | target := startableLocalTarget(t, carriage, ports.BrokerDaemonStartIfNeeded) | |
| 471 | + | ||
| 472 | + | const callers = 8 | |
| 473 | + | var wg sync.WaitGroup | |
| 474 | + | results := make([]daemonmux.RawFramedTransport, callers) | |
| 475 | + | errs := make([]error, callers) | |
| 476 | + | start := make(chan struct{}) | |
| 477 | + | for i := range callers { | |
| 478 | + | wg.Add(1) | |
| 479 | + | go func() { | |
| 480 | + | defer wg.Done() | |
| 481 | + | <-start | |
| 482 | + | results[i], errs[i] = dialBrokerLocalDaemon(context.Background(), carriage, target) | |
| 483 | + | }() | |
| 484 | + | } | |
| 485 | + | // Publish the daemon the held election is starting, as its spawner does. | |
| 486 | + | go func() { | |
| 487 | + | <-start | |
| 488 | + | scripted.markRunning() | |
| 489 | + | }() | |
| 490 | + | close(start) | |
| 491 | + | // The held election ends once the carriage answers. | |
| 492 | + | require.Eventually(t, func() bool { return scripted.isRunning() }, brokerTestWait, time.Millisecond) | |
| 493 | + | release() | |
| 494 | + | wg.Wait() | |
| 495 | + | ||
| 496 | + | for i := range callers { | |
| 497 | + | require.NoError(t, errs[i], "caller %d", i) | |
| 498 | + | require.NotNil(t, results[i], "caller %d", i) | |
| 499 | + | } | |
| 500 | + | _, spawns := scripted.counters() | |
| 501 | + | require.Zero(t, spawns, "a caller must never spawn while another election holds the lock") | |
| 502 | + | } | |
| 503 | + | ||
| 504 | + | // TestDaemonStartConcurrentExistingOnlyNeverSpawns proves concurrent | |
| 505 | + | // existing-only acquisitions never share a start: each one dials, fails on the | |
| 506 | + | // same absent carriage, and spawns nothing. | |
| 507 | + | func TestDaemonStartConcurrentExistingOnlyNeverSpawns(t *testing.T) { | |
| 508 | + | isolateSandboxEnv(t) | |
| 509 | + | _, carriage := testDaemonCarriage(t) | |
| 510 | + | scripted := &scriptedDaemonStarter{carriage: carriage} | |
| 511 | + | withDaemonStarter(t, scripted.starter(fastDaemonStartBackoff)) | |
| 512 | + | ||
| 513 | + | target := startableLocalTarget(t, carriage, ports.BrokerDaemonExistingOnly) | |
| 514 | + | const callers = 8 | |
| 515 | + | var wg sync.WaitGroup | |
| 516 | + | start := make(chan struct{}) | |
| 517 | + | for range callers { | |
| 518 | + | wg.Add(1) | |
| 519 | + | go func() { | |
| 520 | + | defer wg.Done() | |
| 521 | + | <-start | |
| 522 | + | _, err := dialBrokerLocalDaemon(context.Background(), carriage, target) | |
| 523 | + | require.ErrorIs(t, err, os.ErrNotExist) | |
| 524 | + | }() | |
| 525 | + | } | |
| 526 | + | close(start) | |
| 527 | + | wg.Wait() | |
| 528 | + | ||
| 529 | + | dials, spawns := scripted.counters() | |
| 530 | + | require.Equal(t, callers, dials) | |
| 531 | + | require.Zero(t, spawns) | |
| 532 | + | } | |
| 533 | + | ||
| 534 | + | // TestDaemonStartPropagatesTheWholeTarget proves the resolved target reaches the | |
| 535 | + | // carriage owner whole: address, fence, policy, start authorization, and expected | |
| 536 | + | // identity all travel together, so a dial can never act on an authorization the | |
| 537 | + | // connector did not validate. | |
| 538 | + | func TestDaemonStartPropagatesTheWholeTarget(t *testing.T) { | |
| 539 | + | isolateSandboxEnv(t) | |
| 540 | + | _, carriage := testDaemonCarriage(t) | |
| 541 | + | ||
| 542 | + | var seen []ports.BrokerDialTarget | |
| 543 | + | var mu sync.Mutex | |
| 544 | + | connectorDial := func(ctx context.Context, target ports.BrokerDialTarget) (daemonmux.RawFramedTransport, error) { | |
| 545 | + | mu.Lock() | |
| 546 | + | seen = append(seen, target) | |
| 547 | + | mu.Unlock() | |
| 548 | + | return nil, os.ErrNotExist | |
| 549 | + | } | |
| 550 | + | connector, err := daemonmux.NewEndpointConnector(daemonmux.RawCarrierDialer(connectorDial), daemonmux.DefaultMuxCeilings()) | |
| 551 | + | require.NoError(t, err) | |
| 552 | + | ||
| 553 | + | // The pool-side connector already owns the whole target; the local start | |
| 554 | + | // helper is what turns it into the carriage decision. | |
| 555 | + | scripted := &scriptedDaemonStarter{carriage: carriage} | |
| 556 | + | withDaemonStarter(t, scripted.starter(fastDaemonStartBackoff)) | |
| 557 | + | ||
| 558 | + | target := startableLocalTarget(t, carriage, ports.BrokerDaemonExistingOnly) | |
| 559 | + | raw, err := dialBrokerLocalDaemon(context.Background(), carriage, target) | |
| 560 | + | require.Error(t, err) | |
| 561 | + | require.Nil(t, raw) | |
| 562 | + | ||
| 563 | + | // The exact same target value is what a connector hands to its dial | |
| 564 | + | // function, so the local helper observes the identical authorization. | |
| 565 | + | _, err = connector.Connect(context.Background(), target) | |
| 566 | + | require.Error(t, err) | |
| 567 | + | mu.Lock() | |
| 568 | + | require.Equal(t, []ports.BrokerDialTarget{target}, seen, "the connector dials the whole resolved target") | |
| 569 | + | mu.Unlock() | |
| 570 | + | require.Equal(t, ports.BrokerDaemonExistingOnly, seen[0].StartMode) | |
| 571 | + | require.True(t, seen[0].Fence.Local) | |
| 572 | + | require.Equal(t, target.Policy, seen[0].Policy) | |
| 573 | + | require.Equal(t, target.ExpectedIdentity, seen[0].ExpectedIdentity) | |
| 574 | + | } | |
| 575 | + | ||
| 576 | + | // TestDaemonStartNeverFallsBackToASessionDial proves structurally that the | |
| 577 | + | // start path owns no session carriage: it never dials the daemon's public | |
| 578 | + | // session socket and never wraps a session transport. | |
| 579 | + | func TestDaemonStartNeverFallsBackToASessionDial(t *testing.T) { | |
| 580 | + | sources := loadAppSources(t, false) | |
| 581 | + | body, ok := sources["broker_daemon_start.go"] | |
| 582 | + | require.True(t, ok, "the daemon start owner must exist") | |
| 583 | + | ||
| 584 | + | for _, forbidden := range []string{ | |
| 585 | + | "ipc.DialContext(", | |
| 586 | + | "ipc.SocketPath(", | |
| 587 | + | "sessionwire.", | |
| 588 | + | "localDaemonDialer", | |
| 589 | + | "ensureDaemonWithLifecycle", | |
| 590 | + | "ensureDaemon(", | |
| 591 | + | } { | |
| 592 | + | require.NotContains(t, body, forbidden, "the daemon start owner must not reach a session carriage") | |
| 593 | + | } | |
| 594 | + | // The only transport it hands out is the daemonmux carriage. | |
| 595 | + | require.Contains(t, body, "daemonmux.RawFramedTransport") | |
| 596 | + | } | |
| 597 | + | ||
| 598 | + | // TestDaemonStartRefusesIncompleteStarter proves a start seam that cannot dial | |
| 599 | + | // or spawn fails closed instead of dialing a session or panicking. | |
| 600 | + | func TestDaemonStartRefusesIncompleteStarter(t *testing.T) { | |
| 601 | + | isolateSandboxEnv(t) | |
| 602 | + | _, carriage := testDaemonCarriage(t) | |
| 603 | + | target := startableLocalTarget(t, carriage, ports.BrokerDaemonStartIfNeeded) | |
| 604 | + | ||
| 605 | + | for _, starter := range []brokerDaemonStarter{{}, {dial: dialLocalDaemonCarriage}, {spawn: realSpawn}} { | |
| 606 | + | withDaemonStarter(t, starter) | |
| 607 | + | _, err := dialBrokerLocalDaemon(context.Background(), carriage, target) | |
| 608 | + | require.Error(t, err) | |
| 609 | + | var typed ports.BrokerError | |
| 610 | + | require.ErrorAs(t, err, &typed) | |
| 611 | + | require.Equal(t, ports.BrokerErrorIncompatible, typed.Code) | |
| 612 | + | } | |
| 613 | + | } | |
| 614 | + | ||
| 615 | + | // TestDaemonStartRefusesInvalidTarget proves an unvalidated resolved target is | |
| 616 | + | // refused before any dial or spawn. | |
| 617 | + | func TestDaemonStartRefusesInvalidTarget(t *testing.T) { | |
| 618 | + | isolateSandboxEnv(t) | |
| 619 | + | _, carriage := testDaemonCarriage(t) | |
| 620 | + | scripted := &scriptedDaemonStarter{carriage: carriage} | |
| 621 | + | withDaemonStarter(t, scripted.starter(fastDaemonStartBackoff)) | |
| 622 | + | ||
| 623 | + | invalid := startableLocalTarget(t, carriage, ports.BrokerDaemonStartIfNeeded) | |
| 624 | + | invalid.StartMode = 0 | |
| 625 | + | _, err := dialBrokerLocalDaemon(context.Background(), carriage, invalid) | |
| 626 | + | require.Error(t, err) | |
| 627 | + | var typed ports.BrokerError | |
| 628 | + | require.ErrorAs(t, err, &typed) | |
| 629 | + | require.Equal(t, ports.BrokerErrorIncompatible, typed.Code) | |
| 630 | + | dials, spawns := scripted.counters() | |
| 631 | + | require.Zero(t, dials) | |
| 632 | + | require.Zero(t, spawns) | |
| 633 | + | } | |
| 634 | + | ||
| 635 | + | // helperSandboxConfigForCarriage provisions one sandbox whose single | |
| 636 | + | // registration bridges the supplied private Unix carriage under the supplied | |
| 637 | + | // policy, and returns its loaded configuration. A remote-side mux helper owns | |
| 638 | + | // exactly this shape: the provisioning is the helper's own authority. | |
| 639 | + | func helperSandboxConfigForCarriage(t *testing.T, carriage string, policy ports.BrokerPolicy) *brokerconfig.Config { | |
| 640 | + | t.Helper() | |
| 641 | + | root := filepath.Dir(carriage) | |
| 642 | + | writeSandboxConfig(t, root, map[string]any{ | |
| 643 | + | "marker": brokerconfig.Marker, | |
| 644 | + | "registrations": []any{map[string]any{ | |
| 645 | + | "endpoint": brokerTestEndpoint, | |
| 646 | + | "incarnation": "0102030405060708090a0b0c0d0e0f10", | |
| 647 | + | "generation": 1, | |
| 648 | + | "identity": brokerTestIdentity, | |
| 649 | + | "route": carriage, | |
| 650 | + | "policy": map[string]any{ | |
| 651 | + | "protocolVersion": policy.ProtocolVersion, | |
| 652 | + | "catalogSchemaVersion": policy.CatalogSchemaVersion, | |
| 653 | + | "environmentPolicy": "client-owned", | |
| 654 | + | "transport": policy.Transport, | |
| 655 | + | "trust": policy.Trust, | |
| 656 | + | "launch": policy.Launch, | |
| 657 | + | "isolation": policy.Isolation, | |
| 658 | + | }, | |
| 659 | + | }}, | |
| 660 | + | }) | |
| 661 | + | config, err := brokerconfig.Load(mustOfflineLayout(t, root)) | |
| 662 | + | require.NoError(t, err) | |
| 663 | + | return config | |
| 664 | + | } | |
| 665 | + | ||
| 666 | + | // TestMuxHelperCarriageStartAuthorization proves the remote-side helper applies | |
| 667 | + | // the propagated authorization against its own provisioned authority: an | |
| 668 | + | // existing-only request is one carriage dial, a start-if-needed request is gated | |
| 669 | + | // by the helper's own policy, and an unknown mode is refused outright. | |
| 670 | + | func TestMuxHelperCarriageStartAuthorization(t *testing.T) { | |
| 671 | + | isolateSandboxEnv(t) | |
| 672 | + | dir, carriage := testDaemonCarriage(t) | |
| 673 | + | config := helperSandboxConfigForCarriage(t, carriage, brokerTestPolicy()) | |
| 674 | + | ||
| 675 | + | scripted := &scriptedDaemonStarter{carriage: carriage} | |
| 676 | + | withDaemonStarter(t, scripted.starter(fastDaemonStartBackoff)) | |
| 677 | + | ||
| 678 | + | _, err := dialMuxHelperCarriage(context.Background(), config, ports.BrokerDaemonExistingOnly) | |
| 679 | + | require.ErrorIs(t, err, os.ErrNotExist) | |
| 680 | + | dials, spawns := scripted.counters() | |
| 681 | + | require.Equal(t, 1, dials, "existing-only is exactly one carriage dial") | |
| 682 | + | require.Zero(t, spawns) | |
| 683 | + | ||
| 684 | + | // The provisioned policy does not name the launch authority, so a | |
| 685 | + | // start-if-needed request is refused before any dial and never spawns: the | |
| 686 | + | // helper's own authority is static, so the refusal is decided up front. | |
| 687 | + | _, err = dialMuxHelperCarriage(context.Background(), config, ports.BrokerDaemonStartIfNeeded) | |
| 688 | + | require.Error(t, err) | |
| 689 | + | var typed ports.BrokerError | |
| 690 | + | require.ErrorAs(t, err, &typed) | |
| 691 | + | require.Equal(t, ports.BrokerErrorConflictingPolicy, typed.Code) | |
| 692 | + | dials, spawns = scripted.counters() | |
| 693 | + | require.Equal(t, 1, dials, "a policy refusal is decided before any dial") | |
| 694 | + | require.Zero(t, spawns, "a non-launching helper policy must never spawn") | |
| 695 | + | require.NoFileExists(t, filepath.Join(dir, spawnLockName)) | |
| 696 | + | ||
| 697 | + | // An unknown mode and a missing configuration are never defaulted. | |
| 698 | + | _, err = dialMuxHelperCarriage(context.Background(), config, ports.BrokerDaemonStartMode(9)) | |
| 699 | + | require.Error(t, err) | |
| 700 | + | _, err = dialMuxHelperCarriage(context.Background(), nil, ports.BrokerDaemonExistingOnly) | |
| 701 | + | require.Error(t, err) | |
| 702 | + | dials, spawns = scripted.counters() | |
| 703 | + | require.Equal(t, 1, dials, "a refused mode never reaches a dial") | |
| 704 | + | require.Zero(t, spawns) | |
| 705 | + | } | |
| 706 | + | ||
| 707 | + | // TestMuxHelperCarriageStartsUnderItsOwnLaunchAuthority proves the helper starts | |
| 708 | + | // the daemon only when its own provisioned policy authorizes launching, and that | |
| 709 | + | // it starts the daemon published on its own machine rather than another broker. | |
| 710 | + | func TestMuxHelperCarriageStartsUnderItsOwnLaunchAuthority(t *testing.T) { | |
| 711 | + | isolateSandboxEnv(t) | |
| 712 | + | _, carriage := testDaemonCarriage(t) | |
| 713 | + | config := helperSandboxConfigForCarriage(t, carriage, brokerTestPolicyForLaunch()) | |
| 714 | + | ||
| 715 | + | scripted := &scriptedDaemonStarter{carriage: carriage} | |
| 716 | + | withDaemonStarter(t, scripted.starter(fastDaemonStartBackoff)) | |
| 717 | + | ||
| 718 | + | raw, err := dialMuxHelperCarriage(context.Background(), config, ports.BrokerDaemonStartIfNeeded) | |
| 719 | + | require.NoError(t, err) | |
| 720 | + | require.NotNil(t, raw) | |
| 721 | + | _, spawns := scripted.counters() | |
| 722 | + | require.Equal(t, 1, spawns) | |
| 723 | + | } | |
| 724 | + | ||
| 725 | + | // brokerTestPolicyForLaunch is the sandbox policy with the launch authority the | |
| 726 | + | // broker's own production local daemon names, so a helper test can exercise the | |
| 727 | + | // authorized start path without provisioning production layout. | |
| 728 | + | func brokerTestPolicyForLaunch() ports.BrokerPolicy { | |
| 729 | + | policy := brokerTestPolicy() | |
| 730 | + | policy.Launch = brokerLaunchAuthority | |
| 731 | + | return policy | |
| 732 | + | } | |
| 733 | + | ||
| 734 | + | // TestDaemonStartArgvFlagRoundTrips proves the helper argument spelling is one | |
| 735 | + | // closed value both sides agree on, and that an unknown value is refused in | |
| 736 | + | // either direction. | |
| 737 | + | func TestDaemonStartArgvFlagRoundTrips(t *testing.T) { | |
| 738 | + | tests := []struct { | |
| 739 | + | mode ports.BrokerDaemonStartMode | |
| 740 | + | flag string | |
| 741 | + | }{ | |
| 742 | + | {mode: ports.BrokerDaemonExistingOnly, flag: "existing-only"}, | |
| 743 | + | {mode: ports.BrokerDaemonStartIfNeeded, flag: "if-needed"}, | |
| 744 | + | } | |
| 745 | + | for _, tt := range tests { | |
| 746 | + | flag, err := brokerDaemonStartArgvFlag(tt.mode) | |
| 747 | + | require.NoError(t, err) | |
| 748 | + | require.Equal(t, tt.flag, flag) | |
| 749 | + | parsed, err := parseBrokerDaemonStartArgvFlag(flag) | |
| 750 | + | require.NoError(t, err) | |
| 751 | + | require.Equal(t, tt.mode, parsed) | |
| 752 | + | } | |
| 753 | + | _, err := brokerDaemonStartArgvFlag(ports.BrokerDaemonStartMode(0)) | |
| 754 | + | require.Error(t, err) | |
| 755 | + | for _, invalid := range []string{"", "existing_only", "if_needed", "always", "Existing-Only"} { | |
| 756 | + | _, err := parseBrokerDaemonStartArgvFlag(invalid) | |
| 757 | + | require.Error(t, err, "value %q must be refused", invalid) | |
| 758 | + | } | |
| 759 | + | } | |
| 760 | + | ||
| 761 | + | // testLocalSandboxConfigForCarriage provisions one sandbox whose single local | |
| 762 | + | // binding is the supplied private carriage, and returns its loaded | |
| 763 | + | // configuration and binding. | |
| 764 | + | func testLocalSandboxConfigForCarriage(t *testing.T, carriage string) (*brokerconfig.Config, brokerconfig.LocalBinding) { | |
| 765 | + | t.Helper() | |
| 766 | + | root := filepath.Dir(carriage) | |
| 767 | + | writePrivateSandboxConfig(t, root, carriage, brokerTestPolicy()) | |
| 768 | + | layout := mustOfflineLayout(t, root) | |
| 769 | + | config, err := brokerconfig.Load(layout) | |
| 770 | + | require.NoError(t, err) | |
| 771 | + | binding, ok := config.LocalBinding() | |
| 772 | + | require.True(t, ok) | |
| 773 | + | return config, binding | |
| 774 | + | } | |
| 775 | + | ||
| 776 | + | func mustOfflineLayout(t *testing.T, root string) brokerconfig.Layout { | |
| 777 | + | t.Helper() | |
| 778 | + | layout, err := offlineLayout(root) | |
| 779 | + | require.NoError(t, err) | |
| 780 | + | return layout | |
| 781 | + | } | |
| 782 | + | ||
| 783 | + | func mustLocalMuxAddress(t *testing.T, config *brokerconfig.Config) string { | |
| 784 | + | t.Helper() | |
| 785 | + | route, err := config.LocalMuxRoute() | |
| 786 | + | require.NoError(t, err) | |
| 787 | + | return route.Address() | |
| 788 | + | } | |
| 789 | + | ||
| 790 | + | // TestBrokerMuxConnectorObservationNeverSpawns proves the composed broker | |
| 791 | + | // connector dials the broker-owned local carriage for an observation under the | |
| 792 | + | // existing-only authorization and spawns nothing, so observing a stopped local | |
| 793 | + | // daemon stays a reported failure rather than a start. | |
| 794 | + | func TestBrokerMuxConnectorObservationNeverSpawns(t *testing.T) { | |
| 795 | + | isolateSandboxEnv(t) | |
| 796 | + | _, carriage := testDaemonCarriage(t) | |
| 797 | + | config, binding := testLocalSandboxConfigForCarriage(t, carriage) | |
| 798 | + | ||
| 799 | + | scripted := &scriptedDaemonStarter{carriage: carriage} | |
| 800 | + | withDaemonStarter(t, scripted.starter(fastDaemonStartBackoff)) | |
| 801 | + | ||
| 802 | + | connector, err := brokerMuxConnector(config, slog.New(slog.NewTextHandler(io.Discard, nil))) | |
| 803 | + | require.NoError(t, err) | |
| 804 | + | target, err := config.Resolver().ResolveDialTarget(context.Background(), ports.BrokerOpenStreamRequest{ | |
| 805 | + | Purpose: ports.BrokerStreamObservation, | |
| 806 | + | Local: true, | |
| 807 | + | Stream: 1, | |
| 808 | + | Policy: binding.Policy, | |
| 809 | + | StartMode: ports.BrokerDaemonExistingOnly, | |
| 810 | + | }) | |
| 811 | + | require.NoError(t, err) | |
| 812 | + | ||
| 813 | + | _, err = connector.Connect(context.Background(), target) | |
| 814 | + | require.ErrorIs(t, err, os.ErrNotExist) | |
| 815 | + | dials, spawns := scripted.counters() | |
| 816 | + | require.Equal(t, 1, dials, "the observation dials its local carriage once") | |
| 817 | + | require.Zero(t, spawns, "an observation must never start a daemon") | |
| 818 | + | } | |
| 819 | + | ||
| 820 | + | // TestBrokerMuxConnectorRefusesMismatchedAuthority proves the transport never | |
| 821 | + | // dials under an authority the configuration did not provision: a target whose | |
| 822 | + | // policy or address no longer matches the route is refused before any dial. | |
| 823 | + | func TestBrokerMuxConnectorRefusesMismatchedAuthority(t *testing.T) { | |
| 824 | + | isolateSandboxEnv(t) | |
| 825 | + | _, carriage := testDaemonCarriage(t) | |
| 826 | + | config, _ := testLocalSandboxConfigForCarriage(t, carriage) | |
| 827 | + | ||
| 828 | + | scripted := &scriptedDaemonStarter{carriage: carriage} | |
| 829 | + | withDaemonStarter(t, scripted.starter(fastDaemonStartBackoff)) | |
| 830 | + | ||
| 831 | + | connector, err := brokerMuxConnector(config, slog.New(slog.NewTextHandler(io.Discard, nil))) | |
| 832 | + | require.NoError(t, err) | |
| 833 | + | ||
| 834 | + | target := startableLocalTarget(t, carriage, ports.BrokerDaemonExistingOnly) | |
| 835 | + | // The address is provisioned, but the policy is not. | |
| 836 | + | target.Policy.Launch = "other-launch" | |
| 837 | + | require.NoError(t, target.Validate()) | |
| 838 | + | _, err = connector.Connect(context.Background(), target) | |
| 839 | + | require.Error(t, err) | |
| 840 | + | var typed ports.BrokerError | |
| 841 | + | require.ErrorAs(t, err, &typed) | |
| 842 | + | require.Equal(t, ports.BrokerErrorConflictingPolicy, typed.Code) | |
| 843 | + | ||
| 844 | + | // An address the configuration never produced is refused too. | |
| 845 | + | unknown := target | |
| 846 | + | unknown.Address = "0000000000000000000000000000000000000000000000000000000000000000" | |
| 847 | + | require.NoError(t, unknown.Validate()) | |
| 848 | + | _, err = connector.Connect(context.Background(), unknown) | |
| 849 | + | require.Error(t, err) | |
| 850 | + | ||
| 851 | + | dials, spawns := scripted.counters() | |
| 852 | + | require.Zero(t, dials, "an unmatched authority must never reach a dial") | |
| 853 | + | require.Zero(t, spawns) | |
| 854 | + | } | |
M internal/app/broker_offline.go +194 -27
| @@ -8,7 +8,9 @@ import ( | |||
| 8 | 8 | "fmt" | |
| 9 | 9 | "io" | |
| 10 | 10 | "log/slog" | |
| 11 | + | "os" | |
| 11 | 12 | "os/signal" | |
| 13 | + | "path/filepath" | |
| 12 | 14 | "strings" | |
| 13 | 15 | "syscall" | |
| 14 | 16 | "time" | |
| @@ -17,6 +19,8 @@ import ( | |||
| 17 | 19 | "github.com/bnema/vev/internal/adapters/brokeripc" | |
| 18 | 20 | "github.com/bnema/vev/internal/adapters/brokerstore" | |
| 19 | 21 | "github.com/bnema/vev/internal/adapters/clock" | |
| 22 | + | "github.com/bnema/vev/internal/adapters/daemonidentity" | |
| 23 | + | "github.com/bnema/vev/internal/adapters/daemonmux" | |
| 20 | 24 | "github.com/bnema/vev/internal/adapters/ipc" | |
| 21 | 25 | "github.com/bnema/vev/internal/adapters/lifecycle" | |
| 22 | 26 | "github.com/bnema/vev/internal/logging" | |
| @@ -42,7 +46,10 @@ import ( | |||
| 42 | 46 | // until a signal or its parent context ends it, or until the broker idles out, | |
| 43 | 47 | // and then shuts down in a fixed order. | |
| 44 | 48 | ||
| 45 | - | const brokerServeCommand = "_broker-serve" | |
| 49 | + | const ( | |
| 50 | + | brokerServeCommand = "_broker-serve" | |
| 51 | + | productionBrokerServeCommand = "_broker-production-serve" | |
| 52 | + | ) | |
| 46 | 53 | ||
| 47 | 54 | // brokerServePoolLimits bound the offline broker pool. They are explicit, not | |
| 48 | 55 | // derived from the environment, and small enough that a stalled peer is | |
| @@ -58,6 +65,7 @@ var brokerServePoolLimits = broker.PoolLimits{ | |||
| 58 | 65 | // brokerServeOptions is the parsed hidden invocation. | |
| 59 | 66 | type brokerServeOptions struct { | |
| 60 | 67 | offlineRoot string | |
| 68 | + | production bool | |
| 61 | 69 | // idleGrace is zero when the operator did not override it; the supervisor | |
| 62 | 70 | // then applies its own 5m default. | |
| 63 | 71 | idleGrace time.Duration | |
| @@ -67,6 +75,13 @@ type brokerServeOptions struct { | |||
| 67 | 75 | // [--idle-grace DURATION]`. Unknown flags, duplicate flags, positionals, a | |
| 68 | 76 | // missing value, and a non-positive or unparsable duration are refused; the | |
| 69 | 77 | // path itself is validated later against the filesystem. | |
| 78 | + | func parseProductionBrokerServeArgs(args []string) (command, error) { | |
| 79 | + | if len(args) != 0 { | |
| 80 | + | return command{}, usagef("`%s` does not accept arguments", productionBrokerServeCommand) | |
| 81 | + | } | |
| 82 | + | return command{kind: kindProductionBrokerServe, brokerServe: brokerServeOptions{production: true}}, nil | |
| 83 | + | } | |
| 84 | + | ||
| 70 | 85 | func parseBrokerServeArgs(args []string) (command, error) { | |
| 71 | 86 | if len(args) == 0 { | |
| 72 | 87 | return command{}, usagef("`%s` requires --offline-root", brokerServeCommand) | |
| @@ -118,6 +133,29 @@ func parseBrokerServeArgs(args []string) (command, error) { | |||
| 118 | 133 | // brokerServeDeps are the composition seams. Production supplies the real | |
| 119 | 134 | // adapters; tests override the clock, the owner lock, the logger, and the | |
| 120 | 135 | // readiness hook so idle shutdown and cleanup are deterministic. | |
| 136 | + | type productionIdentityBinder struct { | |
| 137 | + | delegate ports.BrokerIdentityBinder | |
| 138 | + | stateDir string | |
| 139 | + | policy ports.BrokerPolicy | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | func (b productionIdentityBinder) BindAuthenticatedIdentity(ctx context.Context, request ports.BrokerIdentityBindingRequest) (ports.BrokerDaemonIdentity, error) { | |
| 143 | + | if !request.Fence.Local { | |
| 144 | + | return b.delegate.BindAuthenticatedIdentity(ctx, request) | |
| 145 | + | } | |
| 146 | + | if request.Policy != b.policy { | |
| 147 | + | return "", errors.New("vev: local daemon policy authority changed") | |
| 148 | + | } | |
| 149 | + | identity, err := daemonidentity.Load(b.stateDir) | |
| 150 | + | if err != nil { | |
| 151 | + | return "", fmt.Errorf("vev: revalidate daemon authority: %w", err) | |
| 152 | + | } | |
| 153 | + | if identity != request.Identity { | |
| 154 | + | return "", errors.New("vev: local daemon identity authority changed") | |
| 155 | + | } | |
| 156 | + | return identity, nil | |
| 157 | + | } | |
| 158 | + | ||
| 121 | 159 | type brokerServeDeps struct { | |
| 122 | 160 | clock func() ports.Clock | |
| 123 | 161 | acquire func(string) (lifecycleOwnership, error) | |
| @@ -144,9 +182,23 @@ func defaultBrokerServeDeps() brokerServeDeps { | |||
| 144 | 182 | ||
| 145 | 183 | // runBrokerServeCommand enters the hidden foreground process: it installs | |
| 146 | 184 | // SIGINT/SIGTERM cancellation over the parent context and runs the sandbox. | |
| 147 | - | func runBrokerServeCommand(ctx context.Context, options brokerServeOptions) error { | |
| 185 | + | func runBrokerServeCommand(ctx context.Context, options brokerServeOptions) (retErr error) { | |
| 148 | 186 | ctx, stop := signal.NotifyContext(ctx, syscall.SIGINT, syscall.SIGTERM) | |
| 149 | 187 | defer stop() | |
| 188 | + | var observerCloser io.Closer | |
| 189 | + | if options.production { | |
| 190 | + | observer, closer, err := newPerformanceTrace(clock.New()) | |
| 191 | + | if err != nil { | |
| 192 | + | return fmt.Errorf("vev: performance trace: %w", err) | |
| 193 | + | } | |
| 194 | + | observerCloser = closer | |
| 195 | + | if observer != nil { | |
| 196 | + | observer.ObserveRuntime(ports.NewRuntimeMark("broker", ports.RuntimeTransportDiagnostic, 0, true)) | |
| 197 | + | } | |
| 198 | + | } | |
| 199 | + | if observerCloser != nil { | |
| 200 | + | defer func() { retErr = errors.Join(retErr, observerCloser.Close()) }() | |
| 201 | + | } | |
| 150 | 202 | return runBrokerServe(ctx, options, defaultBrokerServeDeps()) | |
| 151 | 203 | } | |
| 152 | 204 | ||
| @@ -211,9 +263,18 @@ func effectiveIdleGrace(config *brokerconfig.Config, explicit time.Duration) (ti | |||
| 211 | 263 | // supervisor open from construction until the listener and its accept drain are | |
| 212 | 264 | // ready, so the idle timer can never fire during setup. | |
| 213 | 265 | func runBrokerServe(ctx context.Context, options brokerServeOptions, deps brokerServeDeps) (retErr error) { | |
| 214 | - | layout, err := offlineLayout(options.offlineRoot) | |
| 215 | - | if err != nil { | |
| 216 | - | return err | |
| 266 | + | var layout brokerconfig.Layout | |
| 267 | + | var configPath string | |
| 268 | + | var err error | |
| 269 | + | if options.production { | |
| 270 | + | layout = productionBrokerLayout() | |
| 271 | + | configPath = productionBrokerConfigPath() | |
| 272 | + | } else { | |
| 273 | + | layout, err = offlineLayout(options.offlineRoot) | |
| 274 | + | if err != nil { | |
| 275 | + | return err | |
| 276 | + | } | |
| 277 | + | configPath = filepath.Join(layout.Root, brokerconfig.ConfigFileName) | |
| 217 | 278 | } | |
| 218 | 279 | for _, dir := range []string{layout.Root, layout.Runtime, layout.State, layout.Log} { | |
| 219 | 280 | if err := safedir.EnsurePrivate(dir); err != nil { | |
| @@ -227,7 +288,19 @@ func runBrokerServe(ctx context.Context, options brokerServeOptions, deps broker | |||
| 227 | 288 | if err := layout.VerifyCreated(); err != nil { | |
| 228 | 289 | return fmt.Errorf("vev: verify broker sandbox paths: %w", err) | |
| 229 | 290 | } | |
| 230 | - | config, err := brokerconfig.Load(layout) | |
| 291 | + | var config *brokerconfig.Config | |
| 292 | + | if options.production { | |
| 293 | + | // The broker is allowed to precede the daemon. Identity is therefore an | |
| 294 | + | // optional existing authority here; acquisition binds it after an | |
| 295 | + | // authenticated StartIfNeeded connection when the daemon is first born. | |
| 296 | + | identity, loadErr := daemonidentity.Load(platform.StateDir()) | |
| 297 | + | if loadErr != nil && !errors.Is(loadErr, os.ErrNotExist) { | |
| 298 | + | return fmt.Errorf("vev: load daemon authority: %w", loadErr) | |
| 299 | + | } | |
| 300 | + | config, err = brokerconfig.LoadProduction(layout, configPath, identity, localDaemonPolicy(), daemonmux.SocketPath(ipc.SocketDir())) | |
| 301 | + | } else { | |
| 302 | + | config, err = brokerconfig.LoadPath(layout, configPath) | |
| 303 | + | } | |
| 231 | 304 | if err != nil { | |
| 232 | 305 | return err | |
| 233 | 306 | } | |
| @@ -247,7 +320,22 @@ func runBrokerServe(ctx context.Context, options brokerServeOptions, deps broker | |||
| 247 | 320 | } | |
| 248 | 321 | defer func() { retErr = errors.Join(retErr, logCloser.Close()) }() | |
| 249 | 322 | ||
| 250 | - | store, err := brokerstore.Open(brokerstore.Options{Dir: layout.State}) | |
| 323 | + | if options.production { | |
| 324 | + | // The durable route schema intentionally delegates SSH trust to OpenSSH. | |
| 325 | + | // Refuse nonrepresentable legacy overrides rather than silently dropping | |
| 326 | + | // security inputs during the one-time production import. | |
| 327 | + | for _, endpoint := range config.Endpoints() { | |
| 328 | + | registration, _ := config.Registration(endpoint) | |
| 329 | + | if registration.Route.KnownHostsFile() != "" || registration.Route.ConnectTimeout() != 0 { | |
| 330 | + | return errors.New("vev: production broker routes require SSH trust and timeouts in OpenSSH configuration") | |
| 331 | + | } | |
| 332 | + | } | |
| 333 | + | } | |
| 334 | + | initialHosts, err := configuredBrokerHosts(config) | |
| 335 | + | if err != nil { | |
| 336 | + | return err | |
| 337 | + | } | |
| 338 | + | store, err := brokerstore.Open(brokerstore.Options{Dir: layout.State, InitialHosts: initialHosts, InitialImportProvided: true}) | |
| 251 | 339 | if err != nil { | |
| 252 | 340 | return fmt.Errorf("vev: open broker sandbox store: %w", err) | |
| 253 | 341 | } | |
| @@ -283,36 +371,59 @@ func runBrokerServe(ctx context.Context, options brokerServeOptions, deps broker | |||
| 283 | 371 | } | |
| 284 | 372 | defer startupLease.Release() | |
| 285 | 373 | ||
| 286 | - | registryConfig, err := offlineRegistryConfig(config, log) | |
| 374 | + | // The sandbox retains its explicit route/trust fixture contract. Production | |
| 375 | + | // imports membership once, then uses only durable remote authority. | |
| 376 | + | dynamicRoutes := &brokerRoutes{local: config.Resolver()} | |
| 377 | + | var resolver ports.BrokerRouteAuthority = config.Resolver() | |
| 378 | + | connector, err := brokerMuxConnector(config, log) | |
| 379 | + | if options.production { | |
| 380 | + | resolver = dynamicRoutes | |
| 381 | + | connector, err = brokerDynamicMuxConnector(config, dynamicRoutes, log) | |
| 382 | + | } | |
| 287 | 383 | if err != nil { | |
| 288 | 384 | return err | |
| 289 | 385 | } | |
| 290 | - | connector, err := brokerMuxConnector(config, log) | |
| 386 | + | var localIdentityLoaders []localIdentityLoader | |
| 387 | + | if options.production { | |
| 388 | + | localIdentityLoaders = append(localIdentityLoaders, func() (ports.BrokerDaemonIdentity, error) { | |
| 389 | + | identity, loadErr := daemonidentity.Load(platform.StateDir()) | |
| 390 | + | if errors.Is(loadErr, os.ErrNotExist) { | |
| 391 | + | return "", nil | |
| 392 | + | } | |
| 393 | + | return identity, loadErr | |
| 394 | + | }) | |
| 395 | + | } | |
| 396 | + | registryConfig, err := offlineRegistryConfig(config, epoch, connector, localIdentityLoaders...) | |
| 291 | 397 | if err != nil { | |
| 292 | 398 | return err | |
| 293 | 399 | } | |
| 294 | - | resolver := config.Resolver() | |
| 295 | 400 | var remoteProbe ports.BrokerHostProbe | |
| 296 | - | if len(config.Endpoints()) != 0 { | |
| 297 | - | remoteProbe = &brokerRemoteProbe{ | |
| 298 | - | epoch: epoch, resolver: resolver, connector: connector, | |
| 299 | - | policy: func(endpoint string) (ports.BrokerPolicy, bool) { | |
| 300 | - | registration, ok := config.Registration(endpoint) | |
| 301 | - | return registration.Policy, ok | |
| 302 | - | }, | |
| 401 | + | var concreteRemoteProbe *brokerRemoteProbe | |
| 402 | + | if options.production || len(config.Endpoints()) != 0 { | |
| 403 | + | concreteRemoteProbe = &brokerRemoteProbe{ | |
| 404 | + | epoch: epoch, routes: resolver, connector: connector, | |
| 303 | 405 | } | |
| 406 | + | remoteProbe = concreteRemoteProbe | |
| 304 | 407 | } | |
| 305 | 408 | registry, err := broker.NewRegistryWithConfig(epoch, store, remoteProbe, clk, log, registryConfig) | |
| 306 | 409 | if err != nil { | |
| 307 | 410 | return err | |
| 308 | 411 | } | |
| 309 | - | // Registration order is shutdown order reversed: registering the registry | |
| 310 | - | // first, then the pool, then the listener drains them listener -> pool -> | |
| 311 | - | // registry. | |
| 412 | + | dynamicRoutes.hosts = registry | |
| 413 | + | binder := ports.BrokerIdentityBinder(registry) | |
| 414 | + | if options.production { | |
| 415 | + | binder = productionIdentityBinder{delegate: registry, stateDir: platform.StateDir(), policy: localDaemonPolicy()} | |
| 416 | + | } | |
| 417 | + | if concreteRemoteProbe != nil { | |
| 418 | + | concreteRemoteProbe.binder = binder | |
| 419 | + | concreteRemoteProbe.hosts = registry | |
| 420 | + | } | |
| 421 | + | // RegisterRunner starts immediately: finish the cyclic composition before | |
| 422 | + | // publishing it to any goroutine. Shutdown remains listener -> pool -> registry. | |
| 312 | 423 | if err := supervisor.RegisterRunner("registry", registry); err != nil { | |
| 313 | 424 | return err | |
| 314 | 425 | } | |
| 315 | - | pool, err := broker.NewPool(epoch, resolver, connector, clk, brokerServePoolLimits) | |
| 426 | + | pool, err := broker.NewPool(epoch, resolver, binder, connector, clk, brokerServePoolLimits) | |
| 316 | 427 | if err != nil { | |
| 317 | 428 | return err | |
| 318 | 429 | } | |
| @@ -339,7 +450,7 @@ func runBrokerServe(ctx context.Context, options brokerServeOptions, deps broker | |||
| 339 | 450 | go drainBrokerAccept(listener, log, started, drained, failed) | |
| 340 | 451 | <-started | |
| 341 | 452 | startupLease.Release() | |
| 342 | - | log.Info("broker_offline_ready", "socket", socketPath, "endpoints", len(config.Endpoints())) | |
| 453 | + | log.Info("broker_ready", "socket", socketPath, "endpoints", len(config.Endpoints()), "production", options.production) | |
| 343 | 454 | if deps.onReady != nil { | |
| 344 | 455 | deps.onReady(socketPath) | |
| 345 | 456 | } | |
| @@ -358,7 +469,7 @@ func runBrokerServe(ctx context.Context, options brokerServeOptions, deps broker | |||
| 358 | 469 | closeErr := supervisor.Close() | |
| 359 | 470 | supervisorClosed = true | |
| 360 | 471 | <-drained | |
| 361 | - | log.Info("broker_offline_stopped") | |
| 472 | + | log.Info("broker_stopped", "production", options.production) | |
| 362 | 473 | return closeErr | |
| 363 | 474 | } | |
| 364 | 475 | ||
| @@ -372,18 +483,74 @@ func runBrokerServe(ctx context.Context, options brokerServeOptions, deps broker | |||
| 372 | 483 | // is never logged; any other terminal accept failure is unexpected: it is logged | |
| 373 | 484 | // as an error and reported on failed so the caller commits shutdown instead of | |
| 374 | 485 | // leaving a bound broker that can no longer admit work. | |
| 375 | - | func offlineRegistryConfig(config *brokerconfig.Config, log *slog.Logger) (broker.RegistryConfig, error) { | |
| 486 | + | // configuredBrokerHosts converts the one-time configuration import into store | |
| 487 | + | // input. brokerstore consumes it only while creating its first manifest; an | |
| 488 | + | // existing (including deliberately empty) membership is never reseeded. | |
| 489 | + | func configuredBrokerHosts(config *brokerconfig.Config) ([]ports.BrokerHostRecord, error) { | |
| 490 | + | records := make([]ports.BrokerHostRecord, 0, len(config.Endpoints())) | |
| 491 | + | for _, endpoint := range config.Endpoints() { | |
| 492 | + | registration, ok := config.Registration(endpoint) | |
| 493 | + | if !ok { | |
| 494 | + | return nil, fmt.Errorf("vev: missing configured broker registration %q", endpoint) | |
| 495 | + | } | |
| 496 | + | route, err := durableBrokerRoute(registration.Route) | |
| 497 | + | if err != nil { | |
| 498 | + | return nil, fmt.Errorf("vev: import configured broker host %q: %w", endpoint, err) | |
| 499 | + | } | |
| 500 | + | records = append(records, ports.BrokerHostRecord{Registration: registration.Registration, Pinned: true, Policy: registration.Policy, Identity: registration.Identity, Route: route}) | |
| 501 | + | } | |
| 502 | + | return records, nil | |
| 503 | + | } | |
| 504 | + | ||
| 505 | + | func durableBrokerRoute(route brokerconfig.Route) (ports.BrokerRouteSpec, error) { | |
| 506 | + | spec := ports.BrokerRouteSpec{Path: route.Path(), Target: route.Target(), Argv: route.Argv()} | |
| 507 | + | switch route.Kind() { | |
| 508 | + | case brokerconfig.RouteUnix: | |
| 509 | + | spec.Kind = ports.BrokerRouteUnix | |
| 510 | + | case brokerconfig.RouteSSHStdio: | |
| 511 | + | spec.Kind = ports.BrokerRouteSSHStdio | |
| 512 | + | case brokerconfig.RouteSSHQUIC: | |
| 513 | + | spec.Kind = ports.BrokerRouteSSHQUIC | |
| 514 | + | default: | |
| 515 | + | return ports.BrokerRouteSpec{}, errors.New("unknown configured broker route kind") | |
| 516 | + | } | |
| 517 | + | if err := spec.Validate(); err != nil { | |
| 518 | + | return ports.BrokerRouteSpec{}, err | |
| 519 | + | } | |
| 520 | + | return spec, nil | |
| 521 | + | } | |
| 522 | + | ||
| 523 | + | // offlineRegistryConfig selects the registry's observation producers from one | |
| 524 | + | // immutable configuration. A configuration that provisions a local binding | |
| 525 | + | // always composes the broker-owned local producer over the broker's already | |
| 526 | + | // owned endpoint connector, so the probe is dynamic: it derives its authority | |
| 527 | + | // from the local route, the local daemon policy, and an identity loader that is | |
| 528 | + | // re-read on every attempt, so the broker may precede the daemon and start | |
| 529 | + | // observing the moment the daemon first publishes its identity. Such a | |
| 530 | + | // composition is mutable: the broker owns host membership for its whole run, | |
| 531 | + | // including a zero-host membership, so a caller can add and remove hosts. | |
| 532 | + | // | |
| 533 | + | // A configuration that provisions no local binding is an explicitly configured | |
| 534 | + | // fixture: it is deliberately left observation-disabled, so a read-only | |
| 535 | + | // snapshot owner issues no probes, arms no timers, and never reconciles. The | |
| 536 | + | // disabled fixture's membership mode follows the endpoints it was provisioned | |
| 537 | + | // with, exactly as before. | |
| 538 | + | func offlineRegistryConfig(config *brokerconfig.Config, epoch ports.BrokerEpoch, connector ports.BrokerEndpointConnector, loaders ...localIdentityLoader) (broker.RegistryConfig, error) { | |
| 376 | 539 | if config == nil { | |
| 377 | 540 | return broker.RegistryConfig{}, errors.New("vev: offline registry requires configuration") | |
| 378 | 541 | } | |
| 379 | 542 | if _, ok := config.LocalBinding(); !ok { | |
| 380 | - | return broker.RegistryConfig{ObservationDisabled: true}, nil | |
| 543 | + | mode := broker.MembershipImmutable | |
| 544 | + | if len(config.Endpoints()) != 0 { | |
| 545 | + | mode = broker.MembershipMutable | |
| 546 | + | } | |
| 547 | + | return broker.RegistryConfig{ObservationDisabled: true, MembershipMode: mode}, nil | |
| 381 | 548 | } | |
| 382 | - | local, err := brokerLocalObservation(config, log) | |
| 549 | + | local, err := brokerLocalObservation(config, epoch, connector, loaders...) | |
| 383 | 550 | if err != nil { | |
| 384 | 551 | return broker.RegistryConfig{}, err | |
| 385 | 552 | } | |
| 386 | - | return broker.RegistryConfig{Local: local}, nil | |
| 553 | + | return broker.RegistryConfig{Local: local, MembershipMode: broker.MembershipMutable}, nil | |
| 387 | 554 | } | |
| 388 | 555 | ||
| 389 | 556 | func drainBrokerAccept(listener ports.BrokerListener, log *slog.Logger, started chan<- struct{}, drained chan<- struct{}, failed chan<- struct{}) { | |
M internal/app/broker_offline_client.go +75 -118
| @@ -7,12 +7,10 @@ import ( | |||
| 7 | 7 | "log/slog" | |
| 8 | 8 | "os" | |
| 9 | 9 | "strings" | |
| 10 | - | "sync" | |
| 11 | 10 | ||
| 12 | 11 | "github.com/bnema/vev/internal/adapters/brokeripc" | |
| 13 | 12 | "github.com/bnema/vev/internal/adapters/clock" | |
| 14 | 13 | "github.com/bnema/vev/internal/adapters/term" | |
| 15 | - | "github.com/bnema/vev/internal/adapters/uidriver" | |
| 16 | 14 | "github.com/bnema/vev/internal/adapters/uiterm" | |
| 17 | 15 | "github.com/bnema/vev/internal/domain" | |
| 18 | 16 | "github.com/bnema/vev/internal/ports" | |
| @@ -32,11 +30,14 @@ const ( | |||
| 32 | 30 | type brokerClientOptions struct { | |
| 33 | 31 | offlineRoot string | |
| 34 | 32 | harness string | |
| 33 | + | // picker starts the harness with no initial navigation at all, exactly like | |
| 34 | + | // `--ui-driver --picker`: the client presents the picker and opens nothing. | |
| 35 | + | picker bool | |
| 35 | 36 | } | |
| 36 | 37 | ||
| 37 | 38 | func parseBrokerClientArgs(args []string) (command, error) { | |
| 38 | 39 | options := brokerClientOptions{harness: offlineClientTerminal} | |
| 39 | - | var rootSet, harnessSet bool | |
| 40 | + | var rootSet, harnessSet, pickerSet bool | |
| 40 | 41 | for index := 0; index < len(args); { | |
| 41 | 42 | switch args[index] { | |
| 42 | 43 | case "--offline-root": | |
| @@ -60,6 +61,12 @@ func parseBrokerClientArgs(args []string) (command, error) { | |||
| 60 | 61 | return command{}, usagef("`--harness` requires terminal or ui-driver") | |
| 61 | 62 | } | |
| 62 | 63 | index += 2 | |
| 64 | + | case "--picker": | |
| 65 | + | if pickerSet { | |
| 66 | + | return command{}, usagef("`%s` received duplicate --picker", brokerClientCommand) | |
| 67 | + | } | |
| 68 | + | options.picker, pickerSet = true, true | |
| 69 | + | index++ | |
| 63 | 70 | default: | |
| 64 | 71 | if strings.HasPrefix(args[index], "-") { | |
| 65 | 72 | return command{}, usagef("unknown flag %q for `%s`", args[index], brokerClientCommand) | |
| @@ -70,6 +77,9 @@ func parseBrokerClientArgs(args []string) (command, error) { | |||
| 70 | 77 | if !rootSet { | |
| 71 | 78 | return command{}, usagef("`%s` requires --offline-root", brokerClientCommand) | |
| 72 | 79 | } | |
| 80 | + | if pickerSet && options.harness != offlineClientUIDriver { | |
| 81 | + | return command{}, usagef("`%s` --picker requires --harness ui-driver", brokerClientCommand) | |
| 82 | + | } | |
| 73 | 83 | return command{kind: kindBrokerClient, brokerClient: options}, nil | |
| 74 | 84 | } | |
| 75 | 85 | ||
| @@ -89,136 +99,83 @@ func runBrokerClientCommand(ctx context.Context, options brokerClientOptions) er | |||
| 89 | 99 | } | |
| 90 | 100 | defer terminal.Close() | |
| 91 | 101 | ui := client.NewUI(terminal, clock.New()) | |
| 92 | - | return runOfflineUIDriver(ctx, brokeripc.SocketPath(layout.Runtime), terminal, ui, log, &stdioStream{reader: os.Stdin, writer: os.Stdout}) | |
| 102 | + | navigation := localEphemeralNavigation() | |
| 103 | + | if options.picker { | |
| 104 | + | navigation = client.InitialNavigation{} | |
| 105 | + | } | |
| 106 | + | return runOfflineUIDriverClient(ctx, offlineUIDriverHarness{ | |
| 107 | + | socket: brokeripc.SocketPath(layout.Runtime), | |
| 108 | + | terminal: terminal, | |
| 109 | + | ui: ui, | |
| 110 | + | log: log, | |
| 111 | + | navigation: navigation, | |
| 112 | + | stream: &stdioStream{reader: os.Stdin, writer: os.Stdout}, | |
| 113 | + | }) | |
| 93 | 114 | default: | |
| 94 | 115 | return usagef("unknown offline client harness %q", options.harness) | |
| 95 | 116 | } | |
| 96 | 117 | } | |
| 97 | 118 | ||
| 98 | - | // runOfflineClient is the common offline terminal composition used by the | |
| 99 | - | // physical terminal, headless UI driver, and browser terminal harnesses. The | |
| 100 | - | // connector is the real broker IPC adapter and InitialNavigation opens a real | |
| 101 | - | // broker logical stream; no direct daemon dialer is present. | |
| 119 | + | // runOfflineClient is the sandbox terminal harness over the shared broker | |
| 120 | + | // client composition. It supplies the real broker IPC connector for one | |
| 121 | + | // offline root and the no-argument ephemeral local creation, then delegates the | |
| 122 | + | // whole run to runBrokerClient: the sandbox adds no picker, supervisor, dialer, | |
| 123 | + | // or stream of its own. | |
| 102 | 124 | func runOfflineClient(ctx context.Context, socket string, terminal ports.Terminal, ui *client.UI, log *slog.Logger, onState func(client.State), onFailure func(error)) error { | |
| 103 | 125 | if terminal == nil { | |
| 104 | 126 | return errors.New("vev: offline client requires a terminal") | |
| 105 | 127 | } | |
| 106 | - | clk := clock.New() | |
| 107 | - | picker := client.NewPicker(clk, 0) | |
| 108 | - | navigation := client.InitialNavigationCreateEphemeral | |
| 109 | - | supervisor, err := client.NewSupervisor(client.SupervisorConfig{ | |
| 110 | - | Connector: brokeripc.NewConnector(socket, brokeripc.Config{}), | |
| 111 | - | Terminal: terminal, | |
| 112 | - | Clock: clk, | |
| 113 | - | UI: ui, | |
| 114 | - | Picker: picker, | |
| 115 | - | InitialNavigation: &navigation, | |
| 116 | - | AttachmentEnvironment: client.AttachmentEnvironment{ | |
| 117 | - | TermEnv: os.Getenv("TERM"), Cwd: currentWorkingDirectory(), TrueColor: client.DetectTrueColor(os.Getenv("TERM"), os.Getenv("COLORTERM"), os.Environ()), | |
| 118 | - | }, | |
| 119 | - | Render: offlineClientRender(terminal, picker, onState), | |
| 120 | - | Notify: func(_ client.State, err error) { | |
| 121 | - | if onFailure != nil { | |
| 122 | - | onFailure(err) | |
| 123 | - | } | |
| 124 | - | }, | |
| 125 | - | }) | |
| 126 | - | if err != nil { | |
| 127 | - | return err | |
| 128 | - | } | |
| 129 | 128 | if log != nil { | |
| 130 | 129 | log.Debug("broker_offline_client", "socket", socket) | |
| 131 | 130 | } | |
| 132 | - | return supervisor.Run(ctx) | |
| 133 | - | } | |
| 134 | - | ||
| 135 | - | // offlineClientRender is the production offline render callback. Every state | |
| 136 | - | // reaches the optional observer; the terminal is written only while the shared | |
| 137 | - | // client.PickerPresentation fence admits the presentation, which is exactly | |
| 138 | - | // PresentPicker. That is what lets a resize invalidation repaint the picker at | |
| 139 | - | // its current geometry while refusing the pre-attachment connecting transition, | |
| 140 | - | // whose admitted foreground already owns the same terminal writer. | |
| 141 | - | // | |
| 142 | - | // ResizeEvents has exactly one consumer for the whole supervisor run: its | |
| 143 | - | // attachment geometry collector. Picker rendering samples Geometry here at | |
| 144 | - | // render time instead of competing for that single event channel, so a repaint | |
| 145 | - | // always observes the latest size. | |
| 146 | - | // | |
| 147 | - | // The fence is exactly PickerPresentation. An attached foreground owns the | |
| 148 | - | // terminal writer and a terminating process is leaving it, so neither is ever | |
| 149 | - | // painted over from here. | |
| 150 | - | func offlineClientRender(terminal ports.Terminal, picker *client.Picker, onState func(client.State)) func(client.State) { | |
| 151 | - | var writer sync.Mutex | |
| 152 | - | return func(state client.State) { | |
| 153 | - | if onState != nil { | |
| 154 | - | onState(state) | |
| 155 | - | } | |
| 156 | - | if !client.PickerPresentation(state) { | |
| 157 | - | return | |
| 158 | - | } | |
| 159 | - | geometry, err := terminal.Geometry() | |
| 160 | - | if err != nil { | |
| 161 | - | return | |
| 162 | - | } | |
| 163 | - | frame := picker.Render(geometry.Size) | |
| 164 | - | notice := picker.RenderNotice(geometry.Size) | |
| 165 | - | if len(frame) == 0 && len(notice) == 0 { | |
| 166 | - | return | |
| 167 | - | } | |
| 168 | - | writer.Lock() | |
| 169 | - | defer writer.Unlock() | |
| 170 | - | if len(frame) != 0 { | |
| 171 | - | _, _ = terminal.Out().Write(frame) | |
| 172 | - | } | |
| 173 | - | if len(notice) != 0 { | |
| 174 | - | _, _ = terminal.Out().Write(notice) | |
| 175 | - | } | |
| 176 | - | _ = terminal.Flush() | |
| 177 | - | } | |
| 131 | + | return runBrokerClient(ctx, brokerClientConfig{ | |
| 132 | + | Connector: brokeripc.NewConnector(socket, brokeripc.Config{}), | |
| 133 | + | Terminal: terminal, | |
| 134 | + | UI: ui, | |
| 135 | + | // No-argument composition creates one ephemeral local session through | |
| 136 | + | // the closed initial-navigation union; there is no special attach path. | |
| 137 | + | InitialNavigation: localEphemeralNavigation(), | |
| 138 | + | AttachmentEnvironment: terminalAttachmentEnvironment(), | |
| 139 | + | SessionEnvironment: client.SessionEnvironment{Provenance: client.SessionEnvironmentLocalCLI}, | |
| 140 | + | OnState: onState, | |
| 141 | + | OnFailure: onFailure, | |
| 142 | + | }) | |
| 178 | 143 | } | |
| 179 | 144 | ||
| 180 | - | func currentWorkingDirectory() string { | |
| 181 | - | cwd, err := os.Getwd() | |
| 182 | - | if err != nil { | |
| 183 | - | return "" | |
| 184 | - | } | |
| 185 | - | return cwd | |
| 145 | + | // offlineUIDriverHarness is the sandbox harness input: one explicitly supplied | |
| 146 | + | // offline broker socket, the virtual terminal and UI service built over it, the | |
| 147 | + | // one-shot navigation the harness was asked to perform, and the stream the JSONL | |
| 148 | + | // protocol is served on. The CLI's `--picker` selects the zero picker intent; the | |
| 149 | + | // default is the same no-argument local ephemeral creation every frontend uses. | |
| 150 | + | type offlineUIDriverHarness struct { | |
| 151 | + | socket string | |
| 152 | + | terminal *uiterm.Terminal | |
| 153 | + | ui *client.UI | |
| 154 | + | log *slog.Logger | |
| 155 | + | navigation client.InitialNavigation | |
| 156 | + | resolver client.InitialNavigationResolver | |
| 157 | + | stream io.ReadWriteCloser | |
| 186 | 158 | } | |
| 187 | 159 | ||
| 188 | - | func runOfflineUIDriver(ctx context.Context, socket string, terminal *uiterm.Terminal, ui *client.UI, log *slog.Logger, stream io.ReadWriteCloser) error { | |
| 189 | - | runCtx, cancel := context.WithCancel(ctx) | |
| 190 | - | defer cancel() | |
| 191 | - | runnerDone := make(chan error, 1) | |
| 192 | - | states := make(chan client.State, 1) | |
| 193 | - | go func() { | |
| 194 | - | runnerDone <- runOfflineClient(runCtx, socket, terminal, ui, log, func(state client.State) { | |
| 195 | - | select { | |
| 196 | - | case states <- state: | |
| 197 | - | default: | |
| 198 | - | } | |
| 199 | - | }, nil) | |
| 200 | - | }() | |
| 201 | - | ||
| 202 | - | select { | |
| 203 | - | case <-states: | |
| 204 | - | case err := <-runnerDone: | |
| 205 | - | return err | |
| 206 | - | case <-runCtx.Done(): | |
| 207 | - | return runCtx.Err() | |
| 208 | - | } | |
| 209 | - | // Ready advertises the currently actionable UI generation. Before the first | |
| 210 | - | // attachment there is no actionable generation; attached snapshots publish | |
| 211 | - | // the non-zero generation clients must fence actions against. | |
| 212 | - | ready := uidriver.Ready{Attachment: ui.Handle(), Control: true, Status: ports.UIStatusReconnecting} | |
| 213 | - | server := uidriver.New(ui, clock.New()) | |
| 214 | - | serveDone := make(chan error, 1) | |
| 215 | - | go func() { serveDone <- server.Serve(runCtx, stream, ready) }() | |
| 216 | - | select { | |
| 217 | - | case err := <-serveDone: | |
| 218 | - | cancel() | |
| 219 | - | return errors.Join(err, ignoreContextCancellation(<-runnerDone)) | |
| 220 | - | case err := <-runnerDone: | |
| 221 | - | cancel() | |
| 222 | - | return errors.Join(err, ignoreContextCancellation(<-serveDone)) | |
| 160 | + | // runOfflineUIDriverClient composes the sandbox UI-driver harness over the same | |
| 161 | + | // runUIDriverClient path production uses. Its connector is the real broker IPC | |
| 162 | + | // connector of one explicitly supplied offline root: the sandbox adds no | |
| 163 | + | // production fallback, starts no broker of its own, and never reaches the | |
| 164 | + | // production XDG runtime or state. No session, endpoint, or process is | |
| 165 | + | // provisioned here: the harness reaches sessions only through the broker of its | |
| 166 | + | // own root, exactly like the production driver. | |
| 167 | + | func runOfflineUIDriverClient(ctx context.Context, harness offlineUIDriverHarness) error { | |
| 168 | + | if harness.log != nil { | |
| 169 | + | harness.log.Debug("broker_offline_ui_driver", "socket", harness.socket) | |
| 223 | 170 | } | |
| 171 | + | return runUIDriverClient(ctx, brokerClientConfig{ | |
| 172 | + | Connector: brokeripc.NewConnector(harness.socket, brokeripc.Config{}), | |
| 173 | + | Terminal: harness.terminal, | |
| 174 | + | Clock: clock.New(), | |
| 175 | + | UI: harness.ui, | |
| 176 | + | InitialNavigation: harness.navigation, | |
| 177 | + | ResolveInitialNavigation: harness.resolver, | |
| 178 | + | AttachmentEnvironment: terminalAttachmentEnvironment(), | |
| 179 | + | SessionEnvironment: client.SessionEnvironment{Provenance: client.SessionEnvironmentLocalCLI}, | |
| 180 | + | }, harness.stream) | |
| 224 | 181 | } | |
M internal/app/broker_offline_client_render_test.go +68 -3
| @@ -152,7 +152,7 @@ func TestOfflineClientRenderRefusesNonPickerPresentations(t *testing.T) { | |||
| 152 | 152 | picker := offlineRenderPicker(t) | |
| 153 | 153 | ||
| 154 | 154 | observed := make(chan client.State, 8) | |
| 155 | - | render := offlineClientRender(terminal, picker, func(state client.State) { | |
| 155 | + | render := brokerClientRender(terminal, picker, func(state client.State) { | |
| 156 | 156 | select { | |
| 157 | 157 | case observed <- state: | |
| 158 | 158 | default: | |
| @@ -192,7 +192,7 @@ func TestOfflineClientRenderFenceIsPickerPresentation(t *testing.T) { | |||
| 192 | 192 | for _, state := range states { | |
| 193 | 193 | t.Run(state.Presentation.String(), func(t *testing.T) { | |
| 194 | 194 | terminal := newOfflineRenderTerminal(small) | |
| 195 | - | render := offlineClientRender(terminal, offlineRenderPicker(t), nil) | |
| 195 | + | render := brokerClientRender(terminal, offlineRenderPicker(t), nil) | |
| 196 | 196 | ||
| 197 | 197 | render(state) | |
| 198 | 198 | ||
| @@ -218,7 +218,7 @@ func TestOfflineClientRenderPaintsPickerResize(t *testing.T) { | |||
| 218 | 218 | ||
| 219 | 219 | terminal := newOfflineRenderTerminal(small) | |
| 220 | 220 | picker := offlineRenderPicker(t) | |
| 221 | - | render := offlineClientRender(terminal, picker, nil) | |
| 221 | + | render := brokerClientRender(terminal, picker, nil) | |
| 222 | 222 | ||
| 223 | 223 | render(client.State{Presentation: client.PresentPicker, Connectivity: client.ConnectivityReady, Generation: 1}) | |
| 224 | 224 | initial, flushes := terminal.written() | |
| @@ -234,3 +234,68 @@ func TestOfflineClientRenderPaintsPickerResize(t *testing.T) { | |||
| 234 | 234 | require.Contains(t, strings.TrimPrefix(resized, initial), "offline-render-session", "the appended repaint is the picker frame at the new size") | |
| 235 | 235 | require.Equal(t, 2, flushes) | |
| 236 | 236 | } | |
| 237 | + | ||
| 238 | + | // transactionRecordingTerminal is a composition terminal that also records the | |
| 239 | + | // UI output transactions the picker paint commits, so a test can prove the | |
| 240 | + | // picker frame and its published context are one unattached state. | |
| 241 | + | type transactionRecordingTerminal struct { | |
| 242 | + | *offlineRenderTerminal | |
| 243 | + | mu sync.Mutex | |
| 244 | + | contexts []ports.UIContext | |
| 245 | + | beginDepth int | |
| 246 | + | endSuccesses []bool | |
| 247 | + | } | |
| 248 | + | ||
| 249 | + | func newTransactionRecordingTerminal(geometry domain.Geometry) *transactionRecordingTerminal { | |
| 250 | + | return &transactionRecordingTerminal{offlineRenderTerminal: newOfflineRenderTerminal(geometry)} | |
| 251 | + | } | |
| 252 | + | ||
| 253 | + | func (t *transactionRecordingTerminal) BeginOutput(context ports.UIContext) { | |
| 254 | + | t.mu.Lock() | |
| 255 | + | defer t.mu.Unlock() | |
| 256 | + | t.contexts = append(t.contexts, context) | |
| 257 | + | t.beginDepth++ | |
| 258 | + | } | |
| 259 | + | ||
| 260 | + | func (t *transactionRecordingTerminal) EndOutput(success bool) { | |
| 261 | + | t.mu.Lock() | |
| 262 | + | defer t.mu.Unlock() | |
| 263 | + | t.beginDepth-- | |
| 264 | + | t.endSuccesses = append(t.endSuccesses, success) | |
| 265 | + | } | |
| 266 | + | ||
| 267 | + | func (t *transactionRecordingTerminal) PublishContext(context ports.UIContext) error { | |
| 268 | + | t.mu.Lock() | |
| 269 | + | defer t.mu.Unlock() | |
| 270 | + | t.contexts = append(t.contexts, context) | |
| 271 | + | return nil | |
| 272 | + | } | |
| 273 | + | ||
| 274 | + | func (t *transactionRecordingTerminal) published() []ports.UIContext { | |
| 275 | + | t.mu.Lock() | |
| 276 | + | defer t.mu.Unlock() | |
| 277 | + | return append([]ports.UIContext(nil), t.contexts...) | |
| 278 | + | } | |
| 279 | + | ||
| 280 | + | // TestOfflineClientPickerPaintCommitsPickerTransaction pins that the picker frame | |
| 281 | + | // is committed through the terminal's UI output transaction under the Picker | |
| 282 | + | // presentation: the published context carries the run's handle and status with | |
| 283 | + | // no session identity and no actionable generation, and the transaction | |
| 284 | + | // succeeds only after the frame was written and flushed. | |
| 285 | + | func TestOfflineClientPickerPaintCommitsPickerTransaction(t *testing.T) { | |
| 286 | + | terminal := newTransactionRecordingTerminal(domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}) | |
| 287 | + | render := brokerClientRender(terminal, offlineRenderPicker(t), nil) | |
| 288 | + | ||
| 289 | + | render(client.State{Presentation: client.PresentPicker, Connectivity: client.ConnectivityReady, Generation: 1}) | |
| 290 | + | written, _ := terminal.written() | |
| 291 | + | require.NotEmpty(t, written, "the picker paint writes the frame") | |
| 292 | + | published := terminal.published() | |
| 293 | + | require.NotEmpty(t, published, "the picker paint commits a context through the UI transaction") | |
| 294 | + | for _, context := range published { | |
| 295 | + | require.Equal(t, ports.UIStatusPicker, context.Status) | |
| 296 | + | require.Zero(t, context.Generation, "a Picker context never carries an actionable generation") | |
| 297 | + | require.Equal(t, ports.UIContext{Status: ports.UIStatusPicker}, context, "a Picker context carries no session metadata") | |
| 298 | + | } | |
| 299 | + | require.Equal(t, 0, terminal.beginDepth, "the transaction is balanced") | |
| 300 | + | require.Equal(t, []bool{true}, terminal.endSuccesses, "the frame committed after write and flush") | |
| 301 | + | } | |
M internal/app/broker_offline_client_test.go +160 -73
| @@ -4,7 +4,8 @@ package app | |||
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "context" | |
| 7 | - | "encoding/json" | |
| 7 | + | "crypto/rand" | |
| 8 | + | "errors" | |
| 8 | 9 | "io" | |
| 9 | 10 | "net/http" | |
| 10 | 11 | "net/http/httptest" | |
| @@ -24,13 +25,15 @@ import ( | |||
| 24 | 25 | "github.com/bnema/vev/internal/adapters/daemonmux" | |
| 25 | 26 | "github.com/bnema/vev/internal/adapters/ipc" | |
| 26 | 27 | "github.com/bnema/vev/internal/adapters/pty" | |
| 28 | + | "github.com/bnema/vev/internal/adapters/snapshot" | |
| 27 | 29 | "github.com/bnema/vev/internal/adapters/uiterm" | |
| 28 | 30 | "github.com/bnema/vev/internal/adapters/webterm" | |
| 29 | 31 | "github.com/bnema/vev/internal/domain" | |
| 32 | + | "github.com/bnema/vev/internal/persist" | |
| 30 | 33 | "github.com/bnema/vev/internal/ports" | |
| 31 | 34 | "github.com/bnema/vev/internal/protocol" | |
| 32 | - | "github.com/bnema/vev/internal/usecase/client" | |
| 33 | 35 | "github.com/bnema/vev/internal/usecase/daemon" | |
| 36 | + | "github.com/bnema/vev/internal/usecase/recovery" | |
| 34 | 37 | "github.com/bnema/vev/pkg/safedir" | |
| 35 | 38 | ) | |
| 36 | 39 | ||
| @@ -45,9 +48,34 @@ type offlineClientFixture struct { | |||
| 45 | 48 | cancel context.CancelFunc | |
| 46 | 49 | prodRuntime string | |
| 47 | 50 | prodState string | |
| 51 | + | // catalogue is the daemon's durable session catalogue when the fixture was | |
| 52 | + | // started with persistence; nil otherwise. | |
| 53 | + | catalogue *persist.Persister | |
| 48 | 54 | } | |
| 49 | 55 | ||
| 50 | 56 | func startOfflineClientFixture(t *testing.T) offlineClientFixture { | |
| 57 | + | t.Helper() | |
| 58 | + | // The default sandbox shell prints one marker and then parks on read, so a | |
| 59 | + | // client proves it reached a session without depending on shell echo of | |
| 60 | + | // injected input. | |
| 61 | + | return startOfflineClientFixtureWithShell(t, "/bin/sh", []string{"-c", "sleep 0.1; printf offline-ready; trap : TERM; while :; do read line || exit; done"}) | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | // startOfflineClientFixtureWithShell starts the same private broker sandbox with | |
| 65 | + | // an explicitly supplied daemon shell, so a browser test can drive real session | |
| 66 | + | // content and real session actions (echo and command output) through the | |
| 67 | + | // ordinary client path instead of a parked shell. | |
| 68 | + | func startOfflineClientFixtureWithShell(t *testing.T, shell string, shellArgs []string) offlineClientFixture { | |
| 69 | + | t.Helper() | |
| 70 | + | return startPersistentOfflineClientFixture(t, shell, shellArgs, false) | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | // startPersistentOfflineClientFixture starts the same private broker sandbox but | |
| 74 | + | // additionally binds the daemon to real session persistence under the sandbox | |
| 75 | + | // state directory. It exists so a test can prove which lifecycle state the | |
| 76 | + | // daemon actually persists: a named session appears in the durable catalogue, | |
| 77 | + | // while an ephemeral one is never written. | |
| 78 | + | func startPersistentOfflineClientFixture(t *testing.T, shell string, shellArgs []string, persistEnabled bool) offlineClientFixture { | |
| 51 | 79 | t.Helper() | |
| 52 | 80 | root, prodRuntime, prodState := isolateSandboxEnv(t) | |
| 53 | 81 | require.NoError(t, os.MkdirAll(root, 0o700)) | |
| @@ -96,7 +124,22 @@ func startOfflineClientFixture(t *testing.T) offlineClientFixture { | |||
| 96 | 124 | }() | |
| 97 | 125 | streamSeen := make(chan struct{}, 16) | |
| 98 | 126 | counting := &countingServerListener{ServerListener: aggregate, seen: streamSeen} | |
| 99 | - | d := daemon.New(pty.NewFactory(), clock.New(), discardLog(), daemon.WithShell("/bin/sh", []string{"-c", "sleep 0.1; printf offline-ready; trap : TERM; while :; do read line || exit; done"})) | |
| 127 | + | daemonOpts := []daemon.Option{daemon.WithShell(shell, shellArgs)} | |
| 128 | + | var fixtureCatalogue *persist.Persister | |
| 129 | + | if persistEnabled { | |
| 130 | + | stateDir := filepath.Join(root, "state") | |
| 131 | + | opened, err := persist.OpenOrCreate(stateDir) | |
| 132 | + | require.NoError(t, err) | |
| 133 | + | repository := snapshot.NewRepository(filepath.Join(stateDir, "snapshots")) | |
| 134 | + | coordinator := recovery.NewCoordinator(opened.Catalogue, repository, rand.Reader) | |
| 135 | + | daemonOpts = append(daemonOpts, | |
| 136 | + | daemon.WithCatalogue(opened.Catalogue, opened.Records), | |
| 137 | + | daemon.WithSnapshotRepository(repository), | |
| 138 | + | daemon.WithRecoveryCoordinator(coordinator), | |
| 139 | + | ) | |
| 140 | + | fixtureCatalogue = opened.Catalogue | |
| 141 | + | } | |
| 142 | + | d := daemon.New(pty.NewFactory(), clock.New(), discardLog(), daemonOpts...) | |
| 100 | 143 | go func() { _ = d.Serve(ctx, counting) }() | |
| 101 | 144 | ||
| 102 | 145 | // The client picker and registry must share wall-clock freshness semantics: | |
| @@ -112,6 +155,28 @@ func startOfflineClientFixture(t *testing.T) offlineClientFixture { | |||
| 112 | 155 | case <-time.After(brokerTestWait): | |
| 113 | 156 | t.Fatal("offline broker did not become ready") | |
| 114 | 157 | } | |
| 158 | + | // The sandbox broker becomes socket-ready before its first local daemon | |
| 159 | + | // observation is committed. Product clients start from a committed local | |
| 160 | + | // authority; wait for the fixture to provide the same contract so one-shot | |
| 161 | + | // initial navigation is not consumed against an artificial empty catalogue. | |
| 162 | + | probe, err := brokeripc.NewConnector(socket, brokeripc.Config{}).Connect(ctx) | |
| 163 | + | require.NoError(t, err) | |
| 164 | + | subscription, err := probe.Subscribe() | |
| 165 | + | require.NoError(t, err) | |
| 166 | + | require.Eventually(t, func() bool { | |
| 167 | + | for _, observation := range probe.Snapshot().Daemons { | |
| 168 | + | if observation.Local { | |
| 169 | + | return true | |
| 170 | + | } | |
| 171 | + | } | |
| 172 | + | select { | |
| 173 | + | case <-subscription.Changed(): | |
| 174 | + | default: | |
| 175 | + | } | |
| 176 | + | return false | |
| 177 | + | }, brokerTestWait, 5*time.Millisecond, "offline broker never committed its local daemon authority") | |
| 178 | + | subscription.Close() | |
| 179 | + | require.NoError(t, probe.Close()) | |
| 115 | 180 | t.Cleanup(func() { | |
| 116 | 181 | cancel() | |
| 117 | 182 | _ = rawListener.Close() | |
| @@ -137,7 +202,12 @@ func startOfflineClientFixture(t *testing.T) offlineClientFixture { | |||
| 137 | 202 | defer physicalMu.Unlock() | |
| 138 | 203 | return len(physicalConnections) | |
| 139 | 204 | } | |
| 140 | - | return offlineClientFixture{root: root, socket: socket, streams: streamSeen, physical: physicalSeen, physicalCount: physicalCount, policy: policy, losePhysical: losePhysical, cancel: cancel, prodRuntime: prodRuntime, prodState: prodState} | |
| 205 | + | return offlineClientFixture{ | |
| 206 | + | root: root, socket: socket, streams: streamSeen, physical: physicalSeen, | |
| 207 | + | physicalCount: physicalCount, policy: policy, losePhysical: losePhysical, | |
| 208 | + | cancel: cancel, prodRuntime: prodRuntime, prodState: prodState, | |
| 209 | + | catalogue: fixtureCatalogue, | |
| 210 | + | } | |
| 141 | 211 | } | |
| 142 | 212 | ||
| 143 | 213 | type countingServerListener struct { | |
| @@ -234,85 +304,84 @@ func mustConnectivityOwner(t *testing.T, kind string) connectivityOwner { | |||
| 234 | 304 | return owner | |
| 235 | 305 | } | |
| 236 | 306 | ||
| 237 | - | func TestOfflineClientTerminalReachesSessionThroughBrokerIPC(t *testing.T) { | |
| 238 | - | fixture := startOfflineClientFixture(t) | |
| 239 | - | ctx, cancel := context.WithCancel(t.Context()) | |
| 240 | - | terminal, err := uiterm.New(ctx, domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}, "") | |
| 307 | + | // TestOfflineEphemeralSessionIsNotPersistedViaBroker proves the daemon-owned | |
| 308 | + | // broker fixture persists a named session but never an ephemeral one: the | |
| 309 | + | // durable catalogue holds exactly the named record after both lifecycles, so | |
| 310 | + | // the ephemeral session survives only while its daemon does. | |
| 311 | + | func TestOfflineEphemeralSessionIsNotPersistedViaBroker(t *testing.T) { | |
| 312 | + | fixture := startPersistentOfflineClientFixture(t, "/bin/sh", | |
| 313 | + | []string{"-c", "sleep 0.1; printf ephemeral-ready; trap : TERM; while :; do read line || exit; done"}, true) | |
| 314 | + | require.NotNil(t, fixture.catalogue, "the persistent fixture must expose its durable catalogue") | |
| 315 | + | ctx, cancel := context.WithTimeout(context.Background(), brokerTestWait) | |
| 316 | + | defer cancel() | |
| 317 | + | ||
| 318 | + | service, err := brokeripc.NewConnector(fixture.socket, brokeripc.Config{}).Connect(ctx) | |
| 241 | 319 | require.NoError(t, err) | |
| 242 | - | done := make(chan error, 1) | |
| 243 | - | failures := make(chan error, 1) | |
| 244 | - | go func() { | |
| 245 | - | done <- runOfflineClient(ctx, fixture.socket, terminal, nil, discardLog(), nil, func(err error) { | |
| 246 | - | select { | |
| 247 | - | case failures <- err: | |
| 248 | - | default: | |
| 249 | - | } | |
| 250 | - | }) | |
| 251 | - | }() | |
| 252 | - | select { | |
| 253 | - | case <-fixture.streams: | |
| 254 | - | case err := <-failures: | |
| 255 | - | t.Fatalf("offline client stream open failed: %v (%#v)", err, err) | |
| 256 | - | case <-time.After(brokerTestWait): | |
| 257 | - | t.Fatal("client did not reach a session through a broker logical stream") | |
| 320 | + | defer service.Close() | |
| 321 | + | ||
| 322 | + | create := func(name string, admission ports.BrokerStreamAdmission, intent uint8) { | |
| 323 | + | t.Helper() | |
| 324 | + | streamID, err := service.NextStreamID() | |
| 325 | + | require.NoError(t, err) | |
| 326 | + | request := ports.BrokerOpenStreamRequest{ | |
| 327 | + | Purpose: ports.BrokerStreamAttachment, Stream: streamID, Local: true, | |
| 328 | + | Policy: fixture.policy, Admission: admission, Name: name, | |
| 329 | + | StartMode: ports.BrokerDaemonStartIfNeeded, | |
| 330 | + | } | |
| 331 | + | stream, err := service.OpenStream(ctx, request) | |
| 332 | + | require.NoError(t, err) | |
| 333 | + | require.NoError(t, stream.SendClient(protocol.Hello{ | |
| 334 | + | Version: protocol.Version, Intent: intent, Name: name, | |
| 335 | + | Size: domain.Size{Cols: 80, Rows: 24}, EnvironmentPolicy: protocol.EnvironmentPolicyClientOwned, | |
| 336 | + | })) | |
| 337 | + | receiveOfflineWelcome(t, stream) | |
| 338 | + | require.NoError(t, stream.Close()) | |
| 258 | 339 | } | |
| 259 | - | awaitUITerminalMarker(t, terminal, "offline-ready") | |
| 260 | - | cancel() | |
| 261 | - | select { | |
| 262 | - | case err := <-done: | |
| 263 | - | require.NoError(t, ignoreContextCancellation(err)) | |
| 264 | - | case <-time.After(brokerTestWait): | |
| 265 | - | t.Fatal("offline terminal client did not stop") | |
| 340 | + | ||
| 341 | + | create("persisted", ports.BrokerAdmissionCreateNamed, protocol.IntentNew) | |
| 342 | + | create("", ports.BrokerAdmissionCreateEphemeral, protocol.IntentEphemeral) | |
| 343 | + | ||
| 344 | + | // The daemon persists on its own cadence; the durable catalogue must settle | |
| 345 | + | // to exactly the named record and never gain the ephemeral one. | |
| 346 | + | require.Eventually(t, func() bool { | |
| 347 | + | records, err := fixture.catalogue.Records() | |
| 348 | + | require.NoError(t, err) | |
| 349 | + | return len(records) == 1 && records[0].Name == "persisted" | |
| 350 | + | }, brokerTestWait, brokerTestPollTick, "the durable catalogue must hold exactly the named session") | |
| 351 | + | ||
| 352 | + | records, err := fixture.catalogue.Records() | |
| 353 | + | require.NoError(t, err) | |
| 354 | + | for _, record := range records { | |
| 355 | + | require.NotEmpty(t, record.Name, "an ephemeral session must never be persisted") | |
| 266 | 356 | } | |
| 267 | 357 | } | |
| 268 | 358 | ||
| 269 | - | func TestOfflineClientUIDriverReachesSessionThroughBrokerIPC(t *testing.T) { | |
| 359 | + | func TestOfflineClientTerminalReachesSessionThroughBrokerIPC(t *testing.T) { | |
| 270 | 360 | fixture := startOfflineClientFixture(t) | |
| 271 | 361 | ctx, cancel := context.WithCancel(t.Context()) | |
| 272 | - | defer cancel() | |
| 273 | 362 | terminal, err := uiterm.New(ctx, domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}, "") | |
| 274 | 363 | require.NoError(t, err) | |
| 275 | - | ui := client.NewUI(terminal, clock.New()) | |
| 276 | - | serverEnd, clientEnd := io.Pipe() | |
| 277 | - | writerReader, writerEnd := io.Pipe() | |
| 278 | - | stream := &stdioStream{reader: serverEnd, writer: writerEnd} | |
| 279 | 364 | done := make(chan error, 1) | |
| 280 | - | go func() { done <- runOfflineUIDriver(ctx, fixture.socket, terminal, ui, discardLog(), stream) }() | |
| 281 | - | awaitLogicalStream(t, fixture.streams) | |
| 282 | - | decoder := json.NewDecoder(writerReader) | |
| 283 | - | readyResult := make(chan map[string]any, 1) | |
| 284 | - | readyError := make(chan error, 1) | |
| 285 | 365 | go func() { | |
| 286 | - | var ready map[string]any | |
| 287 | - | if err := decoder.Decode(&ready); err != nil { | |
| 288 | - | readyError <- err | |
| 289 | - | return | |
| 290 | - | } | |
| 291 | - | readyResult <- ready | |
| 366 | + | done <- runOfflineClient(ctx, fixture.socket, terminal, nil, discardLog(), nil, nil) | |
| 292 | 367 | }() | |
| 293 | - | select { | |
| 294 | - | case ready := <-readyResult: | |
| 295 | - | result, ok := ready["result"].(map[string]any) | |
| 296 | - | require.True(t, ok) | |
| 297 | - | require.Equal(t, true, result["control"], "autonomous supervisor binds UI actions") | |
| 298 | - | require.Equal(t, string(ports.UIStatusReconnecting), result["status"], "ready precedes the attachment publication") | |
| 299 | - | require.Equal(t, float64(0), result["generation"], "ready has no actionable generation before attachment publication") | |
| 300 | - | case err := <-readyError: | |
| 301 | - | t.Fatalf("decode UI-driver ready: %v", err) | |
| 302 | - | case <-time.After(brokerTestWait): | |
| 303 | - | t.Fatal("offline UI driver did not publish ready") | |
| 304 | - | } | |
| 368 | + | awaitLogicalStream(t, fixture.streams) | |
| 305 | 369 | awaitUITerminalMarker(t, terminal, "offline-ready") | |
| 306 | - | _ = clientEnd.Close() | |
| 307 | - | _ = writerReader.Close() | |
| 308 | 370 | cancel() | |
| 309 | 371 | select { | |
| 310 | - | case <-done: | |
| 372 | + | case err := <-done: | |
| 373 | + | require.NoError(t, ignoreContextCancellation(err)) | |
| 311 | 374 | case <-time.After(brokerTestWait): | |
| 312 | - | t.Fatal("offline UI driver did not stop") | |
| 375 | + | t.Fatal("offline terminal client did not stop") | |
| 313 | 376 | } | |
| 314 | 377 | } | |
| 315 | 378 | ||
| 379 | + | // The sandbox UI-driver harness is exercised by | |
| 380 | + | // TestUIDriverSandboxHarnessUsesItsOwnConnector, TestUIDriverSandboxHarnessPickerOpensNothing, | |
| 381 | + | // TestUIDriverClientKeepsSharedDaemonOnEOF, and the shared | |
| 382 | + | // runUIDriverClient tests in ui_driver_client_test.go; the harness is the same | |
| 383 | + | // runUIDriverClient call production uses, so it is not re-tested here. | |
| 384 | + | ||
| 316 | 385 | func TestOfflineBrokerSharedTransportKeepsAttachmentsIndependent(t *testing.T) { | |
| 317 | 386 | fixture := startOfflineClientFixture(t) | |
| 318 | 387 | ctx, cancel := context.WithTimeout(context.Background(), brokerTestWait) | |
| @@ -324,7 +393,9 @@ func TestOfflineBrokerSharedTransportKeepsAttachmentsIndependent(t *testing.T) { | |||
| 324 | 393 | connections := make([]ports.BrokerLogicalConnection, 3) | |
| 325 | 394 | welcomes := make([]protocol.Welcome, 3) | |
| 326 | 395 | for i := range connections { | |
| 327 | - | request := ports.BrokerOpenStreamRequest{Purpose: ports.BrokerStreamAttachment, Local: true, Policy: fixture.policy} | |
| 396 | + | streamID, err := service.NextStreamID() | |
| 397 | + | require.NoError(t, err) | |
| 398 | + | request := ports.BrokerOpenStreamRequest{Purpose: ports.BrokerStreamAttachment, Stream: streamID, Local: true, Policy: fixture.policy, StartMode: ports.BrokerDaemonStartIfNeeded} | |
| 328 | 399 | if i == 0 { | |
| 329 | 400 | request.Admission, request.Name = ports.BrokerAdmissionCreateNamed, "shared" | |
| 330 | 401 | } else { | |
| @@ -387,8 +458,7 @@ func TestOfflineBrokerSharedTransportKeepsAttachmentsIndependent(t *testing.T) { | |||
| 387 | 458 | } | |
| 388 | 459 | ||
| 389 | 460 | require.NoError(t, connections[0].SendClient(protocol.Detach{})) | |
| 390 | - | receiveOfflineDetached(t, connections[0]) | |
| 391 | - | require.NoError(t, connections[0].Close()) | |
| 461 | + | require.NoError(t, receiveOfflineEOF(t, connections[0]), "detach must end its logical stream with orderly EOF") | |
| 392 | 462 | for i := 1; i < len(connections); i++ { | |
| 393 | 463 | requestID := uint64(20 + i) | |
| 394 | 464 | require.NoError(t, connections[i].SendClient(protocol.CommandRequest{Version: protocol.Version, RequestID: requestID, Attached: true, Slug: "next-tab"})) | |
| @@ -410,7 +480,9 @@ func TestOfflineBrokerPhysicalLossSettlesEverySharedStream(t *testing.T) { | |||
| 410 | 480 | var target protocol.ExactSessionTarget | |
| 411 | 481 | connections := make([]ports.BrokerLogicalConnection, 3) | |
| 412 | 482 | for i := range connections { | |
| 413 | - | request := ports.BrokerOpenStreamRequest{Purpose: ports.BrokerStreamAttachment, Local: true, Policy: fixture.policy} | |
| 483 | + | streamID, err := service.NextStreamID() | |
| 484 | + | require.NoError(t, err) | |
| 485 | + | request := ports.BrokerOpenStreamRequest{Purpose: ports.BrokerStreamAttachment, Stream: streamID, Local: true, Policy: fixture.policy, StartMode: ports.BrokerDaemonStartIfNeeded} | |
| 414 | 486 | if i == 0 { | |
| 415 | 487 | request.Admission, request.Name = ports.BrokerAdmissionCreateNamed, "loss" | |
| 416 | 488 | } else { | |
| @@ -469,12 +541,27 @@ func receiveOfflineCommandResult(t *testing.T, connection ports.BrokerLogicalCon | |||
| 469 | 541 | return message.(protocol.CommandResult) | |
| 470 | 542 | } | |
| 471 | 543 | ||
| 472 | - | func receiveOfflineDetached(t *testing.T, connection ports.BrokerLogicalConnection) { | |
| 544 | + | func receiveOfflineEOF(t *testing.T, connection ports.BrokerLogicalConnection) error { | |
| 473 | 545 | t.Helper() | |
| 474 | - | receiveOfflineMessage(t, connection, "Detached", func(message protocol.ServerMessage) bool { | |
| 475 | - | _, ok := message.(protocol.Detached) | |
| 476 | - | return ok | |
| 477 | - | }) | |
| 546 | + | result := make(chan error, 1) | |
| 547 | + | go func() { | |
| 548 | + | for { | |
| 549 | + | _, err := connection.ReceiveServer() | |
| 550 | + | if err != nil { | |
| 551 | + | result <- err | |
| 552 | + | return | |
| 553 | + | } | |
| 554 | + | } | |
| 555 | + | }() | |
| 556 | + | select { | |
| 557 | + | case err := <-result: | |
| 558 | + | if errors.Is(err, io.EOF) { | |
| 559 | + | return nil | |
| 560 | + | } | |
| 561 | + | return err | |
| 562 | + | case <-time.After(brokerTestWait): | |
| 563 | + | return errors.New("timed out waiting for orderly EOF") | |
| 564 | + | } | |
| 478 | 565 | } | |
| 479 | 566 | ||
| 480 | 567 | func receiveOfflineMessage(t *testing.T, connection ports.BrokerLogicalConnection, want string, match func(protocol.ServerMessage) bool) protocol.ServerMessage { | |
M internal/app/broker_offline_local.go +155 -72
| @@ -4,7 +4,6 @@ import ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | "errors" | |
| 6 | 6 | "fmt" | |
| 7 | - | "log/slog" | |
| 8 | 7 | ||
| 9 | 8 | "github.com/bnema/vev/internal/adapters/brokerconfig" | |
| 10 | 9 | "github.com/bnema/vev/internal/adapters/daemonmux" | |
| @@ -13,140 +12,224 @@ import ( | |||
| 13 | 12 | "github.com/bnema/vev/internal/usecase/broker" | |
| 14 | 13 | ) | |
| 15 | 14 | ||
| 16 | - | // Broker-owned local observation composition (Plan 001 P5.3a). | |
| 15 | + | // Broker-owned local observation composition (Plan 001 P5.3a, P7.4b). | |
| 17 | 16 | // | |
| 18 | 17 | // The offline broker observes its own machine daemon through the same daemonmux | |
| 19 | - | // carriage a local logical stream is dialed over. The probe completes only the | |
| 20 | - | // physical preamble: it dials the provisioned local Unix route, negotiates | |
| 21 | - | // identity, incarnation, and policy, and closes the carriage. It deliberately | |
| 22 | - | // opens no logical stream, so observing the local daemon never attaches to a | |
| 23 | - | // session, and it only ever dials an existing local Unix socket, so it never | |
| 24 | - | // starts a stopped daemon. The composition is isolated from production state: | |
| 25 | - | // it derives the probe from the sandbox configuration and never touches the | |
| 26 | - | // production runtime or state directories. | |
| 18 | + | // carriage a local logical stream is dialed over. The probe completes the | |
| 19 | + | // authenticated physical preamble (identity, incarnation, and policy), opens | |
| 20 | + | // exactly one logical BrokerStreamObservation stream, sends one typed | |
| 21 | + | // remote-catalog control request, validates the catalogue the daemon answers, | |
| 22 | + | // and closes the stream and carriage. It never sends a Hello, never claims a | |
| 23 | + | // geometry, never requests an attachment admission, and never creates a | |
| 24 | + | // session or a process, and it only ever dials an existing local Unix socket, so | |
| 25 | + | // observing the local daemon never attaches to a session and never starts a | |
| 26 | + | // stopped daemon. | |
| 27 | + | // | |
| 28 | + | // The composition is isolated from production state: it derives the probe from | |
| 29 | + | // the sandbox configuration and never touches the production runtime or state | |
| 30 | + | // directories. Remote and local observation share one semantic implementation | |
| 31 | + | // (observeDaemonCatalogue) through the same daemonmux endpoint connector seam | |
| 32 | + | // the broker already owns, so neither producer becomes a second transport | |
| 33 | + | // owner and the two cannot drift apart. | |
| 27 | 34 | // | |
| 28 | 35 | // The hidden offline sandbox selects this producer whenever its strict config | |
| 29 | 36 | // provisions a local binding. Ordinary production composition does not select | |
| 30 | 37 | // it. The sandbox owns no remote membership, and probing remains no-spawn and | |
| 31 | 38 | // attachment-free even though it runs on the registry's observation cadence. | |
| 32 | 39 | ||
| 33 | - | // localCarrierDialer establishes one raw framed daemonmux carriage for the | |
| 34 | - | // opaque route address the broker-owned local binding published. It is the same | |
| 35 | - | // seam the pooled mux connector uses, so the probe can never select a transport | |
| 36 | - | // or an address of its own. | |
| 37 | - | type localCarrierDialer func(ctx context.Context, address string) (daemonmux.RawFramedTransport, error) | |
| 40 | + | // localCarrierDialer establishes one raw framed daemonmux carriage for one | |
| 41 | + | // resolved dial target. It is the same seam the pooled mux connector uses, so | |
| 42 | + | // the probe can never select a transport or an address of its own, and the | |
| 43 | + | // target travels whole: the address selects the route while the start | |
| 44 | + | // authorization, policy, and fence decide what the carriage owner may do. | |
| 45 | + | type localCarrierDialer func(ctx context.Context, target ports.BrokerDialTarget) (daemonmux.RawFramedTransport, error) | |
| 38 | 46 | ||
| 39 | 47 | // localRouteProbe is the broker-owned ports-independent local observation | |
| 40 | 48 | // probe. It holds the resolved local endpoint authority (identity, policy, and | |
| 41 | - | // opaque address) and dials it exactly once per observation, then closes the | |
| 42 | - | // carriage: no logical stream is ever opened and no daemon process is ever | |
| 43 | - | // started. | |
| 49 | + | // opaque address) and one daemonmux endpoint connector over the same dial and | |
| 50 | + | // ceiling seam the pooled connector uses, and observes the daemon through it. | |
| 51 | + | // Every probe dials one already authenticated local carriage, opens exactly one | |
| 52 | + | // observation stream, and closes both; no logical attachment stream is ever | |
| 53 | + | // opened and no daemon process is ever started. | |
| 54 | + | type localIdentityLoader func() (ports.BrokerDaemonIdentity, error) | |
| 55 | + | ||
| 44 | 56 | type localRouteProbe struct { | |
| 45 | - | endpoint ports.BrokerResolvedEndpoint | |
| 46 | - | dial localCarrierDialer | |
| 47 | - | ceilings daemonmux.MuxCeilings | |
| 57 | + | epoch ports.BrokerEpoch | |
| 58 | + | route brokerconfig.Route | |
| 59 | + | policy ports.BrokerPolicy | |
| 60 | + | loadIdentity localIdentityLoader | |
| 61 | + | connector ports.BrokerEndpointConnector | |
| 48 | 62 | } | |
| 49 | 63 | ||
| 50 | 64 | var _ broker.LocalProbe = (*localRouteProbe)(nil) | |
| 51 | 65 | ||
| 52 | - | // newLocalRouteProbe fences one broker-owned local binding into a probe. The | |
| 53 | - | // binding's identity, policy, and route address must validate, the ceilings | |
| 54 | - | // must be a valid advertisement, and a nil dial is refused: a probe that cannot | |
| 55 | - | // establish a carriage must not exist. | |
| 56 | - | func newLocalRouteProbe(binding brokerconfig.LocalBinding, dial localCarrierDialer, ceilings daemonmux.MuxCeilings) (*localRouteProbe, error) { | |
| 66 | + | // newLocalRouteProbe fences one broker-owned local binding into a probe over a | |
| 67 | + | // dial seam. The binding's identity, policy, and route address must validate, | |
| 68 | + | // the ceilings must be a valid advertisement, the epoch must be the broker | |
| 69 | + | // process incarnation every local stream is scoped to, and a nil dial is | |
| 70 | + | // refused: a probe that cannot establish a carriage must not exist. It builds | |
| 71 | + | // its own endpoint connector; a composition that already owns the pooled | |
| 72 | + | // connector passes it to newLocalRouteProbeWithConnector instead, so one broker | |
| 73 | + | // process never holds two connector owners for the same route. | |
| 74 | + | func newLocalRouteProbe(binding brokerconfig.LocalBinding, epoch ports.BrokerEpoch, dial localCarrierDialer, ceilings daemonmux.MuxCeilings) (*localRouteProbe, error) { | |
| 57 | 75 | if dial == nil { | |
| 58 | 76 | return nil, errors.New("vev: broker local probe requires a dialer") | |
| 59 | 77 | } | |
| 78 | + | connector, err := daemonmux.NewEndpointConnector(daemonmux.RawCarrierDialer(dial), ceilings) | |
| 79 | + | if err != nil { | |
| 80 | + | return nil, fmt.Errorf("vev: broker local probe: %w", err) | |
| 81 | + | } | |
| 82 | + | return newLocalRouteProbeWithConnector(binding, epoch, connector) | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | // newLocalRouteProbeWithConnector fences one broker-owned local binding into a | |
| 86 | + | // probe over an existing connector. The connector is the same setup half a | |
| 87 | + | // pooled physical connection uses, so the probe composes exactly the transport | |
| 88 | + | // owner the broker already has and never becomes a second one. The binding's | |
| 89 | + | // identity, policy, and route must validate and the epoch must be nonzero. | |
| 90 | + | func newLocalRouteProbeWithConnector(binding brokerconfig.LocalBinding, epoch ports.BrokerEpoch, connector ports.BrokerEndpointConnector) (*localRouteProbe, error) { | |
| 91 | + | return newDynamicLocalRouteProbe(binding.Route, binding.Policy, epoch, connector, func() (ports.BrokerDaemonIdentity, error) { | |
| 92 | + | return binding.Identity, nil | |
| 93 | + | }) | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | func newDynamicLocalRouteProbe(route brokerconfig.Route, policy ports.BrokerPolicy, epoch ports.BrokerEpoch, connector ports.BrokerEndpointConnector, loadIdentity localIdentityLoader) (*localRouteProbe, error) { | |
| 97 | + | if epoch == 0 { | |
| 98 | + | return nil, errors.New("vev: broker local probe requires a broker epoch") | |
| 99 | + | } | |
| 100 | + | if connector == nil { | |
| 101 | + | return nil, errors.New("vev: broker local probe requires a connector") | |
| 102 | + | } | |
| 103 | + | if loadIdentity == nil { | |
| 104 | + | return nil, errors.New("vev: broker local probe requires an identity loader") | |
| 105 | + | } | |
| 60 | 106 | // Probing must never spawn a process, so only a Unix route this process | |
| 61 | 107 | // dials directly is acceptable. brokerconfig refuses any other kind at load | |
| 62 | 108 | // time; this re-checks the exported binding a caller may have built itself, | |
| 63 | 109 | // because the dial seam routes an ssh address to a child process. | |
| 64 | - | if !binding.Route.IsLocal() { | |
| 110 | + | if !route.IsLocal() { | |
| 65 | 111 | return nil, errors.New("vev: broker local probe requires a local route") | |
| 66 | 112 | } | |
| 67 | - | if err := ceilings.Validate(); err != nil { | |
| 113 | + | if err := route.Validate(); err != nil { | |
| 68 | 114 | return nil, fmt.Errorf("vev: broker local probe: %w", err) | |
| 69 | 115 | } | |
| 70 | - | endpoint := ports.BrokerResolvedEndpoint{Identity: binding.Identity, Policy: binding.Policy, Address: binding.Route.Address()} | |
| 71 | - | if err := endpoint.Validate(); err != nil { | |
| 116 | + | if err := policy.Validate(); err != nil { | |
| 72 | 117 | return nil, fmt.Errorf("vev: broker local probe: %w", err) | |
| 73 | 118 | } | |
| 74 | - | return &localRouteProbe{endpoint: endpoint, dial: dial, ceilings: ceilings}, nil | |
| 119 | + | return &localRouteProbe{epoch: epoch, route: route, policy: policy, loadIdentity: loadIdentity, connector: connector}, nil | |
| 75 | 120 | } | |
| 76 | 121 | ||
| 77 | - | // ProbeLocal observes the local daemon by completing the daemonmux physical | |
| 78 | - | // preamble and closing the carriage. A successful preamble reports the only | |
| 79 | - | // observed state the carriage exposes: the authenticated identity, the daemon | |
| 80 | - | // incarnation, the protocol version the daemon's accepted policy negotiated, | |
| 81 | - | // and reachability. Capabilities and the session catalogue are not carried by | |
| 82 | - | // the physical preamble, so they stay unknown (zero) rather than being | |
| 83 | - | // invented, and every failure reports an explicit availability with zero | |
| 84 | - | // observed identity: a probe never manufactures identity, version, or | |
| 85 | - | // inventory it did not observe. | |
| 122 | + | // ProbeLocal observes the local daemon by authenticating the daemonmux physical | |
| 123 | + | // preamble and then opening exactly one observation stream that asks the daemon | |
| 124 | + | // for its own catalogue. A successful observation reports the authenticated | |
| 125 | + | // identity, the daemon incarnation, the protocol version the catalogue schema | |
| 126 | + | // negotiated, reachability, and the exact session inventory the daemon | |
| 127 | + | // answered. Capabilities are not carried by the catalogue command, so they stay | |
| 128 | + | // unknown (zero) rather than being invented, and every failure reports an | |
| 129 | + | // explicit availability with zero observed identity: a probe never manufactures | |
| 130 | + | // identity, version, or inventory it did not observe, and it never creates an | |
| 131 | + | // attachment, a session, a process, or durable identity. | |
| 132 | + | // | |
| 133 | + | // The local daemon owns no configured membership, so the probe commits no | |
| 134 | + | // durable identity binding: the binding's configured identity is the fence the | |
| 135 | + | // physical preamble already verified. | |
| 86 | 136 | func (p *localRouteProbe) ProbeLocal(ctx context.Context) (ports.BrokerDaemonObservation, error) { | |
| 87 | - | if p == nil { | |
| 137 | + | if p == nil || p.connector == nil { | |
| 88 | 138 | return ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityUnreachable}, errors.New("vev: broker local probe is nil") | |
| 89 | 139 | } | |
| 90 | - | raw, err := p.dial(ctx, p.endpoint.Address) | |
| 140 | + | identity, err := p.loadIdentity() | |
| 91 | 141 | if err != nil { | |
| 92 | - | if raw != nil { | |
| 93 | - | _ = raw.Close() | |
| 94 | - | } | |
| 95 | 142 | return ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityUnreachable}, err | |
| 96 | 143 | } | |
| 97 | - | if raw == nil { | |
| 98 | - | return ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityUnreachable}, errors.New("vev: broker local probe: nil carriage") | |
| 144 | + | if identity == "" { | |
| 145 | + | return ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityUnknown}, nil | |
| 99 | 146 | } | |
| 100 | - | bridge, err := daemonmux.NewPreambleCarrier(raw) | |
| 147 | + | endpoint := ports.BrokerDialTarget{ | |
| 148 | + | Fence: ports.BrokerEndpointFence{Local: true}, Policy: p.policy, Address: p.route.Address(), | |
| 149 | + | StartMode: ports.BrokerDaemonExistingOnly, | |
| 150 | + | ExpectedIdentity: ports.BrokerExpectedIdentity{Identity: identity, Bound: true}, | |
| 151 | + | } | |
| 152 | + | if err := endpoint.Validate(); err != nil { | |
| 153 | + | return ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityUnreachable}, fmt.Errorf("vev: broker local probe: %w", err) | |
| 154 | + | } | |
| 155 | + | physical, err := p.connector.Connect(ctx, endpoint) | |
| 156 | + | if err != nil { | |
| 157 | + | return ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityUnreachable}, err | |
| 158 | + | } | |
| 159 | + | defer func() { _ = physical.Close() }() | |
| 160 | + | // The endpoint stays authoritative: re-verify the accepted authority even | |
| 161 | + | // though the connector already checked it, so a future connector change can | |
| 162 | + | // never weaken what the probe publishes. | |
| 163 | + | if physical.Identity() != endpoint.ExpectedIdentity.Identity || !physical.Policy().Compatible(endpoint.Policy) { | |
| 164 | + | return ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityUnreachable}, errors.New("vev: broker local probe authenticated binding mismatch") | |
| 165 | + | } | |
| 166 | + | request, err := p.request() | |
| 101 | 167 | if err != nil { | |
| 102 | - | _ = raw.Close() | |
| 103 | 168 | return ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityUnreachable}, err | |
| 104 | 169 | } | |
| 105 | - | // RunClientHandshake closes the carriage on every failure; on success the | |
| 106 | - | // probe closes it here, because it has nothing further to send. The | |
| 107 | - | // handshake never opens a logical stream. | |
| 108 | - | result, err := daemonmux.RunClientHandshake(ctx, bridge, p.endpoint, p.ceilings) | |
| 109 | - | _ = bridge.Close() | |
| 170 | + | observation, err := observeDaemonCatalogue(ctx, physical, request) | |
| 110 | 171 | if err != nil { | |
| 111 | 172 | return ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityUnreachable}, err | |
| 112 | 173 | } | |
| 113 | - | return ports.BrokerDaemonObservation{ | |
| 114 | - | Identity: result.Identity, | |
| 115 | - | Incarnation: result.Incarnation, | |
| 116 | - | ProtocolVersion: result.Policy.ProtocolVersion, | |
| 117 | - | Availability: domain.RemoteAvailabilityReachable, | |
| 174 | + | return observation, nil | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | // request builds the one observation open this probe performs: a local | |
| 178 | + | // observation stream that may never start the daemon. Connection and stream | |
| 179 | + | // identities are fresh per attempt so a replayed or duplicated open is refused | |
| 180 | + | // by the peer's anti-replay admission window. The request carries no endpoint, | |
| 181 | + | // no registration, no admission, no session name, no exact target, and no | |
| 182 | + | // environment: the stream asks the daemon for its own catalogue and nothing | |
| 183 | + | // else. | |
| 184 | + | func (p *localRouteProbe) request() (ports.BrokerOpenStreamRequest, error) { | |
| 185 | + | connection, err := randomConnectionID() | |
| 186 | + | if err != nil { | |
| 187 | + | return ports.BrokerOpenStreamRequest{}, err | |
| 188 | + | } | |
| 189 | + | stream, err := randomNonzeroUint64() | |
| 190 | + | if err != nil { | |
| 191 | + | return ports.BrokerOpenStreamRequest{}, err | |
| 192 | + | } | |
| 193 | + | return ports.BrokerOpenStreamRequest{ | |
| 194 | + | Epoch: p.epoch, | |
| 195 | + | Purpose: ports.BrokerStreamObservation, | |
| 196 | + | Local: true, | |
| 197 | + | Connection: connection, | |
| 198 | + | Stream: ports.BrokerStreamID(stream), | |
| 199 | + | Policy: p.policy, | |
| 200 | + | // Observation never starts the target: an ExistingOnly authorization is | |
| 201 | + | // what ports enforces for an observation stream, and a start-if-needed | |
| 202 | + | // one would be refused rather than silently widened into a spawn. | |
| 203 | + | StartMode: ports.BrokerDaemonExistingOnly, | |
| 118 | 204 | }, nil | |
| 119 | 205 | } | |
| 120 | 206 | ||
| 121 | 207 | // brokerLocalProbe builds the broker-owned local probe over one validated | |
| 122 | - | // offline configuration. The dialer looks the opaque address up in the same | |
| 123 | - | // immutable configuration the pooled connector uses, so a probe can never reach | |
| 124 | - | // an address the configuration did not provision. | |
| 125 | - | func brokerLocalProbe(config *brokerconfig.Config, log *slog.Logger) (*localRouteProbe, error) { | |
| 208 | + | // offline configuration and the broker's already owned endpoint connector, so | |
| 209 | + | // the probe composes the exact transport owner the pool does instead of | |
| 210 | + | // creating a second one. | |
| 211 | + | func brokerLocalProbe(config *brokerconfig.Config, epoch ports.BrokerEpoch, connector ports.BrokerEndpointConnector, loaders ...localIdentityLoader) (*localRouteProbe, error) { | |
| 126 | 212 | binding, ok := config.LocalBinding() | |
| 127 | 213 | if !ok { | |
| 128 | 214 | return nil, errors.New("vev: broker local probe requires a provisioned local binding") | |
| 129 | 215 | } | |
| 130 | - | dial := func(ctx context.Context, address string) (daemonmux.RawFramedTransport, error) { | |
| 131 | - | route, ok := config.RouteByAddress(address) | |
| 132 | - | if !ok { | |
| 133 | - | return nil, errors.New("vev: broker local probe: unknown route address") | |
| 134 | - | } | |
| 135 | - | return dialBrokerRoute(ctx, route, log) | |
| 216 | + | loader := localIdentityLoader(func() (ports.BrokerDaemonIdentity, error) { return binding.Identity, nil }) | |
| 217 | + | if len(loaders) != 0 { | |
| 218 | + | loader = loaders[0] | |
| 136 | 219 | } | |
| 137 | - | return newLocalRouteProbe(binding, dial, daemonmux.DefaultMuxCeilings()) | |
| 220 | + | return newDynamicLocalRouteProbe(binding.Route, binding.Policy, epoch, connector, loader) | |
| 138 | 221 | } | |
| 139 | 222 | ||
| 140 | 223 | // brokerLocalObservation composes the registry's local producer from one | |
| 141 | 224 | // validated offline configuration: the configured display origin and policy | |
| 142 | 225 | // become the local entry's authority, and the probe supplies observed state | |
| 143 | 226 | // only. A configuration without a local binding provisions no local producer. | |
| 144 | - | func brokerLocalObservation(config *brokerconfig.Config, log *slog.Logger) (*broker.LocalObservation, error) { | |
| 227 | + | func brokerLocalObservation(config *brokerconfig.Config, epoch ports.BrokerEpoch, connector ports.BrokerEndpointConnector, loaders ...localIdentityLoader) (*broker.LocalObservation, error) { | |
| 145 | 228 | binding, ok := config.LocalBinding() | |
| 146 | 229 | if !ok { | |
| 147 | 230 | return nil, errors.New("vev: broker local observation requires a provisioned local binding") | |
| 148 | 231 | } | |
| 149 | - | probe, err := brokerLocalProbe(config, log) | |
| 232 | + | probe, err := brokerLocalProbe(config, epoch, connector, loaders...) | |
| 150 | 233 | if err != nil { | |
| 151 | 234 | return nil, err | |
| 152 | 235 | } | |
A internal/app/broker_offline_local_attach_test.go +327 -0
| @@ -0,0 +1,327 @@ | |||
| 1 | + | //go:build linux | |
| 2 | + | ||
| 3 | + | package app | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "os" | |
| 8 | + | "path/filepath" | |
| 9 | + | "sync" | |
| 10 | + | "testing" | |
| 11 | + | "time" | |
| 12 | + | ||
| 13 | + | "github.com/stretchr/testify/require" | |
| 14 | + | ||
| 15 | + | "github.com/bnema/vev/internal/adapters/brokerconfig" | |
| 16 | + | "github.com/bnema/vev/internal/adapters/clock" | |
| 17 | + | "github.com/bnema/vev/internal/adapters/daemonidentity" | |
| 18 | + | "github.com/bnema/vev/internal/adapters/daemonmux" | |
| 19 | + | "github.com/bnema/vev/internal/adapters/ipc" | |
| 20 | + | "github.com/bnema/vev/internal/adapters/pty" | |
| 21 | + | "github.com/bnema/vev/internal/domain" | |
| 22 | + | "github.com/bnema/vev/internal/ports" | |
| 23 | + | "github.com/bnema/vev/internal/protocol" | |
| 24 | + | "github.com/bnema/vev/internal/protocol/catalogue" | |
| 25 | + | "github.com/bnema/vev/internal/usecase/daemon" | |
| 26 | + | ) | |
| 27 | + | ||
| 28 | + | // P7.4b real-composition coverage for the broker-owned local probe. | |
| 29 | + | // | |
| 30 | + | // The scripted tests prove the probe's exact stream semantics. These tests run | |
| 31 | + | // the same probe against a real daemon serving a real private Unix daemonmux | |
| 32 | + | // carriage, so the authenticated physical preamble, the observation open, the | |
| 33 | + | // typed remote-catalog command, and the catalogue validation all compose against | |
| 34 | + | // production daemon code. They prove the observation is real: it creates no | |
| 35 | + | // session, no process, and no durable identity, and the catalogue it publishes | |
| 36 | + | // is exactly what makes an exact terminal attach resolvable. | |
| 37 | + | ||
| 38 | + | // realLocalDaemonFixture is a real daemon serving one private Unix daemonmux | |
| 39 | + | // carriage, plus the sandbox state directory a probe must never write to. | |
| 40 | + | type realLocalDaemonFixture struct { | |
| 41 | + | binding brokerconfig.LocalBinding | |
| 42 | + | route string | |
| 43 | + | stateDir string | |
| 44 | + | connector *daemonmux.EndpointConnector | |
| 45 | + | // nextStream allocates the strictly increasing logical stream identity each | |
| 46 | + | // helper attachment names, as the daemonmux engine requires within one | |
| 47 | + | // physical connection. | |
| 48 | + | nextStream ports.BrokerStreamID | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | // startRealLocalDaemon starts one real daemon over a private local daemonmux | |
| 52 | + | // carriage and returns the fixture the probe dials. | |
| 53 | + | func startRealLocalDaemon(t *testing.T) *realLocalDaemonFixture { | |
| 54 | + | t.Helper() | |
| 55 | + | _, prodRuntime, prodState := isolateSandboxEnv(t) | |
| 56 | + | root := filepath.Join(shortTempDir(t, "vevl"), "sandbox") | |
| 57 | + | require.NoError(t, os.MkdirAll(root, 0o700)) | |
| 58 | + | policy := brokerTestPolicy() | |
| 59 | + | route := filepath.Join(root, "local-mux.sock") | |
| 60 | + | stateDir := filepath.Join(root, "state") | |
| 61 | + | require.NoError(t, os.MkdirAll(stateDir, 0o700)) | |
| 62 | + | writeSandboxConfig(t, root, map[string]any{ | |
| 63 | + | "marker": brokerconfig.Marker, | |
| 64 | + | "registrations": []any{}, | |
| 65 | + | "local": map[string]any{ | |
| 66 | + | "identity": brokerLocalTestIdentity, | |
| 67 | + | "displayOrigin": "local", | |
| 68 | + | "route": route, | |
| 69 | + | "policy": map[string]any{ | |
| 70 | + | "protocolVersion": policy.ProtocolVersion, | |
| 71 | + | "catalogSchemaVersion": policy.CatalogSchemaVersion, | |
| 72 | + | "environmentPolicy": "client-owned", | |
| 73 | + | "transport": policy.Transport, | |
| 74 | + | "trust": policy.Trust, | |
| 75 | + | "launch": policy.Launch, | |
| 76 | + | "isolation": policy.Isolation, | |
| 77 | + | }, | |
| 78 | + | }, | |
| 79 | + | }) | |
| 80 | + | layout, err := offlineLayout(root) | |
| 81 | + | require.NoError(t, err) | |
| 82 | + | config, err := brokerconfig.Load(layout) | |
| 83 | + | require.NoError(t, err) | |
| 84 | + | binding, ok := config.LocalBinding() | |
| 85 | + | require.True(t, ok) | |
| 86 | + | ||
| 87 | + | listener, err := ipc.ListenMux(route) | |
| 88 | + | require.NoError(t, err) | |
| 89 | + | aggregate := daemonmux.NewAggregateListener() | |
| 90 | + | serverBinding := testServerBinding(t, binding) | |
| 91 | + | supervisor, err := daemonmux.NewServerSupervisor(aggregate, serverBinding, daemonmux.DefaultMuxCeilings(), 0) | |
| 92 | + | require.NoError(t, err) | |
| 93 | + | ||
| 94 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 95 | + | d := daemon.New(pty.NewFactory(), clock.New(), discardLog(), | |
| 96 | + | daemon.WithShell("/bin/sh", []string{"-c", "trap : TERM; while :; do read line || exit; done"})) | |
| 97 | + | go func() { _ = d.Serve(ctx, aggregate) }() | |
| 98 | + | go func() { | |
| 99 | + | for { | |
| 100 | + | raw, acceptErr := listener.Accept() | |
| 101 | + | if acceptErr != nil { | |
| 102 | + | return | |
| 103 | + | } | |
| 104 | + | go func() { _ = supervisor.Adopt(ctx, raw) }() | |
| 105 | + | } | |
| 106 | + | }() | |
| 107 | + | t.Cleanup(func() { | |
| 108 | + | cancel() | |
| 109 | + | _ = listener.Close() | |
| 110 | + | _ = supervisor.Close() | |
| 111 | + | _ = aggregate.Close() | |
| 112 | + | requireProductionUntouched(t, prodRuntime, prodState) | |
| 113 | + | }) | |
| 114 | + | ||
| 115 | + | dial := func(ctx context.Context, _ ports.BrokerDialTarget) (daemonmux.RawFramedTransport, error) { | |
| 116 | + | return ipc.DialMuxContext(ctx, route) | |
| 117 | + | } | |
| 118 | + | connector, err := daemonmux.NewEndpointConnector(daemonmux.RawCarrierDialer(dial), daemonmux.DefaultMuxCeilings()) | |
| 119 | + | require.NoError(t, err) | |
| 120 | + | return &realLocalDaemonFixture{binding: binding, route: route, stateDir: stateDir, connector: connector} | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | // probe returns one probe over the fixture's real carriage. | |
| 124 | + | func (f *realLocalDaemonFixture) probe(t *testing.T) *localRouteProbe { | |
| 125 | + | t.Helper() | |
| 126 | + | dial := func(ctx context.Context, target ports.BrokerDialTarget) (daemonmux.RawFramedTransport, error) { | |
| 127 | + | require.Equal(t, f.binding.Route.Address(), target.Address, "the probe dials only its provisioned local address") | |
| 128 | + | return ipc.DialMuxContext(ctx, f.route) | |
| 129 | + | } | |
| 130 | + | probe, err := newLocalRouteProbe(f.binding, brokerLocalTestEpoch, dial, daemonmux.DefaultMuxCeilings()) | |
| 131 | + | require.NoError(t, err) | |
| 132 | + | return probe | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | // openStream opens one logical stream over the fixture's real carriage under | |
| 136 | + | // the fixture's authenticated local endpoint. | |
| 137 | + | func (f *realLocalDaemonFixture) openStream(t *testing.T, request ports.BrokerOpenStreamRequest) ports.BrokerLogicalConnection { | |
| 138 | + | t.Helper() | |
| 139 | + | ctx, cancel := context.WithTimeout(context.Background(), brokerTestWait) | |
| 140 | + | t.Cleanup(cancel) | |
| 141 | + | physical, err := f.connector.Connect(ctx, ports.BrokerDialTarget{ | |
| 142 | + | Fence: ports.BrokerEndpointFence{Local: true}, Policy: f.binding.Policy, Address: f.binding.Route.Address(), | |
| 143 | + | StartMode: ports.BrokerDaemonExistingOnly, | |
| 144 | + | ExpectedIdentity: ports.BrokerExpectedIdentity{Identity: f.binding.Identity, Bound: true}, | |
| 145 | + | }) | |
| 146 | + | require.NoError(t, err) | |
| 147 | + | t.Cleanup(func() { _ = physical.Close() }) | |
| 148 | + | ||
| 149 | + | request.Epoch = brokerLocalTestEpoch | |
| 150 | + | request.Local = true | |
| 151 | + | request.Policy = f.binding.Policy | |
| 152 | + | request.Connection = ports.BrokerConnectionID{4, 4} | |
| 153 | + | if request.Stream == 0 { | |
| 154 | + | f.nextStream++ | |
| 155 | + | request.Stream = f.nextStream | |
| 156 | + | } | |
| 157 | + | stream, err := physical.OpenStream(ctx, request) | |
| 158 | + | require.NoError(t, err) | |
| 159 | + | t.Cleanup(func() { _ = stream.Close() }) | |
| 160 | + | return stream | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | // awaitWelcome receives server messages until the typed Welcome arrives. | |
| 164 | + | func awaitWelcome(t *testing.T, stream ports.BrokerLogicalConnection) protocol.Welcome { | |
| 165 | + | t.Helper() | |
| 166 | + | type outcome struct { | |
| 167 | + | message protocol.ServerMessage | |
| 168 | + | err error | |
| 169 | + | } | |
| 170 | + | results := make(chan outcome, 1) | |
| 171 | + | go func() { | |
| 172 | + | for { | |
| 173 | + | message, err := stream.ReceiveServer() | |
| 174 | + | if err != nil { | |
| 175 | + | results <- outcome{err: err} | |
| 176 | + | return | |
| 177 | + | } | |
| 178 | + | if welcome, ok := message.(protocol.Welcome); ok { | |
| 179 | + | results <- outcome{message: welcome} | |
| 180 | + | return | |
| 181 | + | } | |
| 182 | + | } | |
| 183 | + | }() | |
| 184 | + | select { | |
| 185 | + | case result := <-results: | |
| 186 | + | require.NoError(t, result.err, "waiting for Welcome") | |
| 187 | + | return result.message.(protocol.Welcome) | |
| 188 | + | case <-time.After(brokerTestWait): | |
| 189 | + | t.Fatal("timed out waiting for Welcome") | |
| 190 | + | return protocol.Welcome{} | |
| 191 | + | } | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | // TestBrokerLocalProbeRealCatalogueEnablesExactAttach proves the broker-owned | |
| 195 | + | // local probe reads a real daemon's catalogue and that the catalogue it | |
| 196 | + | // publishes is exactly what makes an exact terminal attach resolvable. | |
| 197 | + | func TestBrokerLocalProbeRealCatalogueEnablesExactAttach(t *testing.T) { | |
| 198 | + | fixture := startRealLocalDaemon(t) | |
| 199 | + | probe := fixture.probe(t) | |
| 200 | + | ||
| 201 | + | // An observation of a daemon that owns no session reports reachable, known, | |
| 202 | + | // and empty inventory: observing never creates a session. | |
| 203 | + | empty, err := probe.ProbeLocal(context.Background()) | |
| 204 | + | require.NoError(t, err) | |
| 205 | + | require.Equal(t, domain.RemoteAvailabilityReachable, empty.Availability) | |
| 206 | + | require.True(t, empty.InventoryKnown) | |
| 207 | + | require.Empty(t, empty.Sessions, "an observation must never create a session") | |
| 208 | + | require.Equal(t, fixture.binding.Identity, empty.Identity) | |
| 209 | + | require.NotZero(t, empty.ProtocolVersion) | |
| 210 | + | ||
| 211 | + | // One real session is created through one real attachment stream. | |
| 212 | + | attachment := fixture.openStream(t, ports.BrokerOpenStreamRequest{ | |
| 213 | + | Purpose: ports.BrokerStreamAttachment, | |
| 214 | + | Admission: ports.BrokerAdmissionCreateNamed, | |
| 215 | + | Name: "probe-target", | |
| 216 | + | StartMode: ports.BrokerDaemonStartIfNeeded, | |
| 217 | + | }) | |
| 218 | + | require.NoError(t, attachment.SendClient(protocol.Hello{ | |
| 219 | + | Version: protocol.Version, Intent: protocol.IntentNew, Name: "probe-target", | |
| 220 | + | Size: domain.Size{Cols: 80, Rows: 24}, EnvironmentPolicy: protocol.EnvironmentPolicyClientOwned, | |
| 221 | + | })) | |
| 222 | + | welcome := awaitWelcome(t, attachment) | |
| 223 | + | require.NotNil(t, welcome.CommittedIdentity) | |
| 224 | + | target := welcome.CommittedIdentity.Target | |
| 225 | + | ||
| 226 | + | // The observation reports the real daemon's catalogue: the exact session | |
| 227 | + | // lifecycle the daemon committed, so the observation is the authority an | |
| 228 | + | // exact attach resolves against. | |
| 229 | + | observed, err := probe.ProbeLocal(context.Background()) | |
| 230 | + | require.NoError(t, err) | |
| 231 | + | require.Equal(t, domain.RemoteAvailabilityReachable, observed.Availability) | |
| 232 | + | require.True(t, observed.InventoryKnown) | |
| 233 | + | require.Equal(t, fixture.binding.Identity, observed.Identity) | |
| 234 | + | require.Len(t, observed.Sessions, 1) | |
| 235 | + | require.Equal(t, "probe-target", observed.Sessions[0].Name) | |
| 236 | + | require.Equal(t, target.LifecycleID, observed.Sessions[0].LifecycleID) | |
| 237 | + | require.Equal(t, catalogue.RemoteCatalogSessionUp, observed.Sessions[0].State) | |
| 238 | + | ||
| 239 | + | // The published catalogue resolves the exact attach target the client | |
| 240 | + | // composition uses, and that exact target attaches successfully. | |
| 241 | + | resolved, ok := brokerObservationExactTarget(observed, "probe-target") | |
| 242 | + | require.True(t, ok) | |
| 243 | + | require.Equal(t, target, resolved) | |
| 244 | + | ||
| 245 | + | exact := fixture.openStream(t, ports.BrokerOpenStreamRequest{ | |
| 246 | + | Purpose: ports.BrokerStreamAttachment, | |
| 247 | + | Admission: ports.BrokerAdmissionExact, | |
| 248 | + | Target: resolved, | |
| 249 | + | StartMode: ports.BrokerDaemonStartIfNeeded, | |
| 250 | + | }) | |
| 251 | + | exactTarget := resolved | |
| 252 | + | require.NoError(t, exact.SendClient(protocol.Hello{ | |
| 253 | + | Version: protocol.Version, Intent: protocol.IntentAttach, Name: resolved.SessionName, ExactTarget: &exactTarget, | |
| 254 | + | Size: domain.Size{Cols: 80, Rows: 24}, EnvironmentPolicy: protocol.EnvironmentPolicyClientOwned, | |
| 255 | + | })) | |
| 256 | + | exactWelcome := awaitWelcome(t, exact) | |
| 257 | + | require.NotNil(t, exactWelcome.CommittedIdentity) | |
| 258 | + | require.Equal(t, resolved, exactWelcome.CommittedIdentity.Target) | |
| 259 | + | ||
| 260 | + | // Probing creates no durable identity: only a daemon-owned startup path ever | |
| 261 | + | // creates the identity file, and the probe is not one. | |
| 262 | + | _, statErr := os.Lstat(daemonidentity.Path(fixture.stateDir)) | |
| 263 | + | require.ErrorIs(t, statErr, os.ErrNotExist, "an observation must never create daemon identity") | |
| 264 | + | } | |
| 265 | + | ||
| 266 | + | // TestBrokerLocalProbeAbsentDaemonStaysUnavailable proves an absence daemon | |
| 267 | + | // leaves the local observation unavailable with zero identity and creates | |
| 268 | + | // nothing: no session, no process, no durable identity. brokerconfig refuses a | |
| 269 | + | // missing or malformed configuration before any probe exists, and an absent | |
| 270 | + | // carriage is reported, never started. | |
| 271 | + | func TestBrokerLocalProbeAbsentDaemonStaysUnavailable(t *testing.T) { | |
| 272 | + | _, prodRuntime, prodState := isolateSandboxEnv(t) | |
| 273 | + | root := filepath.Join(shortTempDir(t, "vevl"), "sandbox") | |
| 274 | + | require.NoError(t, os.MkdirAll(root, 0o700)) | |
| 275 | + | stateDir := filepath.Join(root, "state") | |
| 276 | + | require.NoError(t, os.MkdirAll(stateDir, 0o700)) | |
| 277 | + | // A local binding provisioned on a socket nothing listens on: the probe must | |
| 278 | + | // report unavailable rather than start anything. | |
| 279 | + | route := filepath.Join(root, "absent-mux.sock") | |
| 280 | + | writeSandboxConfig(t, root, map[string]any{ | |
| 281 | + | "marker": brokerconfig.Marker, | |
| 282 | + | "registrations": []any{}, | |
| 283 | + | "local": map[string]any{ | |
| 284 | + | "identity": brokerLocalTestIdentity, "displayOrigin": "local", "route": route, | |
| 285 | + | "policy": map[string]any{ | |
| 286 | + | "protocolVersion": brokerTestPolicy().ProtocolVersion, "catalogSchemaVersion": brokerTestPolicy().CatalogSchemaVersion, | |
| 287 | + | "environmentPolicy": "client-owned", "transport": brokerTestPolicy().Transport, | |
| 288 | + | "trust": brokerTestPolicy().Trust, "launch": brokerTestPolicy().Launch, "isolation": brokerTestPolicy().Isolation, | |
| 289 | + | }, | |
| 290 | + | }, | |
| 291 | + | }) | |
| 292 | + | layout, err := offlineLayout(root) | |
| 293 | + | require.NoError(t, err) | |
| 294 | + | config, err := brokerconfig.Load(layout) | |
| 295 | + | require.NoError(t, err) | |
| 296 | + | binding, ok := config.LocalBinding() | |
| 297 | + | require.True(t, ok) | |
| 298 | + | ||
| 299 | + | var dialed []string | |
| 300 | + | var mu sync.Mutex | |
| 301 | + | probe, err := newLocalRouteProbe(binding, brokerLocalTestEpoch, func(ctx context.Context, target ports.BrokerDialTarget) (daemonmux.RawFramedTransport, error) { | |
| 302 | + | mu.Lock() | |
| 303 | + | dialed = append(dialed, target.Address) | |
| 304 | + | mu.Unlock() | |
| 305 | + | return ipc.DialMuxContext(ctx, target.Address) | |
| 306 | + | }, daemonmux.DefaultMuxCeilings()) | |
| 307 | + | require.NoError(t, err) | |
| 308 | + | ||
| 309 | + | ctx, cancel := context.WithTimeout(context.Background(), brokerTestWait) | |
| 310 | + | defer cancel() | |
| 311 | + | observation, err := probe.ProbeLocal(ctx) | |
| 312 | + | require.Error(t, err) | |
| 313 | + | require.Equal(t, domain.RemoteAvailabilityUnreachable, observation.Availability) | |
| 314 | + | require.Zero(t, observation.Identity) | |
| 315 | + | require.False(t, observation.InventoryKnown) | |
| 316 | + | require.Empty(t, observation.Sessions) | |
| 317 | + | ||
| 318 | + | mu.Lock() | |
| 319 | + | require.Equal(t, []string{binding.Route.Address()}, dialed, "the probe dials only the address its provisioned local binding published") | |
| 320 | + | mu.Unlock() | |
| 321 | + | // Nothing was created: no socket, no identity, no production path entry. | |
| 322 | + | _, statErr := os.Lstat(route) | |
| 323 | + | require.ErrorIs(t, statErr, os.ErrNotExist, "a probe must never create a carriage") | |
| 324 | + | _, identityErr := os.Lstat(daemonidentity.Path(stateDir)) | |
| 325 | + | require.ErrorIs(t, identityErr, os.ErrNotExist, "a probe must never create daemon identity") | |
| 326 | + | requireProductionUntouched(t, prodRuntime, prodState) | |
| 327 | + | } | |
M internal/app/broker_offline_local_test.go +726 -88
| @@ -2,12 +2,14 @@ package app | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "encoding/json" | |
| 5 | 6 | "errors" | |
| 6 | 7 | "io" | |
| 7 | 8 | "log/slog" | |
| 8 | 9 | "net" | |
| 9 | 10 | "os" | |
| 10 | 11 | "path/filepath" | |
| 12 | + | "sync" | |
| 11 | 13 | "sync/atomic" | |
| 12 | 14 | "testing" | |
| 13 | 15 | "time" | |
| @@ -17,14 +19,20 @@ import ( | |||
| 17 | 19 | "github.com/bnema/vev/internal/adapters/brokerconfig" | |
| 18 | 20 | "github.com/bnema/vev/internal/adapters/brokerstore" | |
| 19 | 21 | "github.com/bnema/vev/internal/adapters/clock" | |
| 22 | + | "github.com/bnema/vev/internal/adapters/daemonidentity" | |
| 20 | 23 | "github.com/bnema/vev/internal/adapters/daemonmux" | |
| 21 | 24 | "github.com/bnema/vev/internal/adapters/ipc" | |
| 22 | 25 | "github.com/bnema/vev/internal/domain" | |
| 23 | 26 | "github.com/bnema/vev/internal/ports" | |
| 27 | + | "github.com/bnema/vev/internal/protocol" | |
| 28 | + | "github.com/bnema/vev/internal/protocol/catalogue" | |
| 24 | 29 | "github.com/bnema/vev/internal/usecase/broker" | |
| 25 | 30 | ) | |
| 26 | 31 | ||
| 27 | - | const brokerLocalTestIdentity = "offline-local-daemon" | |
| 32 | + | const ( | |
| 33 | + | brokerLocalTestIdentity = "offline-local-daemon" | |
| 34 | + | brokerLocalTestEpoch = ports.BrokerEpoch(11) | |
| 35 | + | ) | |
| 28 | 36 | ||
| 29 | 37 | // testLocalSandboxConfig writes and loads one marker-valid sandbox whose only | |
| 30 | 38 | // provisioned carriage is the broker-owned local binding. | |
| @@ -51,7 +59,7 @@ func testLocalSandboxConfig(t *testing.T) (*brokerconfig.Config, brokerconfig.Lo | |||
| 51 | 59 | }, | |
| 52 | 60 | }, | |
| 53 | 61 | }) | |
| 54 | - | layout, err := brokerconfig.ResolveLayout(root, nil) | |
| 62 | + | layout, err := offlineLayout(root) | |
| 55 | 63 | require.NoError(t, err) | |
| 56 | 64 | config, err := brokerconfig.Load(layout) | |
| 57 | 65 | require.NoError(t, err) | |
| @@ -74,81 +82,268 @@ func testServerBinding(t *testing.T, binding brokerconfig.LocalBinding) daemonmu | |||
| 74 | 82 | return serverBinding | |
| 75 | 83 | } | |
| 76 | 84 | ||
| 77 | - | // TestBrokerLocalProbeObservesWithoutAttaching proves the local probe completes | |
| 78 | - | // only the daemonmux physical preamble: it reports the daemon's authenticated | |
| 79 | - | // identity, incarnation, and negotiated protocol version, and then closes the | |
| 80 | - | // carriage without ever opening a logical stream (no attach). Capabilities and | |
| 81 | - | // the session catalogue stay unknown because the preamble carries neither. | |
| 82 | - | func TestBrokerLocalProbeObservesWithoutAttaching(t *testing.T) { | |
| 83 | - | _, binding := testLocalSandboxConfig(t) | |
| 84 | - | serverBinding := testServerBinding(t, binding) | |
| 85 | + | // testLocalConnector builds the broker-owned endpoint connector a probe | |
| 86 | + | // composes, over an explicit dial seam. It is the same connector type the pool | |
| 87 | + | // uses, so a test exercises the real composition path. | |
| 88 | + | func testLocalConnector(t *testing.T, dial localCarrierDialer) ports.BrokerEndpointConnector { | |
| 89 | + | t.Helper() | |
| 90 | + | connector, err := daemonmux.NewEndpointConnector(daemonmux.RawCarrierDialer(dial), daemonmux.DefaultMuxCeilings()) | |
| 91 | + | require.NoError(t, err) | |
| 92 | + | return connector | |
| 93 | + | } | |
| 85 | 94 | ||
| 86 | - | clientConn, serverConn := net.Pipe() | |
| 87 | - | t.Cleanup(func() { | |
| 88 | - | _ = clientConn.Close() | |
| 89 | - | _ = serverConn.Close() | |
| 95 | + | // unreachableCarriageConnector builds a connector whose dial always fails, so a | |
| 96 | + | // composition test can build a probe without a scripted daemon. | |
| 97 | + | func unreachableCarriageConnector(t *testing.T) ports.BrokerEndpointConnector { | |
| 98 | + | t.Helper() | |
| 99 | + | return testLocalConnector(t, func(context.Context, ports.BrokerDialTarget) (daemonmux.RawFramedTransport, error) { | |
| 100 | + | return nil, errors.New("no carriage") | |
| 90 | 101 | }) | |
| 102 | + | } | |
| 91 | 103 | ||
| 92 | - | var dialed atomic.Value | |
| 93 | - | dial := func(_ context.Context, address string) (daemonmux.RawFramedTransport, error) { | |
| 94 | - | dialed.Store(address) | |
| 95 | - | return rawFramedTransport(clientConn), nil | |
| 96 | - | } | |
| 97 | - | probe, err := newLocalRouteProbe(binding, dial, daemonmux.DefaultMuxCeilings()) | |
| 104 | + | // testLocalCatalogue builds one exact-schema catalogue carrying the supplied | |
| 105 | + | // sessions, marshalled exactly as the daemon's remote-catalog command does. | |
| 106 | + | func testLocalCatalogue(t *testing.T, sessions ...catalogue.RemoteCatalogSession) string { | |
| 107 | + | t.Helper() | |
| 108 | + | encoded, err := json.Marshal(catalogue.RemoteCatalog{ | |
| 109 | + | ProtocolVersion: protocol.Version, | |
| 110 | + | SchemaVersion: catalogue.RemoteCatalogSchemaVersion, | |
| 111 | + | Sessions: sessions, | |
| 112 | + | }) | |
| 98 | 113 | require.NoError(t, err) | |
| 114 | + | return string(encoded) + "\n" | |
| 115 | + | } | |
| 99 | 116 | ||
| 100 | - | // The daemon side completes one preamble and then blocks reading the next | |
| 101 | - | // frame. The probe must never send one: the read ends only when the probe | |
| 102 | - | // closes the carriage, which is the proof that no attachment stream opened. | |
| 103 | - | serverDone := make(chan error, 1) | |
| 117 | + | // testLocalUpSession builds one exact, attachable catalogue session with an | |
| 118 | + | // ordered tab list. | |
| 119 | + | func testLocalUpSession(lifecycle domain.SessionLifecycleID, name string) catalogue.RemoteCatalogSession { | |
| 120 | + | return catalogue.RemoteCatalogSession{ | |
| 121 | + | LifecycleID: lifecycle, | |
| 122 | + | Name: name, | |
| 123 | + | State: catalogue.RemoteCatalogSessionUp, | |
| 124 | + | Tabs: []catalogue.RemoteCatalogTab{}, | |
| 125 | + | } | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | // scriptedLocalDaemon is a scripted daemonmux daemon for the broker-owned local | |
| 129 | + | // probe: it completes the authenticated physical preamble for the supplied | |
| 130 | + | // binding, accepts the logical streams the probe opens, records every typed | |
| 131 | + | // client message, and answers from a scripted responder. It never creates a | |
| 132 | + | // session or a process and never interprets the probe's traffic beyond the | |
| 133 | + | // responder. | |
| 134 | + | type scriptedLocalDaemon struct { | |
| 135 | + | supervisor *daemonmux.ServerSupervisor | |
| 136 | + | respond func(protocol.ClientMessage) protocol.ServerMessage | |
| 137 | + | ||
| 138 | + | dials atomic.Int32 | |
| 139 | + | ||
| 140 | + | mu sync.Mutex | |
| 141 | + | streams int | |
| 142 | + | received []protocol.ClientMessage | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | // newScriptedLocalDaemon serves one scripted daemonmux daemon over in-memory | |
| 146 | + | // carriages. The responder answers one typed client message; a nil reply ends | |
| 147 | + | // the stream without an answer. | |
| 148 | + | func newScriptedLocalDaemon(t *testing.T, binding daemonmux.ServerBinding, respond func(protocol.ClientMessage) protocol.ServerMessage) *scriptedLocalDaemon { | |
| 149 | + | t.Helper() | |
| 150 | + | daemon := &scriptedLocalDaemon{respond: respond} | |
| 151 | + | aggregate := daemonmux.NewAggregateListener() | |
| 152 | + | supervisor, err := daemonmux.NewServerSupervisor(aggregate, binding, daemonmux.DefaultMuxCeilings(), 0) | |
| 153 | + | require.NoError(t, err) | |
| 154 | + | t.Cleanup(func() { | |
| 155 | + | _ = supervisor.Close() | |
| 156 | + | _ = aggregate.Close() | |
| 157 | + | }) | |
| 104 | 158 | go func() { | |
| 105 | - | bridge, err := daemonmux.NewPreambleCarrier(rawFramedTransport(serverConn)) | |
| 159 | + | for { | |
| 160 | + | connection, acceptErr := aggregate.Accept() | |
| 161 | + | if acceptErr != nil { | |
| 162 | + | return | |
| 163 | + | } | |
| 164 | + | daemon.mu.Lock() | |
| 165 | + | daemon.streams++ | |
| 166 | + | daemon.mu.Unlock() | |
| 167 | + | go daemon.serveStream(connection) | |
| 168 | + | } | |
| 169 | + | }() | |
| 170 | + | daemon.supervisor = supervisor | |
| 171 | + | return daemon | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | // dial returns the probe's carrier dialer: one fresh in-memory carriage whose | |
| 175 | + | // daemon end is adopted by the scripted daemon. | |
| 176 | + | func (d *scriptedLocalDaemon) dial() localCarrierDialer { | |
| 177 | + | return func(ctx context.Context, _ ports.BrokerDialTarget) (daemonmux.RawFramedTransport, error) { | |
| 178 | + | d.dials.Add(1) | |
| 179 | + | client, server := net.Pipe() | |
| 180 | + | go func() { _ = d.supervisor.Adopt(context.Background(), rawFramedTransport(server)) }() | |
| 181 | + | return rawFramedTransport(client), nil | |
| 182 | + | } | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | // serveStream records every typed client message of one admitted stream and | |
| 186 | + | // answers it from the responder. A responder that returns nil ends the stream | |
| 187 | + | // without an answer, so a test can exercise the peer-closed-without-replying | |
| 188 | + | // path; a responder that blocks holds the stream open, so a test can exercise | |
| 189 | + | // the caller-bounded wait. | |
| 190 | + | func (d *scriptedLocalDaemon) serveStream(connection ports.ServerConnection) { | |
| 191 | + | defer func() { _ = connection.Close() }() | |
| 192 | + | for { | |
| 193 | + | message, err := connection.ReceiveClient() | |
| 106 | 194 | if err != nil { | |
| 107 | - | serverDone <- err | |
| 108 | 195 | return | |
| 109 | 196 | } | |
| 110 | - | if _, err := daemonmux.RunServerHandshake(context.Background(), bridge, serverBinding, daemonmux.DefaultMuxCeilings()); err != nil { | |
| 111 | - | serverDone <- err | |
| 197 | + | d.mu.Lock() | |
| 198 | + | d.received = append(d.received, message) | |
| 199 | + | d.mu.Unlock() | |
| 200 | + | if d.respond == nil { | |
| 112 | 201 | return | |
| 113 | 202 | } | |
| 114 | - | readCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) | |
| 115 | - | defer cancel() | |
| 116 | - | _, recvErr := bridge.Receive(readCtx) | |
| 117 | - | serverDone <- recvErr | |
| 118 | - | }() | |
| 203 | + | reply := d.respond(message) | |
| 204 | + | if reply == nil { | |
| 205 | + | return | |
| 206 | + | } | |
| 207 | + | if err := connection.SendServer(reply); err != nil { | |
| 208 | + | return | |
| 209 | + | } | |
| 210 | + | } | |
| 211 | + | } | |
| 212 | + | ||
| 213 | + | // messages returns a defensive copy of every recorded client message. | |
| 214 | + | func (d *scriptedLocalDaemon) messages() []protocol.ClientMessage { | |
| 215 | + | d.mu.Lock() | |
| 216 | + | defer d.mu.Unlock() | |
| 217 | + | return append([]protocol.ClientMessage(nil), d.received...) | |
| 218 | + | } | |
| 219 | + | ||
| 220 | + | // awaitMessages waits until the daemon recorded at least count messages. | |
| 221 | + | func (d *scriptedLocalDaemon) awaitMessages(t *testing.T, count int) []protocol.ClientMessage { | |
| 222 | + | t.Helper() | |
| 223 | + | require.Eventually(t, func() bool { | |
| 224 | + | d.mu.Lock() | |
| 225 | + | defer d.mu.Unlock() | |
| 226 | + | return len(d.received) >= count | |
| 227 | + | }, brokerTestWait, time.Millisecond, "scripted local daemon recorded %d messages", len(d.messages())) | |
| 228 | + | return d.messages() | |
| 229 | + | } | |
| 230 | + | ||
| 231 | + | // awaitStreams waits until the daemon admitted at least count logical streams. | |
| 232 | + | func (d *scriptedLocalDaemon) awaitStreams(t *testing.T, count int) { | |
| 233 | + | t.Helper() | |
| 234 | + | require.Eventually(t, func() bool { | |
| 235 | + | d.mu.Lock() | |
| 236 | + | defer d.mu.Unlock() | |
| 237 | + | return d.streams >= count | |
| 238 | + | }, brokerTestWait, time.Millisecond, "scripted local daemon admitted too few streams") | |
| 239 | + | } | |
| 240 | + | ||
| 241 | + | // TestBrokerLocalProbeObservesAuthenticatedCatalogue proves the broker-owned | |
| 242 | + | // local probe opens exactly one logical observation stream over the | |
| 243 | + | // authenticated daemonmux carriage, asks the daemon for its own catalogue | |
| 244 | + | // through the existing typed control protocol, and publishes the observed | |
| 245 | + | // identity, incarnation, protocol version, and exact session inventory. It | |
| 246 | + | // never sends a Hello, requests no admission, claims no geometry, and creates | |
| 247 | + | // nothing. | |
| 248 | + | func TestBrokerLocalProbeObservesAuthenticatedCatalogue(t *testing.T) { | |
| 249 | + | _, binding := testLocalSandboxConfig(t) | |
| 250 | + | serverBinding := testServerBinding(t, binding) | |
| 251 | + | lifecycle := domain.SessionLifecycleID{1} | |
| 252 | + | session := testLocalUpSession(lifecycle, "work") | |
| 253 | + | ||
| 254 | + | daemon := newScriptedLocalDaemon(t, serverBinding, func(message protocol.ClientMessage) protocol.ServerMessage { | |
| 255 | + | request, ok := message.(protocol.CommandRequest) | |
| 256 | + | if !ok { | |
| 257 | + | return nil | |
| 258 | + | } | |
| 259 | + | return protocol.CommandResult{RequestID: request.RequestID, Outcome: protocol.CommandSucceeded, Output: testLocalCatalogue(t, session)} | |
| 260 | + | }) | |
| 261 | + | ||
| 262 | + | probe, err := newLocalRouteProbe(binding, brokerLocalTestEpoch, daemon.dial(), daemonmux.DefaultMuxCeilings()) | |
| 263 | + | require.NoError(t, err) | |
| 119 | 264 | ||
| 120 | 265 | observation, err := probe.ProbeLocal(context.Background()) | |
| 121 | 266 | require.NoError(t, err) | |
| 122 | 267 | require.Equal(t, binding.Identity, observation.Identity) | |
| 123 | - | require.Equal(t, ports.BrokerDaemonIncarnation{7, 1}, observation.Incarnation) | |
| 124 | - | require.Equal(t, binding.Policy.ProtocolVersion, observation.ProtocolVersion) | |
| 268 | + | require.Equal(t, serverBinding.Incarnation(), observation.Incarnation) | |
| 269 | + | require.Equal(t, protocol.Version, observation.ProtocolVersion) | |
| 125 | 270 | require.Equal(t, domain.RemoteAvailabilityReachable, observation.Availability) | |
| 126 | - | require.Zero(t, observation.Capabilities, "the preamble carries no capabilities; they must stay unknown") | |
| 127 | - | require.Empty(t, observation.Sessions) | |
| 128 | - | require.False(t, observation.InventoryKnown) | |
| 129 | - | require.False(t, observation.Local, "the probe supplies observed state only, never local authority") | |
| 271 | + | require.True(t, observation.InventoryKnown, "an answered catalogue is known inventory") | |
| 272 | + | require.Equal(t, []catalogue.RemoteCatalogSession{session}, observation.Sessions) | |
| 273 | + | require.Zero(t, observation.Capabilities, "the catalogue command carries no capabilities; they must stay unknown") | |
| 274 | + | require.False(t, observation.Local, "the probe supplies observed state only, never configured authority") | |
| 130 | 275 | require.Empty(t, observation.Endpoint) | |
| 131 | - | require.Equal(t, binding.Route.Address(), dialed.Load()) | |
| 276 | + | require.Equal(t, domain.RemoteRegistration{}, observation.Registration) | |
| 277 | + | ||
| 278 | + | // Exactly one observation stream opened, and its only traffic is one typed, | |
| 279 | + | // correlated remote-catalog request: no Hello means no attachment, no | |
| 280 | + | // session creation, and no geometry claim. | |
| 281 | + | daemon.awaitStreams(t, 1) | |
| 282 | + | messages := daemon.awaitMessages(t, 1) | |
| 283 | + | require.Len(t, messages, 1) | |
| 284 | + | request, ok := messages[0].(protocol.CommandRequest) | |
| 285 | + | require.True(t, ok, "the observation stream must send only a typed command, got %#v", messages[0]) | |
| 286 | + | require.Equal(t, "remote-catalog", request.Slug) | |
| 287 | + | require.True(t, request.JSON) | |
| 288 | + | require.False(t, request.Attached, "an observation stream never claims an attachment") | |
| 289 | + | require.NotZero(t, request.RequestID) | |
| 290 | + | require.Equal(t, protocol.Version, request.Version) | |
| 291 | + | for _, message := range messages { | |
| 292 | + | _, hello := message.(protocol.Hello) | |
| 293 | + | require.False(t, hello, "an observation must never send a Hello") | |
| 294 | + | } | |
| 295 | + | require.EqualValues(t, 1, daemon.dials.Load()) | |
| 296 | + | } | |
| 297 | + | ||
| 298 | + | // TestBrokerLocalProbeRequestIsObservationOnly proves the one open the local | |
| 299 | + | // probe performs is a local observation that may never start the daemon: it | |
| 300 | + | // carries no admission, no name, no target, no environment, no endpoint, and | |
| 301 | + | // no registration, and it is refused outright when it authorizes a spawn. | |
| 302 | + | func TestBrokerLocalProbeRequestIsObservationOnly(t *testing.T) { | |
| 303 | + | _, binding := testLocalSandboxConfig(t) | |
| 304 | + | probe, err := newLocalRouteProbe(binding, brokerLocalTestEpoch, func(context.Context, ports.BrokerDialTarget) (daemonmux.RawFramedTransport, error) { | |
| 305 | + | return nil, nil | |
| 306 | + | }, daemonmux.DefaultMuxCeilings()) | |
| 307 | + | require.NoError(t, err) | |
| 132 | 308 | ||
| 133 | - | serverErr := <-serverDone | |
| 134 | - | require.Error(t, serverErr, "the probe sent no frame after the preamble") | |
| 135 | - | require.True(t, errors.Is(serverErr, io.EOF) || errors.Is(serverErr, io.ErrClosedPipe) || errors.Is(serverErr, context.Canceled), | |
| 136 | - | "expected a closed carriage, got %v", serverErr) | |
| 309 | + | request, err := probe.request() | |
| 310 | + | require.NoError(t, err) | |
| 311 | + | require.NoError(t, request.Validate()) | |
| 312 | + | require.Equal(t, ports.BrokerStreamObservation, request.Purpose) | |
| 313 | + | require.Equal(t, brokerLocalTestEpoch, request.Epoch) | |
| 314 | + | require.True(t, request.Local) | |
| 315 | + | require.Zero(t, request.Admission) | |
| 316 | + | require.Empty(t, request.Name) | |
| 317 | + | require.Empty(t, request.Endpoint) | |
| 318 | + | require.Equal(t, domain.RemoteRegistration{}, request.Registration) | |
| 319 | + | require.Empty(t, request.Env) | |
| 320 | + | require.Equal(t, protocol.ExactSessionTarget{}, request.Target) | |
| 321 | + | require.Equal(t, binding.Policy, request.Policy) | |
| 322 | + | require.NotZero(t, request.Connection) | |
| 323 | + | require.NotZero(t, request.Stream) | |
| 324 | + | // Observation must never start the target: the request carries the | |
| 325 | + | // existing-only authorization, and a spawn-capable one is refused rather | |
| 326 | + | // than silently narrowed. | |
| 327 | + | require.Equal(t, ports.BrokerDaemonExistingOnly, request.StartMode) | |
| 328 | + | startable := request | |
| 329 | + | startable.StartMode = ports.BrokerDaemonStartIfNeeded | |
| 330 | + | require.Error(t, startable.Validate()) | |
| 137 | 331 | } | |
| 138 | 332 | ||
| 139 | 333 | // TestBrokerLocalProbeFailsClosed proves an unavailable carriage yields zero | |
| 140 | - | // observed identity and an explicit availability, never an invented one, and | |
| 141 | - | // that the probe only dials the provisioned local address: it has no spawn path | |
| 142 | - | // and never starts a stopped daemon. | |
| 334 | + | // observed identity, no inventory, and an explicit availability, never an | |
| 335 | + | // invented one, and that the probe only dials the provisioned local address: it | |
| 336 | + | // has no spawn path and never starts a stopped daemon. | |
| 143 | 337 | func TestBrokerLocalProbeFailsClosed(t *testing.T) { | |
| 144 | 338 | _, binding := testLocalSandboxConfig(t) | |
| 145 | 339 | ||
| 146 | 340 | var addresses []string | |
| 147 | - | dial := func(_ context.Context, address string) (daemonmux.RawFramedTransport, error) { | |
| 148 | - | addresses = append(addresses, address) | |
| 341 | + | dial := func(_ context.Context, target ports.BrokerDialTarget) (daemonmux.RawFramedTransport, error) { | |
| 342 | + | require.Equal(t, ports.BrokerDaemonExistingOnly, target.StartMode, "an observation dial never authorizes starting the daemon") | |
| 343 | + | addresses = append(addresses, target.Address) | |
| 149 | 344 | return nil, errors.New("no carriage") | |
| 150 | 345 | } | |
| 151 | - | probe, err := newLocalRouteProbe(binding, dial, daemonmux.DefaultMuxCeilings()) | |
| 346 | + | probe, err := newLocalRouteProbe(binding, brokerLocalTestEpoch, dial, daemonmux.DefaultMuxCeilings()) | |
| 152 | 347 | require.NoError(t, err) | |
| 153 | 348 | ||
| 154 | 349 | observation, err := probe.ProbeLocal(context.Background()) | |
| @@ -157,35 +352,149 @@ func TestBrokerLocalProbeFailsClosed(t *testing.T) { | |||
| 157 | 352 | require.Zero(t, observation.Identity) | |
| 158 | 353 | require.True(t, observation.Incarnation.IsZero()) | |
| 159 | 354 | require.Zero(t, observation.ProtocolVersion) | |
| 355 | + | require.False(t, observation.InventoryKnown) | |
| 160 | 356 | require.Nil(t, observation.Sessions) | |
| 161 | 357 | require.Equal(t, []string{binding.Route.Address()}, addresses) | |
| 162 | 358 | ||
| 163 | 359 | t.Run("a nil carriage is refused", func(t *testing.T) { | |
| 164 | - | probe, err := newLocalRouteProbe(binding, func(context.Context, string) (daemonmux.RawFramedTransport, error) { | |
| 360 | + | probe, err := newLocalRouteProbe(binding, brokerLocalTestEpoch, func(context.Context, ports.BrokerDialTarget) (daemonmux.RawFramedTransport, error) { | |
| 165 | 361 | return nil, nil | |
| 166 | 362 | }, daemonmux.DefaultMuxCeilings()) | |
| 167 | 363 | require.NoError(t, err) | |
| 168 | 364 | observation, err := probe.ProbeLocal(context.Background()) | |
| 169 | 365 | require.Error(t, err) | |
| 170 | 366 | require.Zero(t, observation.Identity) | |
| 367 | + | require.Equal(t, domain.RemoteAvailabilityUnreachable, observation.Availability) | |
| 171 | 368 | }) | |
| 172 | 369 | ||
| 173 | 370 | t.Run("construction refuses a missing dialer", func(t *testing.T) { | |
| 174 | - | _, err := newLocalRouteProbe(binding, nil, daemonmux.DefaultMuxCeilings()) | |
| 371 | + | _, err := newLocalRouteProbe(binding, brokerLocalTestEpoch, nil, daemonmux.DefaultMuxCeilings()) | |
| 372 | + | require.Error(t, err) | |
| 373 | + | }) | |
| 374 | + | ||
| 375 | + | t.Run("construction refuses a zero epoch", func(t *testing.T) { | |
| 376 | + | _, err := newLocalRouteProbe(binding, 0, func(context.Context, ports.BrokerDialTarget) (daemonmux.RawFramedTransport, error) { | |
| 377 | + | return nil, nil | |
| 378 | + | }, daemonmux.DefaultMuxCeilings()) | |
| 175 | 379 | require.Error(t, err) | |
| 176 | 380 | }) | |
| 177 | 381 | } | |
| 178 | 382 | ||
| 383 | + | // TestBrokerLocalProbeRejectsInvalidObservation proves every malformed or | |
| 384 | + | // mistyped answer fails closed: an uncorrelated result, an explicit failure, a | |
| 385 | + | // result that is not a result at all, malformed JSON, and a catalogue outside | |
| 386 | + | // the exact current schema all yield an error and an explicit unreachable | |
| 387 | + | // observation with zero identity, zero version, and no inventory. A probe never | |
| 388 | + | // publishes inventory it could not validate. | |
| 389 | + | func TestBrokerLocalProbeRejectsInvalidObservation(t *testing.T) { | |
| 390 | + | _, binding := testLocalSandboxConfig(t) | |
| 391 | + | serverBinding := testServerBinding(t, binding) | |
| 392 | + | session := testLocalUpSession(domain.SessionLifecycleID{1}, "work") | |
| 393 | + | ||
| 394 | + | // stalled is released when the subtest ends, so a responder that holds an | |
| 395 | + | // answer open never leaks its goroutine past the test that needed it. | |
| 396 | + | stalled := make(chan struct{}) | |
| 397 | + | t.Cleanup(func() { close(stalled) }) | |
| 398 | + | ||
| 399 | + | valid := testLocalCatalogue(t, session) | |
| 400 | + | badSchema, err := json.Marshal(catalogue.RemoteCatalog{ | |
| 401 | + | ProtocolVersion: protocol.Version, | |
| 402 | + | SchemaVersion: catalogue.RemoteCatalogSchemaVersion + 1, | |
| 403 | + | Sessions: []catalogue.RemoteCatalogSession{session}, | |
| 404 | + | }) | |
| 405 | + | require.NoError(t, err) | |
| 406 | + | ||
| 407 | + | tests := []struct { | |
| 408 | + | name string | |
| 409 | + | respond func(protocol.ClientMessage) protocol.ServerMessage | |
| 410 | + | timeout time.Duration | |
| 411 | + | }{ | |
| 412 | + | { | |
| 413 | + | name: "uncorrelated result", | |
| 414 | + | respond: func(message protocol.ClientMessage) protocol.ServerMessage { | |
| 415 | + | request, _ := message.(protocol.CommandRequest) | |
| 416 | + | return protocol.CommandResult{RequestID: request.RequestID + 1, Outcome: protocol.CommandSucceeded, Output: valid} | |
| 417 | + | }, | |
| 418 | + | }, | |
| 419 | + | { | |
| 420 | + | name: "explicit failure", | |
| 421 | + | respond: func(protocol.ClientMessage) protocol.ServerMessage { | |
| 422 | + | return protocol.CommandResult{RequestID: 0, Outcome: protocol.CommandFailed, Code: protocol.ErrInternal, Text: "catalogue unavailable"} | |
| 423 | + | }, | |
| 424 | + | }, | |
| 425 | + | { | |
| 426 | + | name: "wrong message type", | |
| 427 | + | respond: func(protocol.ClientMessage) protocol.ServerMessage { return protocol.Pong{} }, | |
| 428 | + | }, | |
| 429 | + | { | |
| 430 | + | name: "malformed json", | |
| 431 | + | respond: func(message protocol.ClientMessage) protocol.ServerMessage { | |
| 432 | + | request, _ := message.(protocol.CommandRequest) | |
| 433 | + | return protocol.CommandResult{RequestID: request.RequestID, Outcome: protocol.CommandSucceeded, Output: "{not json"} | |
| 434 | + | }, | |
| 435 | + | }, | |
| 436 | + | { | |
| 437 | + | name: "schema mismatch", | |
| 438 | + | respond: func(message protocol.ClientMessage) protocol.ServerMessage { | |
| 439 | + | request, _ := message.(protocol.CommandRequest) | |
| 440 | + | return protocol.CommandResult{RequestID: request.RequestID, Outcome: protocol.CommandSucceeded, Output: string(badSchema)} | |
| 441 | + | }, | |
| 442 | + | }, | |
| 443 | + | { | |
| 444 | + | name: "peer closes without answering", | |
| 445 | + | respond: func(protocol.ClientMessage) protocol.ServerMessage { return nil }, | |
| 446 | + | }, | |
| 447 | + | { | |
| 448 | + | name: "daemon stalls and the attempt deadline expires", | |
| 449 | + | respond: func(protocol.ClientMessage) protocol.ServerMessage { | |
| 450 | + | <-stalled | |
| 451 | + | return nil | |
| 452 | + | }, | |
| 453 | + | timeout: 200 * time.Millisecond, | |
| 454 | + | }, | |
| 455 | + | } | |
| 456 | + | for _, tt := range tests { | |
| 457 | + | t.Run(tt.name, func(t *testing.T) { | |
| 458 | + | daemon := newScriptedLocalDaemon(t, serverBinding, tt.respond) | |
| 459 | + | probe, err := newLocalRouteProbe(binding, brokerLocalTestEpoch, daemon.dial(), daemonmux.DefaultMuxCeilings()) | |
| 460 | + | require.NoError(t, err) | |
| 461 | + | ||
| 462 | + | wait := brokerTestWait | |
| 463 | + | if tt.timeout > 0 { | |
| 464 | + | wait = tt.timeout | |
| 465 | + | } | |
| 466 | + | ctx, cancel := context.WithTimeout(context.Background(), wait) | |
| 467 | + | defer cancel() | |
| 468 | + | observation, err := probe.ProbeLocal(ctx) | |
| 469 | + | require.Error(t, err) | |
| 470 | + | require.Equal(t, domain.RemoteAvailabilityUnreachable, observation.Availability) | |
| 471 | + | require.Zero(t, observation.Identity) | |
| 472 | + | require.True(t, observation.Incarnation.IsZero()) | |
| 473 | + | require.Zero(t, observation.ProtocolVersion) | |
| 474 | + | require.False(t, observation.InventoryKnown) | |
| 475 | + | require.Empty(t, observation.Sessions) | |
| 476 | + | }) | |
| 477 | + | } | |
| 478 | + | } | |
| 479 | + | ||
| 179 | 480 | // TestBrokerLocalObservationComposition proves the composition derives the | |
| 180 | 481 | // local producer's authority from the validated configuration and refuses a | |
| 181 | 482 | // configuration that provisions no local binding. | |
| 182 | 483 | func TestBrokerLocalObservationComposition(t *testing.T) { | |
| 183 | 484 | config, binding := testLocalSandboxConfig(t) | |
| 184 | - | observation, err := brokerLocalObservation(config, nil) | |
| 485 | + | connector := unreachableCarriageConnector(t) | |
| 486 | + | observation, err := brokerLocalObservation(config, brokerLocalTestEpoch, connector) | |
| 185 | 487 | require.NoError(t, err) | |
| 186 | 488 | require.Equal(t, binding.DisplayOrigin, observation.DisplayOrigin) | |
| 187 | 489 | require.Equal(t, binding.Policy, observation.Policy) | |
| 188 | 490 | require.NotNil(t, observation.Probe) | |
| 491 | + | // Shared transport: the probe composes the exact connector the pool already | |
| 492 | + | // owns instead of dialing its own, so one broker process holds one transport | |
| 493 | + | // owner per route. | |
| 494 | + | probe, ok := observation.Probe.(*localRouteProbe) | |
| 495 | + | require.True(t, ok) | |
| 496 | + | require.Same(t, connector, probe.connector) | |
| 497 | + | require.Equal(t, brokerLocalTestEpoch, probe.epoch) | |
| 189 | 498 | ||
| 190 | 499 | t.Run("no local binding provisions no producer", func(t *testing.T) { | |
| 191 | 500 | root := filepath.Join(shortTempDir(t, "vevl"), "sandbox") | |
| @@ -195,16 +504,24 @@ func TestBrokerLocalObservationComposition(t *testing.T) { | |||
| 195 | 504 | require.NoError(t, err) | |
| 196 | 505 | config, err := brokerconfig.Load(layout) | |
| 197 | 506 | require.NoError(t, err) | |
| 198 | - | _, err = brokerLocalObservation(config, nil) | |
| 507 | + | _, err = brokerLocalObservation(config, brokerLocalTestEpoch, connector) | |
| 199 | 508 | require.Error(t, err) | |
| 200 | 509 | }) | |
| 201 | 510 | } | |
| 202 | 511 | ||
| 512 | + | // TestOfflineRegistryConfigSelectsLocalObservationOnlyWhenProvisioned proves the | |
| 513 | + | // composition's producer selection is decided by the provisioned configuration | |
| 514 | + | // alone, never by the presence of an identity: a provisioned local binding | |
| 515 | + | // always observes and runs with mutable membership (so the broker owns host | |
| 516 | + | // membership for its whole run, including a zero-host membership), while a | |
| 517 | + | // configuration with no local binding is an explicitly configured fixture that | |
| 518 | + | // stays observation-disabled. | |
| 203 | 519 | func TestOfflineRegistryConfigSelectsLocalObservationOnlyWhenProvisioned(t *testing.T) { | |
| 204 | 520 | localConfig, binding := testLocalSandboxConfig(t) | |
| 205 | - | selected, err := offlineRegistryConfig(localConfig, nil) | |
| 521 | + | selected, err := offlineRegistryConfig(localConfig, brokerLocalTestEpoch, unreachableCarriageConnector(t)) | |
| 206 | 522 | require.NoError(t, err) | |
| 207 | 523 | require.False(t, selected.ObservationDisabled) | |
| 524 | + | require.Equal(t, broker.MembershipMutable, selected.MembershipMode, "a provisioned local binding always owns mutable membership") | |
| 208 | 525 | require.NotNil(t, selected.Local) | |
| 209 | 526 | require.Equal(t, binding.DisplayOrigin, selected.Local.DisplayOrigin) | |
| 210 | 527 | require.Equal(t, binding.Policy, selected.Local.Policy) | |
| @@ -216,41 +533,249 @@ func TestOfflineRegistryConfigSelectsLocalObservationOnlyWhenProvisioned(t *test | |||
| 216 | 533 | require.NoError(t, err) | |
| 217 | 534 | remoteOnly, err := brokerconfig.Load(layout) | |
| 218 | 535 | require.NoError(t, err) | |
| 219 | - | disabled, err := offlineRegistryConfig(remoteOnly, nil) | |
| 536 | + | disabled, err := offlineRegistryConfig(remoteOnly, brokerLocalTestEpoch, unreachableCarriageConnector(t)) | |
| 220 | 537 | require.NoError(t, err) | |
| 221 | - | require.True(t, disabled.ObservationDisabled) | |
| 538 | + | require.True(t, disabled.ObservationDisabled, "a fixture with no local binding stays explicitly disabled") | |
| 222 | 539 | require.Nil(t, disabled.Local) | |
| 223 | 540 | } | |
| 224 | 541 | ||
| 542 | + | // productionLocalConfig builds one production-shaped broker configuration whose | |
| 543 | + | // local binding carries an empty identity, exactly as the broker composes it | |
| 544 | + | // before the daemon's first start. The identity is supplied only by the loader. | |
| 545 | + | func productionLocalConfig(t *testing.T) (*brokerconfig.Config, brokerconfig.LocalBinding) { | |
| 546 | + | t.Helper() | |
| 547 | + | t.Setenv("XDG_RUNTIME_DIR", t.TempDir()) | |
| 548 | + | t.Setenv("XDG_STATE_HOME", t.TempDir()) | |
| 549 | + | t.Setenv("XDG_CONFIG_HOME", t.TempDir()) | |
| 550 | + | layout := productionBrokerLayout() | |
| 551 | + | path := productionBrokerConfigPath() | |
| 552 | + | require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o700)) | |
| 553 | + | raw, err := json.Marshal(sandboxEmptyDocument("3s")) | |
| 554 | + | require.NoError(t, err) | |
| 555 | + | require.NoError(t, os.WriteFile(path, raw, 0o600)) | |
| 556 | + | config, err := brokerconfig.LoadProduction(layout, path, "", localDaemonPolicy(), daemonmux.SocketPath(ipc.SocketDir())) | |
| 557 | + | require.NoError(t, err) | |
| 558 | + | binding, ok := config.LocalBinding() | |
| 559 | + | require.True(t, ok) | |
| 560 | + | require.Empty(t, binding.Identity, "production local authority starts with no identity") | |
| 561 | + | return config, binding | |
| 562 | + | } | |
| 563 | + | ||
| 564 | + | // TestBrokerLocalObservationWithoutIdentityPublishesLocalUnknown proves the | |
| 565 | + | // composition observes the local daemon even before an identity exists: the | |
| 566 | + | // published snapshot carries exactly one local entry with the explicit unknown | |
| 567 | + | // availability and zero observed identity, at a nonzero epoch and revision. A | |
| 568 | + | // broker may therefore precede its daemon and still publish a valid catalogue. | |
| 569 | + | func TestBrokerLocalObservationWithoutIdentityPublishesLocalUnknown(t *testing.T) { | |
| 570 | + | config, _ := productionLocalConfig(t) | |
| 571 | + | // The loader re-reads an absent identity on every attempt, so the probe is | |
| 572 | + | // dynamic: it never dials and never spawns while no identity exists. | |
| 573 | + | loader := localIdentityLoader(func() (ports.BrokerDaemonIdentity, error) { return "", nil }) | |
| 574 | + | var dials atomic.Int32 | |
| 575 | + | connector := testLocalConnector(t, func(context.Context, ports.BrokerDialTarget) (daemonmux.RawFramedTransport, error) { | |
| 576 | + | dials.Add(1) | |
| 577 | + | return nil, errors.New("an absent identity must never dial") | |
| 578 | + | }) | |
| 579 | + | local, err := brokerLocalObservation(config, brokerLocalTestEpoch, connector, loader) | |
| 580 | + | require.NoError(t, err) | |
| 581 | + | ||
| 582 | + | stateDir := filepath.Join(shortTempDir(t, "vevl"), "state") | |
| 583 | + | store, err := brokerstore.Open(brokerstore.Options{Dir: stateDir}) | |
| 584 | + | require.NoError(t, err) | |
| 585 | + | t.Cleanup(func() { require.NoError(t, store.Close()) }) | |
| 586 | + | registry, err := broker.NewRegistryWithConfig(brokerLocalTestEpoch, store, nil, clock.New(), discardLog(), broker.RegistryConfig{Local: local, MembershipMode: broker.MembershipMutable}) | |
| 587 | + | require.NoError(t, err) | |
| 588 | + | startLocalRegistry(t, registry) | |
| 589 | + | ||
| 590 | + | observation := waitLocalEntry(t, registry, func(entry ports.BrokerDaemonObservation) bool { return !entry.Checking }) | |
| 591 | + | require.True(t, observation.Local) | |
| 592 | + | require.Equal(t, domain.RemoteAvailabilityUnknown, observation.Availability, "an unobserved local daemon is explicitly unknown, never invented") | |
| 593 | + | require.Empty(t, observation.Identity) | |
| 594 | + | require.True(t, observation.Incarnation.IsZero()) | |
| 595 | + | require.Zero(t, observation.ProtocolVersion) | |
| 596 | + | require.False(t, observation.InventoryKnown) | |
| 597 | + | require.Empty(t, observation.Sessions) | |
| 598 | + | ||
| 599 | + | snapshot := registry.Snapshot() | |
| 600 | + | require.NotZero(t, snapshot.Epoch, "a published snapshot always carries an epoch") | |
| 601 | + | require.NotZero(t, snapshot.Revision, "a published snapshot always carries a nonzero revision") | |
| 602 | + | require.Len(t, snapshot.Daemons, 1, "exactly one local entry is published") | |
| 603 | + | require.True(t, snapshot.Daemons[0].Local) | |
| 604 | + | require.Equal(t, int32(0), dials.Load(), "an absent identity never dials and never spawns") | |
| 605 | + | ||
| 606 | + | // Never durable: the store is asked for its durable snapshot and the local | |
| 607 | + | // entry is absent from it. | |
| 608 | + | durable, err := store.Load() | |
| 609 | + | require.NoError(t, err) | |
| 610 | + | require.Empty(t, durable.Daemons, "a local observation is never durable") | |
| 611 | + | } | |
| 612 | + | ||
| 613 | + | // TestBrokerLocalObservationPublishesReachableCatalogueWhenIdentityAppears | |
| 614 | + | // proves the same broker starts observing without a restart once the daemon | |
| 615 | + | // publishes its identity: the loader is re-read on every attempt, so the next | |
| 616 | + | // observation authenticates the local carriage and publishes the reachable | |
| 617 | + | // catalogue. | |
| 618 | + | func TestBrokerLocalObservationPublishesReachableCatalogueWhenIdentityAppears(t *testing.T) { | |
| 619 | + | config, binding := productionLocalConfig(t) | |
| 620 | + | // The daemon publishes its own identity; the composition's binding starts | |
| 621 | + | // empty and is filled by the loader the moment the daemon first starts. | |
| 622 | + | daemonIdentity := ports.BrokerDaemonIdentity(brokerLocalTestIdentity) | |
| 623 | + | serverBinding, err := daemonmux.NewServerBinding(daemonIdentity, ports.BrokerDaemonIncarnation{7, 1}, binding.Policy) | |
| 624 | + | require.NoError(t, err) | |
| 625 | + | session := testLocalUpSession(domain.SessionLifecycleID{5}, "appeared") | |
| 626 | + | daemon := newScriptedLocalDaemon(t, serverBinding, func(message protocol.ClientMessage) protocol.ServerMessage { | |
| 627 | + | request, ok := message.(protocol.CommandRequest) | |
| 628 | + | if !ok { | |
| 629 | + | return nil | |
| 630 | + | } | |
| 631 | + | return protocol.CommandResult{RequestID: request.RequestID, Outcome: protocol.CommandSucceeded, Output: testLocalCatalogue(t, session)} | |
| 632 | + | }) | |
| 633 | + | ||
| 634 | + | // The identity is absent at first and appears before the next attempt; the | |
| 635 | + | // scripted daemon answers under that identity. | |
| 636 | + | var identity atomic.Value | |
| 637 | + | identity.Store(ports.BrokerDaemonIdentity("")) | |
| 638 | + | connector := testLocalConnector(t, daemon.dial()) | |
| 639 | + | local, err := brokerLocalObservation(config, brokerLocalTestEpoch, connector, func() (ports.BrokerDaemonIdentity, error) { | |
| 640 | + | return identity.Load().(ports.BrokerDaemonIdentity), nil | |
| 641 | + | }) | |
| 642 | + | require.NoError(t, err) | |
| 643 | + | ||
| 644 | + | stateDir := filepath.Join(shortTempDir(t, "vevl"), "state") | |
| 645 | + | store, err := brokerstore.Open(brokerstore.Options{Dir: stateDir}) | |
| 646 | + | require.NoError(t, err) | |
| 647 | + | t.Cleanup(func() { require.NoError(t, store.Close()) }) | |
| 648 | + | registry, err := broker.NewRegistryWithConfig(brokerLocalTestEpoch, store, nil, clock.New(), discardLog(), broker.RegistryConfig{Local: local, MembershipMode: broker.MembershipMutable}) | |
| 649 | + | require.NoError(t, err) | |
| 650 | + | startLocalRegistry(t, registry) | |
| 651 | + | ||
| 652 | + | waitLocalEntry(t, registry, func(entry ports.BrokerDaemonObservation) bool { return !entry.Checking }) | |
| 653 | + | require.Equal(t, int32(0), daemon.dials.Load(), "no dial happens while no identity exists") | |
| 654 | + | ||
| 655 | + | identity.Store(daemonIdentity) | |
| 656 | + | observed := waitLocalEntry(t, registry, func(entry ports.BrokerDaemonObservation) bool { | |
| 657 | + | return entry.Availability == domain.RemoteAvailabilityReachable | |
| 658 | + | }) | |
| 659 | + | require.Equal(t, daemonIdentity, observed.Identity) | |
| 660 | + | require.Equal(t, serverBinding.Incarnation(), observed.Incarnation) | |
| 661 | + | require.Equal(t, protocol.Version, observed.ProtocolVersion) | |
| 662 | + | require.True(t, observed.InventoryKnown) | |
| 663 | + | require.Equal(t, []catalogue.RemoteCatalogSession{session}, observed.Sessions) | |
| 664 | + | require.Positive(t, daemon.dials.Load()) | |
| 665 | + | ||
| 666 | + | // The published catalogue resolves the exact attach target the client | |
| 667 | + | // composition uses, so the appearing daemon is immediately usable. | |
| 668 | + | _, ok := brokerObservationExactTarget(observed, "appeared") | |
| 669 | + | require.True(t, ok) | |
| 670 | + | } | |
| 671 | + | ||
| 672 | + | // TestBrokerLocalObservationNeverSpawnsDaemon proves the local observation is | |
| 673 | + | // spawn-free: every dial carries the existing-only authorization, so observing | |
| 674 | + | // a stopped daemon can never start one, and no dial happens without an identity. | |
| 675 | + | func TestBrokerLocalObservationNeverSpawnsDaemon(t *testing.T) { | |
| 676 | + | _, binding := testLocalSandboxConfig(t) | |
| 677 | + | var modes []ports.BrokerDaemonStartMode | |
| 678 | + | var mu sync.Mutex | |
| 679 | + | probe, err := newDynamicLocalRouteProbe(binding.Route, binding.Policy, brokerLocalTestEpoch, testEndpointConnectorFunc(func(_ context.Context, target ports.BrokerDialTarget) (ports.BrokerPhysicalConnection, error) { | |
| 680 | + | mu.Lock() | |
| 681 | + | modes = append(modes, target.StartMode) | |
| 682 | + | mu.Unlock() | |
| 683 | + | return nil, errors.New("socket absent") | |
| 684 | + | }), func() (ports.BrokerDaemonIdentity, error) { return binding.Identity, nil }) | |
| 685 | + | require.NoError(t, err) | |
| 686 | + | ||
| 687 | + | _, err = probe.ProbeLocal(context.Background()) | |
| 688 | + | require.Error(t, err) | |
| 689 | + | mu.Lock() | |
| 690 | + | require.Equal(t, []ports.BrokerDaemonStartMode{ports.BrokerDaemonExistingOnly}, modes, "an observation never authorizes starting the daemon") | |
| 691 | + | mu.Unlock() | |
| 692 | + | } | |
| 693 | + | ||
| 694 | + | // TestOfflineRegistryConfigExplicitDisabledFixtureStaysDisabled proves an | |
| 695 | + | // explicitly configured fixture that provisions no local binding remains a | |
| 696 | + | // read-only snapshot owner: it issues no probes, arms no timers, and never | |
| 697 | + | // reconciles, so its disabled mode is never inferred from an identity. | |
| 698 | + | func TestOfflineRegistryConfigExplicitDisabledFixtureStaysDisabled(t *testing.T) { | |
| 699 | + | root := filepath.Join(shortTempDir(t, "vevl"), "sandbox") | |
| 700 | + | require.NoError(t, os.MkdirAll(root, 0o700)) | |
| 701 | + | writeSandboxConfig(t, root, sandboxEmptyDocument("3s")) | |
| 702 | + | layout, err := brokerconfig.ResolveLayout(root, nil) | |
| 703 | + | require.NoError(t, err) | |
| 704 | + | config, err := brokerconfig.Load(layout) | |
| 705 | + | require.NoError(t, err) | |
| 706 | + | ||
| 707 | + | selected, err := offlineRegistryConfig(config, brokerLocalTestEpoch, unreachableCarriageConnector(t)) | |
| 708 | + | require.NoError(t, err) | |
| 709 | + | require.True(t, selected.ObservationDisabled) | |
| 710 | + | require.Nil(t, selected.Local) | |
| 711 | + | ||
| 712 | + | stateDir := filepath.Join(shortTempDir(t, "vevl"), "state") | |
| 713 | + | store, err := brokerstore.Open(brokerstore.Options{Dir: stateDir}) | |
| 714 | + | require.NoError(t, err) | |
| 715 | + | t.Cleanup(func() { require.NoError(t, store.Close()) }) | |
| 716 | + | registry, err := broker.NewRegistryWithConfig(brokerLocalTestEpoch, store, nil, clock.New(), discardLog(), selected) | |
| 717 | + | require.NoError(t, err) | |
| 718 | + | startLocalRegistry(t, registry) | |
| 719 | + | ||
| 720 | + | // A disabled registry publishes its restored durable state and then stays | |
| 721 | + | // read-only: no probe, no reconcile, and a zero-host mutation is refused. | |
| 722 | + | require.Eventually(t, func() bool { return registry.Snapshot().Epoch != 0 }, brokerTestWait, time.Millisecond) | |
| 723 | + | _, err = registry.AddHost(context.Background(), "user@example.com", localDaemonPolicy()) | |
| 724 | + | require.ErrorIs(t, err, ports.ErrBrokerMembershipImmutable, "an explicitly disabled fixture owns no mutable membership") | |
| 725 | + | } | |
| 726 | + | ||
| 727 | + | // TestProductionRegistryOwnsMutableMembershipWithZeroHosts proves the production | |
| 728 | + | // composition owns host membership for its whole run even when it starts with | |
| 729 | + | // no configured host at all: the broker may precede every registration, so a | |
| 730 | + | // first host can be added and removed through the same authority the zero-host | |
| 731 | + | // run already published. | |
| 732 | + | func TestProductionRegistryOwnsMutableMembershipWithZeroHosts(t *testing.T) { | |
| 733 | + | config, _ := productionLocalConfig(t) | |
| 734 | + | local, err := brokerLocalObservation(config, brokerLocalTestEpoch, unreachableCarriageConnector(t), func() (ports.BrokerDaemonIdentity, error) { return "", nil }) | |
| 735 | + | require.NoError(t, err) | |
| 736 | + | selected, err := offlineRegistryConfig(config, brokerLocalTestEpoch, unreachableCarriageConnector(t)) | |
| 737 | + | require.NoError(t, err) | |
| 738 | + | require.Equal(t, broker.MembershipMutable, selected.MembershipMode) | |
| 739 | + | ||
| 740 | + | stateDir := filepath.Join(shortTempDir(t, "vevl"), "state") | |
| 741 | + | store, err := brokerstore.Open(brokerstore.Options{Dir: stateDir}) | |
| 742 | + | require.NoError(t, err) | |
| 743 | + | t.Cleanup(func() { require.NoError(t, store.Close()) }) | |
| 744 | + | // The production remote probe is required exactly when membership is added, | |
| 745 | + | // so this fixture supplies one that never dials: the test asserts membership | |
| 746 | + | // authority, not observation. | |
| 747 | + | registry, err := broker.NewRegistryWithConfig(brokerLocalTestEpoch, store, unreachableHostProbe(t), clock.New(), discardLog(), broker.RegistryConfig{Local: local, MembershipMode: broker.MembershipMutable}) | |
| 748 | + | require.NoError(t, err) | |
| 749 | + | startLocalRegistry(t, registry) | |
| 750 | + | ||
| 751 | + | require.Empty(t, registry.Snapshot().Daemons[0].Registration, "the local entry carries no configured registration") | |
| 752 | + | registration, err := registry.AddHost(context.Background(), "user@example.com", remoteBrokerPolicy("quic")) | |
| 753 | + | require.NoError(t, err, "a zero-host production run must admit a first host") | |
| 754 | + | require.Equal(t, "user@example.com", registration.Endpoint) | |
| 755 | + | ||
| 756 | + | removed, err := registry.RemoveHost(context.Background(), registration) | |
| 757 | + | require.NoError(t, err) | |
| 758 | + | require.True(t, removed) | |
| 759 | + | } | |
| 760 | + | ||
| 225 | 761 | // TestBrokerLocalObservationRegistryPublishesLocalFirst proves the composition | |
| 226 | 762 | // end to end: the registry observes the local daemon through the broker-owned | |
| 227 | - | // probe and publishes it at index zero, and the durable store persists no local | |
| 228 | - | // entry (the local observation is never durable). | |
| 763 | + | // probe and publishes it at index zero with the observed inventory, and the | |
| 764 | + | // durable store persists no local entry (the local observation is never | |
| 765 | + | // durable). | |
| 229 | 766 | func TestBrokerLocalObservationRegistryPublishesLocalFirst(t *testing.T) { | |
| 230 | - | _, binding := testLocalSandboxConfig(t) | |
| 767 | + | config, binding := testLocalSandboxConfig(t) | |
| 231 | 768 | serverBinding := testServerBinding(t, binding) | |
| 769 | + | session := testLocalUpSession(domain.SessionLifecycleID{9}, "registry") | |
| 232 | 770 | ||
| 233 | - | var dials atomic.Int32 | |
| 234 | - | dial := func(ctx context.Context, _ string) (daemonmux.RawFramedTransport, error) { | |
| 235 | - | dials.Add(1) | |
| 236 | - | clientConn, serverConn := net.Pipe() | |
| 237 | - | go func() { | |
| 238 | - | defer serverConn.Close() | |
| 239 | - | bridge, err := daemonmux.NewPreambleCarrier(rawFramedTransport(serverConn)) | |
| 240 | - | if err != nil { | |
| 241 | - | return | |
| 242 | - | } | |
| 243 | - | if _, err := daemonmux.RunServerHandshake(ctx, bridge, serverBinding, daemonmux.DefaultMuxCeilings()); err != nil { | |
| 244 | - | return | |
| 245 | - | } | |
| 246 | - | // Hold the carriage until the probe closes it or the attempt is | |
| 247 | - | // retired, so the daemon side owns no logical stream either. | |
| 248 | - | <-ctx.Done() | |
| 249 | - | _ = bridge.Close() | |
| 250 | - | }() | |
| 251 | - | return rawFramedTransport(clientConn), nil | |
| 252 | - | } | |
| 253 | - | probe, err := newLocalRouteProbe(binding, dial, daemonmux.DefaultMuxCeilings()) | |
| 771 | + | daemon := newScriptedLocalDaemon(t, serverBinding, func(message protocol.ClientMessage) protocol.ServerMessage { | |
| 772 | + | request, ok := message.(protocol.CommandRequest) | |
| 773 | + | if !ok { | |
| 774 | + | return nil | |
| 775 | + | } | |
| 776 | + | return protocol.CommandResult{RequestID: request.RequestID, Outcome: protocol.CommandSucceeded, Output: testLocalCatalogue(t, session)} | |
| 777 | + | }) | |
| 778 | + | local, err := brokerLocalObservation(config, brokerLocalTestEpoch, testLocalConnector(t, daemon.dial())) | |
| 254 | 779 | require.NoError(t, err) | |
| 255 | 780 | ||
| 256 | 781 | stateDir := filepath.Join(shortTempDir(t, "vevs"), "state") | |
| @@ -259,9 +784,7 @@ func TestBrokerLocalObservationRegistryPublishesLocalFirst(t *testing.T) { | |||
| 259 | 784 | require.NoError(t, err) | |
| 260 | 785 | t.Cleanup(func() { require.NoError(t, store.Close()) }) | |
| 261 | 786 | ||
| 262 | - | registry, err := broker.NewRegistryWithConfig(7, store, nil, clock.New(), slog.New(slog.NewTextHandler(io.Discard, nil)), broker.RegistryConfig{ | |
| 263 | - | Local: &broker.LocalObservation{DisplayOrigin: binding.DisplayOrigin, Policy: binding.Policy, Probe: probe}, | |
| 264 | - | }) | |
| 787 | + | registry, err := broker.NewRegistryWithConfig(brokerLocalTestEpoch, store, nil, clock.New(), slog.New(slog.NewTextHandler(io.Discard, nil)), broker.RegistryConfig{Local: local}) | |
| 265 | 788 | require.NoError(t, err) | |
| 266 | 789 | ||
| 267 | 790 | ctx, cancel := context.WithCancel(context.Background()) | |
| @@ -279,7 +802,10 @@ func TestBrokerLocalObservationRegistryPublishesLocalFirst(t *testing.T) { | |||
| 279 | 802 | require.Len(t, snapshot.Daemons, 1) | |
| 280 | 803 | require.True(t, snapshot.Daemons[0].Local) | |
| 281 | 804 | require.Equal(t, binding.Identity, snapshot.Daemons[0].Identity) | |
| 282 | - | require.Positive(t, dials.Load()) | |
| 805 | + | require.Equal(t, serverBinding.Incarnation(), snapshot.Daemons[0].Incarnation) | |
| 806 | + | require.True(t, snapshot.Daemons[0].InventoryKnown) | |
| 807 | + | require.Equal(t, []catalogue.RemoteCatalogSession{session}, snapshot.Daemons[0].Sessions) | |
| 808 | + | require.Positive(t, daemon.dials.Load()) | |
| 283 | 809 | ||
| 284 | 810 | // The durable store persists remote observations only: the local entry never | |
| 285 | 811 | // reaches disk. | |
| @@ -289,11 +815,79 @@ func TestBrokerLocalObservationRegistryPublishesLocalFirst(t *testing.T) { | |||
| 289 | 815 | }, 3*time.Second, 2*time.Millisecond) | |
| 290 | 816 | stored, err := store.Load() | |
| 291 | 817 | require.NoError(t, err) | |
| 292 | - | for _, daemon := range stored.Daemons { | |
| 293 | - | require.False(t, daemon.Local, "a local observation must never be durable") | |
| 818 | + | for _, entry := range stored.Daemons { | |
| 819 | + | require.False(t, entry.Local, "a local observation must never be durable") | |
| 294 | 820 | } | |
| 295 | 821 | } | |
| 296 | 822 | ||
| 823 | + | // TestBrokerLocalProbeDialTargetIsExistingOnly proves the broker-owned local | |
| 824 | + | // observation dial target carries the existing-only authorization: an | |
| 825 | + | // observation must never start a stopped daemon, and the resolved target is | |
| 826 | + | // where that decision is carried to the transport. | |
| 827 | + | type testEndpointConnectorFunc func(context.Context, ports.BrokerDialTarget) (ports.BrokerPhysicalConnection, error) | |
| 828 | + | ||
| 829 | + | func (f testEndpointConnectorFunc) Connect(ctx context.Context, target ports.BrokerDialTarget) (ports.BrokerPhysicalConnection, error) { | |
| 830 | + | return f(ctx, target) | |
| 831 | + | } | |
| 832 | + | ||
| 833 | + | func TestBrokerLocalProbeLoadsIdentityOnEveryAttempt(t *testing.T) { | |
| 834 | + | _, binding := testLocalSandboxConfig(t) | |
| 835 | + | var identity ports.BrokerDaemonIdentity | |
| 836 | + | var dials []ports.BrokerDialTarget | |
| 837 | + | probe, err := newDynamicLocalRouteProbe(binding.Route, binding.Policy, brokerLocalTestEpoch, testEndpointConnectorFunc(func(_ context.Context, target ports.BrokerDialTarget) (ports.BrokerPhysicalConnection, error) { | |
| 838 | + | dials = append(dials, target) | |
| 839 | + | return nil, errors.New("socket absent") | |
| 840 | + | }), func() (ports.BrokerDaemonIdentity, error) { | |
| 841 | + | return identity, nil | |
| 842 | + | }) | |
| 843 | + | require.NoError(t, err) | |
| 844 | + | ||
| 845 | + | observation, err := probe.ProbeLocal(context.Background()) | |
| 846 | + | require.NoError(t, err) | |
| 847 | + | require.Equal(t, domain.RemoteAvailabilityUnknown, observation.Availability) | |
| 848 | + | require.Empty(t, dials, "an absent identity cannot dial or spawn") | |
| 849 | + | ||
| 850 | + | identity = binding.Identity | |
| 851 | + | observation, err = probe.ProbeLocal(context.Background()) | |
| 852 | + | require.Error(t, err) | |
| 853 | + | require.Equal(t, domain.RemoteAvailabilityUnreachable, observation.Availability) | |
| 854 | + | require.Len(t, dials, 1) | |
| 855 | + | require.Equal(t, ports.BrokerExpectedIdentity{Identity: binding.Identity, Bound: true}, dials[0].ExpectedIdentity) | |
| 856 | + | require.Equal(t, ports.BrokerDaemonExistingOnly, dials[0].StartMode) | |
| 857 | + | } | |
| 858 | + | ||
| 859 | + | func TestBrokerLocalProbeIdentityCorruptionIsDiagnosticWithoutDial(t *testing.T) { | |
| 860 | + | _, binding := testLocalSandboxConfig(t) | |
| 861 | + | dialed := false | |
| 862 | + | probe, err := newDynamicLocalRouteProbe(binding.Route, binding.Policy, brokerLocalTestEpoch, testEndpointConnectorFunc(func(context.Context, ports.BrokerDialTarget) (ports.BrokerPhysicalConnection, error) { | |
| 863 | + | dialed = true | |
| 864 | + | return nil, nil | |
| 865 | + | }), func() (ports.BrokerDaemonIdentity, error) { | |
| 866 | + | return "", daemonidentity.ErrInvalid | |
| 867 | + | }) | |
| 868 | + | require.NoError(t, err) | |
| 869 | + | observation, err := probe.ProbeLocal(context.Background()) | |
| 870 | + | require.ErrorIs(t, err, daemonidentity.ErrInvalid) | |
| 871 | + | require.Equal(t, domain.RemoteAvailabilityUnreachable, observation.Availability) | |
| 872 | + | require.False(t, dialed) | |
| 873 | + | } | |
| 874 | + | ||
| 875 | + | func TestBrokerLocalProbeDialTargetIsExistingOnly(t *testing.T) { | |
| 876 | + | _, binding := testLocalSandboxConfig(t) | |
| 877 | + | var target ports.BrokerDialTarget | |
| 878 | + | probe, err := newLocalRouteProbe(binding, brokerLocalTestEpoch, func(_ context.Context, observed ports.BrokerDialTarget) (daemonmux.RawFramedTransport, error) { | |
| 879 | + | target = observed | |
| 880 | + | return nil, errors.New("unreachable") | |
| 881 | + | }, daemonmux.DefaultMuxCeilings()) | |
| 882 | + | require.NoError(t, err) | |
| 883 | + | _, err = probe.ProbeLocal(context.Background()) | |
| 884 | + | require.Error(t, err) | |
| 885 | + | require.Equal(t, ports.BrokerDaemonExistingOnly, target.StartMode, | |
| 886 | + | "an observation dial target never authorizes starting the daemon") | |
| 887 | + | require.True(t, target.Fence.Local) | |
| 888 | + | require.Equal(t, binding.Identity, target.ExpectedIdentity.Identity) | |
| 889 | + | } | |
| 890 | + | ||
| 297 | 891 | // TestBrokerLocalProbeRefusesNonLocalRoute proves the probe refuses a | |
| 298 | 892 | // hand-built binding whose route is an ssh helper route: probing must never | |
| 299 | 893 | // start a child process, so only a Unix route this process dials directly is | |
| @@ -317,7 +911,7 @@ func TestBrokerLocalProbeRefusesNonLocalRoute(t *testing.T) { | |||
| 317 | 911 | Policy: brokerTestPolicy(), | |
| 318 | 912 | Route: sshRoute, | |
| 319 | 913 | } | |
| 320 | - | _, err := newLocalRouteProbe(binding, func(context.Context, string) (daemonmux.RawFramedTransport, error) { | |
| 914 | + | _, err := newLocalRouteProbe(binding, brokerLocalTestEpoch, func(context.Context, ports.BrokerDialTarget) (daemonmux.RawFramedTransport, error) { | |
| 321 | 915 | t.Fatal("an ssh route must never be dialed by the local probe") | |
| 322 | 916 | return nil, nil | |
| 323 | 917 | }, daemonmux.DefaultMuxCeilings()) | |
| @@ -334,3 +928,47 @@ func TestBrokerLocalProbeNilReceiverFailsClosed(t *testing.T) { | |||
| 334 | 928 | require.Equal(t, domain.RemoteAvailabilityUnreachable, observation.Availability) | |
| 335 | 929 | require.NotZero(t, observation.Availability, "availability is never zero") | |
| 336 | 930 | } | |
| 931 | + | ||
| 932 | + | // startLocalRegistry runs one registry for the duration of a test. | |
| 933 | + | func startLocalRegistry(t *testing.T, registry *broker.Registry) { | |
| 934 | + | t.Helper() | |
| 935 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 936 | + | done := make(chan struct{}) | |
| 937 | + | go func() { defer close(done); registry.Run(ctx) }() | |
| 938 | + | t.Cleanup(func() { cancel(); <-done }) | |
| 939 | + | } | |
| 940 | + | ||
| 941 | + | // waitLocalEntry waits for the published local entry (always index zero) to | |
| 942 | + | // satisfy predicate and returns it. | |
| 943 | + | func waitLocalEntry(t *testing.T, registry *broker.Registry, predicate func(ports.BrokerDaemonObservation) bool) ports.BrokerDaemonObservation { | |
| 944 | + | t.Helper() | |
| 945 | + | sub := registry.Subscribe() | |
| 946 | + | defer sub.Close() | |
| 947 | + | deadline := time.After(brokerTestWait) | |
| 948 | + | for { | |
| 949 | + | snapshot := registry.Snapshot() | |
| 950 | + | if len(snapshot.Daemons) > 0 && snapshot.Daemons[0].Local && predicate(snapshot.Daemons[0]) { | |
| 951 | + | return snapshot.Daemons[0] | |
| 952 | + | } | |
| 953 | + | select { | |
| 954 | + | case <-sub.Changed(): | |
| 955 | + | case <-deadline: | |
| 956 | + | t.Fatal("timed out waiting for the published local entry") | |
| 957 | + | return ports.BrokerDaemonObservation{} | |
| 958 | + | } | |
| 959 | + | } | |
| 960 | + | } | |
| 961 | + | ||
| 962 | + | // noDialHostProbe is a ports.BrokerHostProbe that never dials: a membership test | |
| 963 | + | // supplies it only because the registry requires a remote probe to observe | |
| 964 | + | // hosts, never to own their authority. | |
| 965 | + | type noDialHostProbe struct{} | |
| 966 | + | ||
| 967 | + | func (noDialHostProbe) Probe(context.Context, domain.RemoteRegistration) (ports.BrokerDaemonObservation, error) { | |
| 968 | + | return ports.BrokerDaemonObservation{}, errors.New("membership authority must not dial") | |
| 969 | + | } | |
| 970 | + | ||
| 971 | + | func unreachableHostProbe(t *testing.T) ports.BrokerHostProbe { | |
| 972 | + | t.Helper() | |
| 973 | + | return noDialHostProbe{} | |
| 974 | + | } | |
M internal/app/broker_offline_mux.go +218 -63
| @@ -19,6 +19,7 @@ import ( | |||
| 19 | 19 | "github.com/bnema/vev/internal/adapters/ipc" | |
| 20 | 20 | "github.com/bnema/vev/internal/adapters/quic" | |
| 21 | 21 | "github.com/bnema/vev/internal/adapters/sshstdio" | |
| 22 | + | "github.com/bnema/vev/internal/ports" | |
| 22 | 23 | "github.com/bnema/vev/internal/protocol/wire" | |
| 23 | 24 | ) | |
| 24 | 25 | ||
| @@ -30,9 +31,9 @@ import ( | |||
| 30 | 31 | // QUIC bootstrap that pins a freshly minted ephemeral certificate. This file | |
| 31 | 32 | // owns both halves. | |
| 32 | 33 | // | |
| 33 | - | // The broker side is a transport-selecting daemonmux.EndpointConnector. It | |
| 34 | - | // receives only the opaque address the immutable resolver published, looks the | |
| 35 | - | // route up in the configuration it was composed with, and dials that route. It | |
| 34 | + | // The broker side is a transport-selecting daemonmux.EndpointConnector. In | |
| 35 | + | // production it rechecks remote targets against committed registry records; | |
| 36 | + | // sandbox fixtures retain their immutable configured routes and trust inputs. It | |
| 36 | 37 | // never authenticates the carriage itself: the daemonmux physical preamble | |
| 37 | 38 | // verifies the daemon identity, incarnation, and policy the resolver already | |
| 38 | 39 | // pinned. One caller-supplied setup context bounds bootstrap, pinned dial, and | |
| @@ -49,10 +50,11 @@ import ( | |||
| 49 | 50 | // server, admits exactly one authenticated carriage, and bridges it to the | |
| 50 | 51 | // same private Unix daemonmux carriage. | |
| 51 | 52 | // | |
| 52 | - | // A helper only ever bridges to the configured private Unix daemonmux | |
| 53 | - | // endpoint. It never starts a broker, an observer, or an ordinary daemon, never | |
| 54 | - | // dials the production daemon socket, and never fabricates a daemon | |
| 55 | - | // incarnation: the daemon on the other side of the carriage owns that value. | |
| 53 | + | // Each helper also accepts --production instead of --offline-root. That scope | |
| 54 | + | // derives the private carriage from this user's production runtime. Helpers | |
| 55 | + | // never start a broker or observer. Only an explicit if-needed authorization | |
| 56 | + | // permits the shared daemon starter; existing-only observation never spawns. | |
| 57 | + | // The daemon on the other side owns its identity and incarnation. | |
| 56 | 58 | // The one-time QUIC token and nonce are minted per physical connection, travel | |
| 57 | 59 | // only inside the SSH channel and the encrypted stream, and never reach an | |
| 58 | 60 | // address, a log, or an error. SSH host-key verification, noninteractive | |
| @@ -93,20 +95,43 @@ const brokerMuxQUICDialTimeout = 15 * time.Second | |||
| 93 | 95 | // setup shutdown past this delay; the direct child is still killed and reaped. | |
| 94 | 96 | const brokerMuxWaitDelay = 2 * time.Second | |
| 95 | 97 | ||
| 98 | + | // brokerDaemonStartArg is the closed helper flag that carries one daemon-start | |
| 99 | + | // authorization across the SSH process boundary. It is added by the broker to | |
| 100 | + | // the provisioned remote argv and parsed strictly by the helper; the value is | |
| 101 | + | // brokerDaemonStartArgvFlag's exact spelling, so the two sides can never drift. | |
| 102 | + | const brokerDaemonStartArg = "--daemon-start" | |
| 103 | + | ||
| 96 | 104 | // brokerMuxOptions is the parsed hidden mux helper invocation. | |
| 97 | 105 | type brokerMuxOptions struct { | |
| 106 | + | production bool | |
| 98 | 107 | offlineRoot string | |
| 108 | + | // startMode is the daemon-start authorization the broker propagated. An | |
| 109 | + | // absent flag is the safe default: the helper observes only what is already | |
| 110 | + | // running and never starts a daemon, a broker, or a recursive helper. | |
| 111 | + | startMode ports.BrokerDaemonStartMode | |
| 99 | 112 | } | |
| 100 | 113 | ||
| 101 | - | // parseBrokerMuxArgs strictly parses `<hidden> --offline-root ABS`. Unknown or | |
| 102 | - | // duplicate flags, a missing value, and positionals are refused, exactly like | |
| 103 | - | // the foreground sandbox entry point. | |
| 114 | + | // parseBrokerMuxArgs strictly parses `<hidden> (--offline-root ABS | --production) | |
| 115 | + | // [--daemon-start existing-only|if-needed]`. Unknown or duplicate flags, a | |
| 116 | + | // missing or unknown value, and positionals are refused, exactly like the | |
| 117 | + | // foreground sandbox entry point. An absent --daemon-start defaults to | |
| 118 | + | // existing-only, and an unrecognized value is never mapped onto a permissive | |
| 119 | + | // authorization. | |
| 104 | 120 | func parseBrokerMuxArgs(name string, kind cmdKind, args []string) (command, error) { | |
| 105 | - | options := brokerMuxOptions{} | |
| 106 | - | rootSet := false | |
| 121 | + | options := brokerMuxOptions{startMode: ports.BrokerDaemonExistingOnly} | |
| 122 | + | rootSet, startSet := false, false | |
| 107 | 123 | for index := 0; index < len(args); { | |
| 108 | 124 | switch args[index] { | |
| 125 | + | case "--production": | |
| 126 | + | if options.production || rootSet { | |
| 127 | + | return command{}, usagef("`%s` received duplicate or conflicting scope", name) | |
| 128 | + | } | |
| 129 | + | options.production = true | |
| 130 | + | index++ | |
| 109 | 131 | case "--offline-root": | |
| 132 | + | if options.production { | |
| 133 | + | return command{}, usagef("`%s` received conflicting scope", name) | |
| 134 | + | } | |
| 110 | 135 | if rootSet { | |
| 111 | 136 | return command{}, usagef("`%s` received duplicate --offline-root", name) | |
| 112 | 137 | } | |
| @@ -116,6 +141,20 @@ func parseBrokerMuxArgs(name string, kind cmdKind, args []string) (command, erro | |||
| 116 | 141 | options.offlineRoot = args[index+1] | |
| 117 | 142 | rootSet = true | |
| 118 | 143 | index += 2 | |
| 144 | + | case brokerDaemonStartArg: | |
| 145 | + | if startSet { | |
| 146 | + | return command{}, usagef("`%s` received duplicate %s", name, brokerDaemonStartArg) | |
| 147 | + | } | |
| 148 | + | if index+1 >= len(args) { | |
| 149 | + | return command{}, usagef("`%s` requires a value", brokerDaemonStartArg) | |
| 150 | + | } | |
| 151 | + | mode, err := parseBrokerDaemonStartArgvFlag(args[index+1]) | |
| 152 | + | if err != nil { | |
| 153 | + | return command{}, usagef("`%s`: %s", name, err) | |
| 154 | + | } | |
| 155 | + | options.startMode = mode | |
| 156 | + | startSet = true | |
| 157 | + | index += 2 | |
| 119 | 158 | default: | |
| 120 | 159 | if strings.HasPrefix(args[index], "-") { | |
| 121 | 160 | return command{}, usagef("unknown flag %q for `%s`", args[index], name) | |
| @@ -123,8 +162,8 @@ func parseBrokerMuxArgs(name string, kind cmdKind, args []string) (command, erro | |||
| 123 | 162 | return command{}, usagef("`%s` does not accept positional arguments", name) | |
| 124 | 163 | } | |
| 125 | 164 | } | |
| 126 | - | if !rootSet { | |
| 127 | - | return command{}, usagef("`%s` requires --offline-root", name) | |
| 165 | + | if !rootSet && !options.production { | |
| 166 | + | return command{}, usagef("`%s` requires --offline-root or --production", name) | |
| 128 | 167 | } | |
| 129 | 168 | return command{kind: kind, brokerMux: options}, nil | |
| 130 | 169 | } | |
| @@ -141,51 +180,134 @@ func loadMuxHelperConfig(offlineRoot string) (*brokerconfig.Config, error) { | |||
| 141 | 180 | } | |
| 142 | 181 | ||
| 143 | 182 | // brokerMuxConnector builds the transport-selecting daemonmux endpoint | |
| 144 | - | // connector over one immutable configuration. The dial function receives only | |
| 145 | - | // the opaque address the resolver published and refuses any address the | |
| 146 | - | // configuration did not produce, so a request can never select a transport or | |
| 147 | - | // an address of its own. | |
| 183 | + | // connector over one immutable configuration. The dial function receives the | |
| 184 | + | // whole resolved target the pool will authenticate against and refuses any | |
| 185 | + | // address the configuration did not produce, so a request can never select a | |
| 186 | + | // transport, an address, or a start authorization of its own. It re-checks that | |
| 187 | + | // the route's provisioned authority still matches the resolved policy and fence | |
| 188 | + | // before dialing, so the transport is only ever reached under the authority the | |
| 189 | + | // resolver published. | |
| 148 | 190 | func brokerMuxConnector(config *brokerconfig.Config, log *slog.Logger) (*daemonmux.EndpointConnector, error) { | |
| 149 | 191 | if config == nil { | |
| 150 | 192 | return nil, errors.New("vev: broker mux connector requires a configuration") | |
| 151 | 193 | } | |
| 152 | - | dial := func(ctx context.Context, address string) (daemonmux.RawFramedTransport, error) { | |
| 153 | - | route, ok := config.RouteByAddress(address) | |
| 194 | + | dial := func(ctx context.Context, target ports.BrokerDialTarget) (daemonmux.RawFramedTransport, error) { | |
| 195 | + | route, ok := config.RouteByAddress(target.Address) | |
| 154 | 196 | if !ok { | |
| 155 | 197 | return nil, fmt.Errorf("vev: broker mux: unknown route address") | |
| 156 | 198 | } | |
| 157 | - | return dialBrokerRoute(ctx, route, log) | |
| 199 | + | if err := brokerRouteAuthorityMatches(config, target); err != nil { | |
| 200 | + | return nil, err | |
| 201 | + | } | |
| 202 | + | return dialBrokerRoute(ctx, route, target, log) | |
| 158 | 203 | } | |
| 159 | 204 | return daemonmux.NewEndpointConnector(dial, daemonmux.DefaultMuxCeilings()) | |
| 160 | 205 | } | |
| 161 | 206 | ||
| 162 | - | // dialBrokerRoute dials one provisioned route as an already-authenticated raw | |
| 163 | - | // framed carriage. Authentication, identity, and policy remain the daemonmux | |
| 164 | - | // connector's concern; this function only selects the transport. | |
| 165 | - | func dialBrokerRoute(ctx context.Context, route brokerconfig.Route, log *slog.Logger) (daemonmux.RawFramedTransport, error) { | |
| 207 | + | // brokerDynamicMuxConnector keeps the configured local carriage, but remote | |
| 208 | + | // targets must still match committed registry authority at dial time. | |
| 209 | + | func brokerDynamicMuxConnector(config *brokerconfig.Config, routes *brokerRoutes, log *slog.Logger) (*daemonmux.EndpointConnector, error) { | |
| 210 | + | return daemonmux.NewEndpointConnector(func(ctx context.Context, target ports.BrokerDialTarget) (daemonmux.RawFramedTransport, error) { | |
| 211 | + | if !target.Fence.Local { | |
| 212 | + | route, err := routes.remoteRoute(ctx, target) | |
| 213 | + | if err != nil { | |
| 214 | + | return nil, err | |
| 215 | + | } | |
| 216 | + | return dialBrokerRoute(ctx, route, target, log) | |
| 217 | + | } | |
| 218 | + | if err := brokerRouteAuthorityMatches(config, target); err != nil { | |
| 219 | + | return nil, err | |
| 220 | + | } | |
| 221 | + | route, ok := config.RouteByAddress(target.Address) | |
| 222 | + | if !ok { | |
| 223 | + | return nil, errors.New("vev: unknown local route") | |
| 224 | + | } | |
| 225 | + | return dialBrokerRoute(ctx, route, target, log) | |
| 226 | + | }, daemonmux.DefaultMuxCeilings()) | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | // brokerRouteAuthorityMatches re-verifies that the route the opaque address | |
| 230 | + | // selected is still the one the resolved target's authority names: the same | |
| 231 | + | // fence, and a policy the provisioned entry accepts. The pool and the daemonmux | |
| 232 | + | // preamble already check this; re-checking here keeps the dial decision and the | |
| 233 | + | // authentication decision on one authority instead of two that could drift. | |
| 234 | + | func brokerRouteAuthorityMatches(config *brokerconfig.Config, target ports.BrokerDialTarget) error { | |
| 235 | + | if target.Fence.Local { | |
| 236 | + | binding, ok := config.LocalBinding() | |
| 237 | + | if !ok || binding.Route.Address() != target.Address || !binding.Policy.Compatible(target.Policy) { | |
| 238 | + | return ports.BrokerError{Code: ports.BrokerErrorConflictingPolicy, Cause: errors.New("vev: broker mux: local route authority does not match the resolved target")} | |
| 239 | + | } | |
| 240 | + | return nil | |
| 241 | + | } | |
| 242 | + | for _, endpoint := range config.Endpoints() { | |
| 243 | + | registration, ok := config.Registration(endpoint) | |
| 244 | + | if !ok || registration.Route.Address() != target.Address { | |
| 245 | + | continue | |
| 246 | + | } | |
| 247 | + | if registration.Registration.Equal(target.Fence.Registration) && registration.Policy.Compatible(target.Policy) { | |
| 248 | + | return nil | |
| 249 | + | } | |
| 250 | + | } | |
| 251 | + | return ports.BrokerError{Code: ports.BrokerErrorConflictingPolicy, Cause: errors.New("vev: broker mux: route authority does not match the resolved target")} | |
| 252 | + | } | |
| 253 | + | ||
| 254 | + | // dialBrokerRoute dials one provisioned route under one resolved target as an | |
| 255 | + | // already-authenticated raw framed carriage. Authentication, identity, and | |
| 256 | + | // policy remain the daemonmux connector's concern; this function selects the | |
| 257 | + | // transport and forwards the whole target, so a mux stdio/QUIC helper receives | |
| 258 | + | // the start authorization before it decides anything about the remote daemon. | |
| 259 | + | // | |
| 260 | + | // A Unix route is dialed directly by this process. The configuration's own | |
| 261 | + | // local binding route is the machine daemon the broker may start, so it goes | |
| 262 | + | // through the broker-owned local daemon start; every other Unix route is a | |
| 263 | + | // provisioned endpoint carriage that the broker only ever dials. | |
| 264 | + | func dialBrokerRoute(ctx context.Context, route brokerconfig.Route, target ports.BrokerDialTarget, log *slog.Logger) (daemonmux.RawFramedTransport, error) { | |
| 166 | 265 | switch route.Kind() { | |
| 167 | 266 | case brokerconfig.RouteUnix: | |
| 267 | + | if target.Fence.Local { | |
| 268 | + | // The broker-owned local fence is the machine daemon this process may | |
| 269 | + | // start; it goes through the shared daemon start so the authorization | |
| 270 | + | // reaches the election. Every other Unix carriage is only dialed. | |
| 271 | + | return dialBrokerLocalDaemon(ctx, route.Path(), target) | |
| 272 | + | } | |
| 168 | 273 | return ipc.DialMuxContext(ctx, route.Path()) | |
| 169 | 274 | case brokerconfig.RouteSSHStdio: | |
| 170 | - | return sshstdio.DialMuxContext(ctx, sshMuxCommandSpec(route), log) | |
| 275 | + | spec, err := sshMuxCommandSpec(route, target.StartMode) | |
| 276 | + | if err != nil { | |
| 277 | + | return nil, err | |
| 278 | + | } | |
| 279 | + | return sshstdio.DialMuxContext(ctx, spec, log) | |
| 171 | 280 | case brokerconfig.RouteSSHQUIC: | |
| 172 | - | return dialBrokerQUICRoute(ctx, route, log) | |
| 281 | + | return dialBrokerQUICRoute(ctx, route, target.StartMode, log) | |
| 173 | 282 | default: | |
| 174 | 283 | return nil, fmt.Errorf("vev: broker mux: unsupported route kind %q", route.Kind()) | |
| 175 | 284 | } | |
| 176 | 285 | } | |
| 177 | 286 | ||
| 178 | - | // sshMuxCommandSpec builds the local ssh argv for one provisioned route. It | |
| 179 | - | // reuses the mux carriage's own command construction (the option terminator, | |
| 180 | - | // the POSIX-quoted remote argv, and `-T` for no remote PTY) and splices in only | |
| 181 | - | // the explicit trust inputs the operator provisioned. It never disables | |
| 182 | - | // host-key verification, never forces a non-interactive auth bypass, and never | |
| 183 | - | // requests a PTY. | |
| 184 | - | func sshMuxCommandSpec(route brokerconfig.Route) sshstdio.CommandSpec { | |
| 185 | - | spec := sshstdio.BuildCommandForMux(route.Target(), route.Argv()...) | |
| 287 | + | // sshMuxCommandSpec builds the local ssh argv for one provisioned route and one | |
| 288 | + | // start authorization. It reuses the mux carriage's own command construction | |
| 289 | + | // (the option terminator, the POSIX-quoted remote argv, and `-T` for no remote | |
| 290 | + | // PTY), splices in only the explicit trust inputs the operator provisioned, and | |
| 291 | + | // appends the closed `--daemon-start existing-only|if-needed` word so the remote | |
| 292 | + | // helper reads the authorization the broker propagated instead of defaulting. | |
| 293 | + | // It never disables host-key verification, never forces a non-interactive auth | |
| 294 | + | // bypass, and never requests a PTY. | |
| 295 | + | func sshMuxCommandSpec(route brokerconfig.Route, mode ports.BrokerDaemonStartMode) (sshstdio.CommandSpec, error) { | |
| 296 | + | flag, err := brokerDaemonStartArgvFlag(mode) | |
| 297 | + | if err != nil { | |
| 298 | + | return sshstdio.CommandSpec{}, err | |
| 299 | + | } | |
| 300 | + | argv := route.Argv() | |
| 301 | + | for _, word := range argv { | |
| 302 | + | if word == brokerDaemonStartArg { | |
| 303 | + | return sshstdio.CommandSpec{}, fmt.Errorf("vev: broker mux route provisions its own %s", brokerDaemonStartArg) | |
| 304 | + | } | |
| 305 | + | } | |
| 306 | + | argv = append(argv, brokerDaemonStartArg, flag) | |
| 307 | + | spec := sshstdio.BuildCommandForMux(route.Target(), argv...) | |
| 186 | 308 | options := sshTrustOptions(route) | |
| 187 | 309 | if len(options) == 0 || len(spec.Args) < 2 { | |
| 188 | - | return spec | |
| 310 | + | return spec, nil | |
| 189 | 311 | } | |
| 190 | 312 | // BuildCommandForMux returns ["-T", "--", target, remote]; insert options | |
| 191 | 313 | // after `-T` so they stay ssh options and the option terminator still | |
| @@ -194,7 +316,7 @@ func sshMuxCommandSpec(route brokerconfig.Route) sshstdio.CommandSpec { | |||
| 194 | 316 | args = append(args, spec.Args[0]) | |
| 195 | 317 | args = append(args, options...) | |
| 196 | 318 | args = append(args, spec.Args[1:]...) | |
| 197 | - | return sshstdio.CommandSpec{Path: spec.Path, Args: args} | |
| 319 | + | return sshstdio.CommandSpec{Path: spec.Path, Args: args}, nil | |
| 198 | 320 | } | |
| 199 | 321 | ||
| 200 | 322 | // sshTrustOptions renders the provisioned trust inputs as ssh options. A | |
| @@ -229,8 +351,11 @@ func sshTrustOptions(route brokerconfig.Route) []string { | |||
| 229 | 351 | // within the bound and can never pin pool setup. The captured stderr sink is | |
| 230 | 352 | // read only after that kill and reap, and the sink is itself synchronized, so | |
| 231 | 353 | // the os/exec copy goroutine can never race the diagnostic. | |
| 232 | - | func dialBrokerQUICRoute(ctx context.Context, route brokerconfig.Route, log *slog.Logger) (wire.BoundedTransport, error) { | |
| 233 | - | spec := sshMuxCommandSpec(route) | |
| 354 | + | func dialBrokerQUICRoute(ctx context.Context, route brokerconfig.Route, mode ports.BrokerDaemonStartMode, log *slog.Logger) (wire.BoundedTransport, error) { | |
| 355 | + | spec, err := sshMuxCommandSpec(route, mode) | |
| 356 | + | if err != nil { | |
| 357 | + | return nil, err | |
| 358 | + | } | |
| 234 | 359 | stderr := sshstdio.NewDiagnosticSink(0) | |
| 235 | 360 | cmd := exec.CommandContext(ctx, spec.Path, spec.Args...) | |
| 236 | 361 | cmd.Stderr = stderr | |
| @@ -377,21 +502,19 @@ func brokerQUICUnavailable(action string, err error) error { | |||
| 377 | 502 | } | |
| 378 | 503 | ||
| 379 | 504 | // runBrokerMuxStdioCommand bridges the process' own stdio to the single | |
| 380 | - | // provisioned private Unix daemonmux carriage. | |
| 505 | + | // provisioned private Unix daemonmux carriage, under the daemon-start | |
| 506 | + | // authorization the broker propagated. | |
| 381 | 507 | func runBrokerMuxStdioCommand(ctx context.Context, options brokerMuxOptions) error { | |
| 382 | - | config, err := loadMuxHelperConfig(options.offlineRoot) | |
| 383 | - | if err != nil { | |
| 384 | - | return err | |
| 385 | - | } | |
| 386 | - | route, err := config.LocalMuxRoute() | |
| 387 | - | if err != nil { | |
| 388 | - | return err | |
| 389 | - | } | |
| 390 | - | raw, err := ipc.DialMuxContext(ctx, route.Path()) | |
| 508 | + | raw, err := dialBrokerMuxHelper(ctx, options) | |
| 391 | 509 | if err != nil { | |
| 392 | 510 | return fmt.Errorf("vev: broker mux stdio: dial daemonmux carriage: %w", err) | |
| 393 | 511 | } | |
| 394 | - | return bridgeMuxTransports(ctx, sshstdio.NewStdioTransport(), raw) | |
| 512 | + | bounded, ok := raw.(wire.BoundedTransport) | |
| 513 | + | if !ok { | |
| 514 | + | _ = raw.Close() | |
| 515 | + | return errors.New("vev: broker mux stdio: carriage is not a bounded transport") | |
| 516 | + | } | |
| 517 | + | return bridgeMuxTransports(ctx, sshstdio.NewStdioTransport(), bounded) | |
| 395 | 518 | } | |
| 396 | 519 | ||
| 397 | 520 | // runBrokerMuxQUICBootstrapCommand starts one detached `_broker-mux-quic-proxy`, | |
| @@ -401,8 +524,10 @@ func runBrokerMuxStdioCommand(ctx context.Context, options brokerMuxOptions) err | |||
| 401 | 524 | func runBrokerMuxQUICBootstrapCommand(ctx context.Context, options brokerMuxOptions) error { | |
| 402 | 525 | // Validate the sandbox before spawning anything, so a bad root or config | |
| 403 | 526 | // fails without a readiness line and without a child. | |
| 404 | - | if _, err := loadMuxHelperConfig(options.offlineRoot); err != nil { | |
| 405 | - | return err | |
| 527 | + | if !options.production { | |
| 528 | + | if _, err := loadMuxHelperConfig(options.offlineRoot); err != nil { | |
| 529 | + | return err | |
| 530 | + | } | |
| 406 | 531 | } | |
| 407 | 532 | exe, err := os.Executable() | |
| 408 | 533 | if err != nil { | |
| @@ -420,7 +545,19 @@ func runBrokerMuxQUICBootstrapCommand(ctx context.Context, options brokerMuxOpti | |||
| 420 | 545 | } | |
| 421 | 546 | defer func() { _ = devNull.Close() }() | |
| 422 | 547 | ||
| 423 | - | cmd := exec.CommandContext(ctx, exe, brokerMuxQUICProxyCommand, "--offline-root", options.offlineRoot) | |
| 548 | + | args := []string{brokerMuxQUICProxyCommand} | |
| 549 | + | if options.production { | |
| 550 | + | args = append(args, "--production") | |
| 551 | + | } else { | |
| 552 | + | args = append(args, "--offline-root", options.offlineRoot) | |
| 553 | + | } | |
| 554 | + | flag, err := brokerDaemonStartArgvFlag(options.startMode) | |
| 555 | + | if err != nil { | |
| 556 | + | _ = writer.Close() | |
| 557 | + | return err | |
| 558 | + | } | |
| 559 | + | args = append(args, brokerDaemonStartArg, flag) | |
| 560 | + | cmd := exec.CommandContext(ctx, exe, args...) | |
| 424 | 561 | // The proxy writes its readiness through the pipe and its diagnostics | |
| 425 | 562 | // through stderr; stdio is otherwise detached so the SSH bootstrap channel | |
| 426 | 563 | // can close while the detached proxy owns the carriage. | |
| @@ -464,14 +601,6 @@ func runBrokerMuxQUICBootstrapCommand(ctx context.Context, options brokerMuxOpti | |||
| 464 | 601 | // expiry, or on setup failure, so it can never outlive its advertised | |
| 465 | 602 | // credential or serve a second physical connection. | |
| 466 | 603 | func runBrokerMuxQUICProxyCommand(ctx context.Context, options brokerMuxOptions) error { | |
| 467 | - | config, err := loadMuxHelperConfig(options.offlineRoot) | |
| 468 | - | if err != nil { | |
| 469 | - | return err | |
| 470 | - | } | |
| 471 | - | route, err := config.LocalMuxRoute() | |
| 472 | - | if err != nil { | |
| 473 | - | return err | |
| 474 | - | } | |
| 475 | 604 | server, readiness, err := quic.NewServer() | |
| 476 | 605 | if err != nil { | |
| 477 | 606 | return err | |
| @@ -489,11 +618,37 @@ func runBrokerMuxQUICProxyCommand(ctx context.Context, options brokerMuxOptions) | |||
| 489 | 618 | return err | |
| 490 | 619 | } | |
| 491 | 620 | defer func() { _ = transport.Close() }() | |
| 492 | - | raw, err := ipc.DialMuxContext(ctx, route.Path()) | |
| 621 | + | raw, err := dialBrokerMuxHelper(ctx, options) | |
| 493 | 622 | if err != nil { | |
| 494 | 623 | return fmt.Errorf("vev: broker mux quic proxy: dial daemonmux carriage: %w", err) | |
| 495 | 624 | } | |
| 496 | - | return bridgeMuxTransports(ctx, transport, raw) | |
| 625 | + | bounded, ok := raw.(wire.BoundedTransport) | |
| 626 | + | if !ok { | |
| 627 | + | _ = raw.Close() | |
| 628 | + | return errors.New("vev: broker mux quic proxy: carriage is not a bounded transport") | |
| 629 | + | } | |
| 630 | + | return bridgeMuxTransports(ctx, transport, bounded) | |
| 631 | + | } | |
| 632 | + | ||
| 633 | + | // dialBrokerMuxHelper is a remote-side broker carriage helper, not a client | |
| 634 | + | // daemon dial. Production authority is derived locally, never from SSH argv. | |
| 635 | + | func dialBrokerMuxHelper(ctx context.Context, options brokerMuxOptions) (daemonmux.RawFramedTransport, error) { | |
| 636 | + | if !options.production { | |
| 637 | + | config, err := loadMuxHelperConfig(options.offlineRoot) | |
| 638 | + | if err != nil { | |
| 639 | + | return nil, err | |
| 640 | + | } | |
| 641 | + | return dialMuxHelperCarriage(ctx, config, options.startMode) | |
| 642 | + | } | |
| 643 | + | route, err := brokerconfig.RouteFromSpec(ports.BrokerRouteSpec{Kind: ports.BrokerRouteUnix, Path: daemonmux.SocketPath(ipc.SocketDir())}) | |
| 644 | + | if err != nil { | |
| 645 | + | return nil, err | |
| 646 | + | } | |
| 647 | + | target := ports.BrokerDialTarget{Fence: ports.BrokerEndpointFence{Local: true}, Policy: localDaemonPolicy(), Address: route.Address(), StartMode: options.startMode} | |
| 648 | + | if err := target.Validate(); err != nil { | |
| 649 | + | return nil, err | |
| 650 | + | } | |
| 651 | + | return dialBrokerLocalDaemon(ctx, route.Path(), target) | |
| 497 | 652 | } | |
| 498 | 653 | ||
| 499 | 654 | // bridgeMuxTransports copies raw frames both ways until either carriage ends or | |
M internal/app/broker_offline_mux_test.go +88 -23
| @@ -161,22 +161,60 @@ func writeMuxRoot(t *testing.T, route any) string { | |||
| 161 | 161 | // resolver publishes, so a test can inspect a parsed route without the | |
| 162 | 162 | // unexported brokerconfig fields. | |
| 163 | 163 | func loadTestRoute(t *testing.T, route any) brokerconfig.Route { | |
| 164 | + | t.Helper() | |
| 165 | + | _, parsed := loadTestRouteAndConfig(t, route) | |
| 166 | + | return parsed | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | // loadTestRouteAndConfig loads one sandbox configuration and returns both the | |
| 170 | + | // immutable configuration and the route its resolver publishes, so a test can | |
| 171 | + | // dial the route the way the broker does: with the whole resolved target. | |
| 172 | + | func loadTestRouteAndConfig(t *testing.T, route any) (*brokerconfig.Config, brokerconfig.Route) { | |
| 164 | 173 | t.Helper() | |
| 165 | 174 | root := writeMuxRoot(t, route) | |
| 166 | 175 | layout, err := offlineLayout(root) | |
| 167 | 176 | require.NoError(t, err) | |
| 168 | 177 | config, err := brokerconfig.Load(layout) | |
| 169 | 178 | require.NoError(t, err) | |
| 170 | - | resolved, err := config.Resolver().Resolve(context.Background(), ports.BrokerOpenStreamRequest{ | |
| 179 | + | resolved, err := config.Resolver().ResolveDialTarget(context.Background(), ports.BrokerOpenStreamRequest{ | |
| 171 | 180 | Purpose: ports.BrokerStreamControl, | |
| 181 | + | Stream: 1, | |
| 172 | 182 | Endpoint: brokerTestEndpoint, | |
| 173 | 183 | Registration: brokerTestRegistration(), | |
| 174 | 184 | Policy: brokerTestPolicy(), | |
| 185 | + | StartMode: ports.BrokerDaemonStartIfNeeded, | |
| 175 | 186 | }) | |
| 176 | 187 | require.NoError(t, err) | |
| 177 | 188 | parsed, ok := config.RouteByAddress(resolved.Address) | |
| 178 | 189 | require.True(t, ok) | |
| 179 | - | return parsed | |
| 190 | + | return config, parsed | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | // testMuxDialTarget resolves the exact dial target the broker publishes for one | |
| 194 | + | // provisioned route under one start authorization, so a test dials the route | |
| 195 | + | // with the same whole target the pool does. | |
| 196 | + | func testMuxDialTarget(t *testing.T, config *brokerconfig.Config, route brokerconfig.Route, mode ports.BrokerDaemonStartMode) ports.BrokerDialTarget { | |
| 197 | + | t.Helper() | |
| 198 | + | resolved, err := config.Resolver().ResolveDialTarget(context.Background(), ports.BrokerOpenStreamRequest{ | |
| 199 | + | Purpose: ports.BrokerStreamControl, | |
| 200 | + | Stream: 1, | |
| 201 | + | Endpoint: brokerTestEndpoint, | |
| 202 | + | Registration: brokerTestRegistration(), | |
| 203 | + | Policy: brokerTestPolicy(), | |
| 204 | + | StartMode: mode, | |
| 205 | + | }) | |
| 206 | + | require.NoError(t, err) | |
| 207 | + | require.Equal(t, route.Address(), resolved.Address, "the test dials the provisioned route") | |
| 208 | + | return resolved | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | // loadTestRouteTarget loads one sandbox configuration and returns both the | |
| 212 | + | // parsed route and the exact resolved dial target the broker would publish for | |
| 213 | + | // it, so a test can call dialBrokerRoute exactly as the pool does. | |
| 214 | + | func loadTestRouteTarget(t *testing.T, route any) (brokerconfig.Route, ports.BrokerDialTarget) { | |
| 215 | + | t.Helper() | |
| 216 | + | config, parsed := loadTestRouteAndConfig(t, route) | |
| 217 | + | return parsed, testMuxDialTarget(t, config, parsed, ports.BrokerDaemonStartIfNeeded) | |
| 180 | 218 | } | |
| 181 | 219 | ||
| 182 | 220 | // TestParseBrokerMuxArgs pins the strict hidden-helper argument contract. | |
| @@ -187,12 +225,17 @@ func TestParseBrokerMuxArgs(t *testing.T) { | |||
| 187 | 225 | wantErr string | |
| 188 | 226 | want brokerMuxOptions | |
| 189 | 227 | }{ | |
| 190 | - | {name: "valid", args: []string{"--offline-root", "/srv/sandbox"}, want: brokerMuxOptions{offlineRoot: "/srv/sandbox"}}, | |
| 228 | + | {name: "valid", args: []string{"--offline-root", "/srv/sandbox"}, want: brokerMuxOptions{offlineRoot: "/srv/sandbox", startMode: ports.BrokerDaemonExistingOnly}}, | |
| 229 | + | {name: "explicit existing-only", args: []string{"--offline-root", "/srv/sandbox", brokerDaemonStartArg, "existing-only"}, want: brokerMuxOptions{offlineRoot: "/srv/sandbox", startMode: ports.BrokerDaemonExistingOnly}}, | |
| 230 | + | {name: "explicit if-needed", args: []string{"--offline-root", "/srv/sandbox", brokerDaemonStartArg, "if-needed"}, want: brokerMuxOptions{offlineRoot: "/srv/sandbox", startMode: ports.BrokerDaemonStartIfNeeded}}, | |
| 191 | 231 | {name: "missing root", args: nil, wantErr: "requires --offline-root"}, | |
| 192 | 232 | {name: "empty root", args: []string{"--offline-root", ""}, wantErr: "requires a path"}, | |
| 193 | 233 | {name: "duplicate root", args: []string{"--offline-root", "/a", "--offline-root", "/b"}, wantErr: "duplicate"}, | |
| 194 | 234 | {name: "unknown flag", args: []string{"--idle-grace", "1m"}, wantErr: "unknown flag"}, | |
| 195 | 235 | {name: "positional", args: []string{"--offline-root", "/a", "extra"}, wantErr: "positional"}, | |
| 236 | + | {name: "missing daemon-start value", args: []string{"--offline-root", "/a", brokerDaemonStartArg}, wantErr: "requires a value"}, | |
| 237 | + | {name: "duplicate daemon-start", args: []string{"--offline-root", "/a", brokerDaemonStartArg, "if-needed", brokerDaemonStartArg, "existing-only"}, wantErr: "duplicate"}, | |
| 238 | + | {name: "unknown daemon-start", args: []string{"--offline-root", "/a", brokerDaemonStartArg, "always"}, wantErr: "unknown daemon start mode"}, | |
| 196 | 239 | } | |
| 197 | 240 | for _, tt := range tests { | |
| 198 | 241 | t.Run(tt.name, func(t *testing.T) { | |
| @@ -203,7 +246,7 @@ func TestParseBrokerMuxArgs(t *testing.T) { | |||
| 203 | 246 | return | |
| 204 | 247 | } | |
| 205 | 248 | require.NoError(t, err) | |
| 206 | - | require.Equal(t, brokerMuxOptions{offlineRoot: tt.want.offlineRoot}, command.brokerMux) | |
| 249 | + | require.Equal(t, tt.want, command.brokerMux) | |
| 207 | 250 | }) | |
| 208 | 251 | } | |
| 209 | 252 | } | |
| @@ -245,7 +288,8 @@ func TestSSHMuxCommandSpecPreservesTrustAndNoPTY(t *testing.T) { | |||
| 245 | 288 | "argv": []any{"vev", brokerMuxStdioCommand, "--offline-root", "/srv/remote"}, | |
| 246 | 289 | "trust": map[string]any{"knownHostsFile": "/etc/ssh/known_hosts", "connectTimeout": "30s"}, | |
| 247 | 290 | }) | |
| 248 | - | spec := sshMuxCommandSpec(route) | |
| 291 | + | spec, err := sshMuxCommandSpec(route, ports.BrokerDaemonStartIfNeeded) | |
| 292 | + | require.NoError(t, err) | |
| 249 | 293 | require.Equal(t, "ssh", spec.Path) | |
| 250 | 294 | require.Equal(t, "-T", spec.Args[0]) | |
| 251 | 295 | require.Contains(t, spec.Args, "--") | |
| @@ -254,6 +298,7 @@ func TestSSHMuxCommandSpecPreservesTrustAndNoPTY(t *testing.T) { | |||
| 254 | 298 | joined := strings.Join(spec.Args, " ") | |
| 255 | 299 | require.Contains(t, joined, "-- user@host:2222 ") | |
| 256 | 300 | require.Contains(t, joined, brokerMuxStdioCommand) | |
| 301 | + | require.Contains(t, joined, "'"+brokerDaemonStartArg+"' 'if-needed'") | |
| 257 | 302 | require.NotContains(t, joined, "StrictHostKeyChecking") | |
| 258 | 303 | require.NotContains(t, joined, "BatchMode") | |
| 259 | 304 | require.NotContains(t, joined, "ProxyCommand") | |
| @@ -270,9 +315,25 @@ func TestSSHMuxCommandSpecPreservesTrustAndNoPTY(t *testing.T) { | |||
| 270 | 315 | "target": "host", | |
| 271 | 316 | "argv": []any{"vev", brokerMuxQUICBootstrapCommand, "--offline-root", "/srv/remote"}, | |
| 272 | 317 | }) | |
| 273 | - | plainSpec := sshMuxCommandSpec(plain) | |
| 318 | + | plainSpec, err := sshMuxCommandSpec(plain, ports.BrokerDaemonExistingOnly) | |
| 319 | + | require.NoError(t, err) | |
| 274 | 320 | require.Equal(t, []string{"-T", "--"}, append([]string(nil), plainSpec.Args[:2]...)) | |
| 275 | 321 | require.NotContains(t, plainSpec.Args, "-o") | |
| 322 | + | require.Contains(t, strings.Join(plainSpec.Args, " "), "'"+brokerDaemonStartArg+"' 'existing-only'") | |
| 323 | + | } | |
| 324 | + | ||
| 325 | + | // TestSSHMuxCommandSpecRefusesUnknownStartMode proves a route never carries an | |
| 326 | + | // unreadable start authorization: an unknown mode is refused instead of | |
| 327 | + | // defaulting the helper to a permissive one. | |
| 328 | + | func TestSSHMuxCommandSpecRefusesUnknownStartMode(t *testing.T) { | |
| 329 | + | isolateSandboxEnv(t) | |
| 330 | + | route := loadTestRoute(t, map[string]any{ | |
| 331 | + | "kind": "ssh-stdio", | |
| 332 | + | "target": "host", | |
| 333 | + | "argv": []any{"vev", brokerMuxStdioCommand, "--offline-root", "/srv/remote"}, | |
| 334 | + | }) | |
| 335 | + | _, err := sshMuxCommandSpec(route, ports.BrokerDaemonStartMode(0)) | |
| 336 | + | require.Error(t, err) | |
| 276 | 337 | } | |
| 277 | 338 | ||
| 278 | 339 | // TestSSHMuxCommandSpecTargetQuotesSpacesInsideArgv proves a remote argv word | |
| @@ -284,8 +345,12 @@ func TestSSHMuxCommandSpecTargetQuotesSpacesInsideArgv(t *testing.T) { | |||
| 284 | 345 | "target": "host", | |
| 285 | 346 | "argv": []any{"vev", "_broker-mux-stdio", "--offline-root", "/srv/with space"}, | |
| 286 | 347 | }) | |
| 287 | - | spec := sshMuxCommandSpec(route) | |
| 348 | + | spec, err := sshMuxCommandSpec(route, ports.BrokerDaemonExistingOnly) | |
| 349 | + | require.NoError(t, err) | |
| 350 | + | // The whole remote command stays one quoted argv word, so the space-bearing | |
| 351 | + | // path is still one word and never becomes a second option. | |
| 288 | 352 | require.Contains(t, spec.Args[len(spec.Args)-1], `'/srv/with space'`) | |
| 353 | + | require.Contains(t, spec.Args[len(spec.Args)-1], "'"+brokerDaemonStartArg+"' 'existing-only'") | |
| 289 | 354 | } | |
| 290 | 355 | ||
| 291 | 356 | // TestBrokerQUICPeerAddr pins the host-independent address composition. | |
| @@ -327,11 +392,7 @@ func TestBrokerMuxConnectorRejectsUnknownAddress(t *testing.T) { | |||
| 327 | 392 | connector, err := brokerMuxConnector(config, slog.New(slog.NewTextHandler(io.Discard, nil))) | |
| 328 | 393 | require.NoError(t, err) | |
| 329 | 394 | ||
| 330 | - | _, err = connector.Connect(context.Background(), ports.BrokerResolvedEndpoint{ | |
| 331 | - | Identity: brokerTestIdentity, | |
| 332 | - | Policy: brokerTestPolicy(), | |
| 333 | - | Address: "offline-route-00000000000000000000000000000000", | |
| 334 | - | }) | |
| 395 | + | _, err = connector.Connect(context.Background(), ports.BrokerDialTarget{Fence: ports.BrokerEndpointFence{Local: true}, Policy: brokerTestPolicy(), Address: "offline-route-00000000000000000000000000000000", StartMode: ports.BrokerDaemonStartIfNeeded, ExpectedIdentity: ports.BrokerExpectedIdentity{Identity: brokerTestIdentity, Bound: true}}) | |
| 335 | 396 | require.Error(t, err) | |
| 336 | 397 | var typed ports.BrokerError | |
| 337 | 398 | require.ErrorAs(t, err, &typed) | |
| @@ -416,14 +477,14 @@ func TestBrokerMuxQUICRejectsBadPinWithoutLeakingSecret(t *testing.T) { | |||
| 416 | 477 | tampered.Fingerprint = strings.Repeat("00", 32) | |
| 417 | 478 | t.Setenv(fakeSSHReadyEnv, string(mustJSON(t, tampered))) | |
| 418 | 479 | ||
| 419 | - | route := loadTestRoute(t, map[string]any{ | |
| 480 | + | route, target := loadTestRouteTarget(t, map[string]any{ | |
| 420 | 481 | "kind": "ssh-quic", | |
| 421 | 482 | "target": "127.0.0.1", | |
| 422 | 483 | "argv": []any{"vev", brokerMuxQUICBootstrapCommand, "--offline-root", "/srv/remote"}, | |
| 423 | 484 | }) | |
| 424 | 485 | ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) | |
| 425 | 486 | defer cancel() | |
| 426 | - | _, err = dialBrokerRoute(ctx, route, slog.New(slog.NewTextHandler(io.Discard, nil))) | |
| 487 | + | _, err = dialBrokerRoute(ctx, route, target, slog.New(slog.NewTextHandler(io.Discard, nil))) | |
| 427 | 488 | require.Error(t, err) | |
| 428 | 489 | requireBrokerErrorHasNoReadiness(t, err, readiness) | |
| 429 | 490 | } | |
| @@ -446,12 +507,12 @@ func TestBrokerMuxQUICRejectsExpiredReadiness(t *testing.T) { | |||
| 446 | 507 | } | |
| 447 | 508 | t.Setenv(fakeSSHReadyEnv, string(mustJSON(t, readiness))) | |
| 448 | 509 | ||
| 449 | - | route := loadTestRoute(t, map[string]any{ | |
| 510 | + | route, target := loadTestRouteTarget(t, map[string]any{ | |
| 450 | 511 | "kind": "ssh-quic", | |
| 451 | 512 | "target": "127.0.0.1", | |
| 452 | 513 | "argv": []any{"vev", brokerMuxQUICBootstrapCommand, "--offline-root", "/srv/remote"}, | |
| 453 | 514 | }) | |
| 454 | - | _, err := dialBrokerRoute(context.Background(), route, slog.New(slog.NewTextHandler(io.Discard, nil))) | |
| 515 | + | _, err := dialBrokerRoute(context.Background(), route, target, slog.New(slog.NewTextHandler(io.Discard, nil))) | |
| 455 | 516 | require.Error(t, err) | |
| 456 | 517 | require.NotContains(t, err.Error(), token) | |
| 457 | 518 | require.NotContains(t, err.Error(), nonce) | |
| @@ -466,7 +527,7 @@ func TestBrokerMuxDialCancellationReapsBootstrap(t *testing.T) { | |||
| 466 | 527 | pidFile := filepath.Join(t.TempDir(), "ssh.pid") | |
| 467 | 528 | t.Setenv(fakeSSHPIDEnv, pidFile) | |
| 468 | 529 | ||
| 469 | - | route := loadTestRoute(t, map[string]any{ | |
| 530 | + | route, target := loadTestRouteTarget(t, map[string]any{ | |
| 470 | 531 | "kind": "ssh-quic", | |
| 471 | 532 | "target": "127.0.0.1", | |
| 472 | 533 | "argv": []any{"vev", brokerMuxQUICBootstrapCommand, "--offline-root", "/srv/remote"}, | |
| @@ -474,7 +535,7 @@ func TestBrokerMuxDialCancellationReapsBootstrap(t *testing.T) { | |||
| 474 | 535 | ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond) | |
| 475 | 536 | defer cancel() | |
| 476 | 537 | start := time.Now() | |
| 477 | - | _, err := dialBrokerRoute(ctx, route, slog.New(slog.NewTextHandler(io.Discard, nil))) | |
| 538 | + | _, err := dialBrokerRoute(ctx, route, target, slog.New(slog.NewTextHandler(io.Discard, nil))) | |
| 478 | 539 | require.Error(t, err) | |
| 479 | 540 | require.Less(t, time.Since(start), 3*time.Second, "cancellation must return promptly") | |
| 480 | 541 | ||
| @@ -494,7 +555,7 @@ func TestBrokerMuxDialFloodedStderrIsReapedAndSanitized(t *testing.T) { | |||
| 494 | 555 | pidFile := filepath.Join(t.TempDir(), "ssh.pid") | |
| 495 | 556 | t.Setenv(fakeSSHPIDEnv, pidFile) | |
| 496 | 557 | ||
| 497 | - | route := loadTestRoute(t, map[string]any{ | |
| 558 | + | route, target := loadTestRouteTarget(t, map[string]any{ | |
| 498 | 559 | "kind": "ssh-quic", | |
| 499 | 560 | "target": "127.0.0.1", | |
| 500 | 561 | "argv": []any{"vev", brokerMuxQUICBootstrapCommand, "--offline-root", "/srv/remote"}, | |
| @@ -504,7 +565,7 @@ func TestBrokerMuxDialFloodedStderrIsReapedAndSanitized(t *testing.T) { | |||
| 504 | 565 | logger := slog.New(slog.NewJSONHandler(&logs, nil)) | |
| 505 | 566 | ctx, cancel := context.WithTimeout(context.Background(), 400*time.Millisecond) | |
| 506 | 567 | defer cancel() | |
| 507 | - | _, err := dialBrokerRoute(ctx, route, logger) | |
| 568 | + | _, err := dialBrokerRoute(ctx, route, target, logger) | |
| 508 | 569 | require.Error(t, err) | |
| 509 | 570 | require.NotContains(t, err.Error(), "flood", "bootstrap stderr must never reach the error") | |
| 510 | 571 | ||
| @@ -535,13 +596,13 @@ func TestBrokerMuxDialStalledBootstrapUsesSetupTimeout(t *testing.T) { | |||
| 535 | 596 | brokerMuxBootstrapReadyTimeout = 250 * time.Millisecond | |
| 536 | 597 | t.Cleanup(func() { brokerMuxBootstrapReadyTimeout = restore }) | |
| 537 | 598 | ||
| 538 | - | route := loadTestRoute(t, map[string]any{ | |
| 599 | + | route, target := loadTestRouteTarget(t, map[string]any{ | |
| 539 | 600 | "kind": "ssh-quic", | |
| 540 | 601 | "target": "127.0.0.1", | |
| 541 | 602 | "argv": []any{"vev", brokerMuxQUICBootstrapCommand, "--offline-root", "/srv/remote"}, | |
| 542 | 603 | }) | |
| 543 | 604 | start := time.Now() | |
| 544 | - | _, err := dialBrokerRoute(context.Background(), route, slog.New(slog.NewTextHandler(io.Discard, nil))) | |
| 605 | + | _, err := dialBrokerRoute(context.Background(), route, target, slog.New(slog.NewTextHandler(io.Discard, nil))) | |
| 545 | 606 | elapsed := time.Since(start) | |
| 546 | 607 | require.Error(t, err) | |
| 547 | 608 | require.Contains(t, err.Error(), "read bootstrap readiness") | |
| @@ -575,7 +636,7 @@ func startMuxBrokerServe(t *testing.T, localRoot string) (string, func()) { | |||
| 575 | 636 | deps, ready := testBrokerServeDeps(newSandboxClock()) | |
| 576 | 637 | ctx, cancel := context.WithCancel(context.Background()) | |
| 577 | 638 | done := runSandbox(ctx, brokerServeOptions{offlineRoot: localRoot}, deps) | |
| 578 | - | socketPath := awaitSandboxReady(t, ready) | |
| 639 | + | socketPath := awaitSandboxReady(t, ready, done) | |
| 579 | 640 | var once bool | |
| 580 | 641 | stop := func() { | |
| 581 | 642 | if once { | |
| @@ -597,11 +658,15 @@ func requireBrokerPing(t *testing.T, socketPath string) { | |||
| 597 | 658 | require.NoError(t, err) | |
| 598 | 659 | defer func() { _ = service.Close() }() | |
| 599 | 660 | ||
| 661 | + | streamID, err := service.NextStreamID() | |
| 662 | + | require.NoError(t, err) | |
| 600 | 663 | stream, err := service.OpenStream(clientCtx, ports.BrokerOpenStreamRequest{ | |
| 601 | 664 | Purpose: ports.BrokerStreamControl, | |
| 665 | + | Stream: streamID, | |
| 602 | 666 | Endpoint: brokerTestEndpoint, | |
| 603 | 667 | Registration: brokerTestRegistration(), | |
| 604 | 668 | Policy: brokerTestPolicy(), | |
| 669 | + | StartMode: ports.BrokerDaemonStartIfNeeded, | |
| 605 | 670 | }) | |
| 606 | 671 | require.NoError(t, err) | |
| 607 | 672 | defer func() { _ = stream.Close() }() | |
M internal/app/broker_offline_status.go +25 -5
| @@ -59,7 +59,8 @@ import ( | |||
| 59 | 59 | const ( | |
| 60 | 60 | // brokerLauncherCommand is the hidden intermediate that starts one detached | |
| 61 | 61 | // `_broker-serve` and exits. | |
| 62 | - | brokerLauncherCommand = "_broker-launcher" | |
| 62 | + | brokerLauncherCommand = "_broker-launcher" | |
| 63 | + | productionBrokerLauncherCommand = "_broker-production-launcher" | |
| 63 | 64 | // brokerStatusCommand is the hidden broker status probe. | |
| 64 | 65 | brokerStatusCommand = "_broker-status" | |
| 65 | 66 | ) | |
| @@ -160,6 +161,13 @@ func defaultBrokerStatusDeps() brokerStatusDeps { | |||
| 160 | 161 | // parseBrokerLauncherArgs strictly parses | |
| 161 | 162 | // `_broker-launcher --offline-root ABS [--idle-grace DURATION]`. Unknown flags, | |
| 162 | 163 | // duplicate flags, positionals, and non-positive durations are refused. | |
| 164 | + | func parseProductionBrokerLauncherArgs(args []string) (command, error) { | |
| 165 | + | if len(args) != 0 { | |
| 166 | + | return command{}, usagef("`%s` does not accept arguments", productionBrokerLauncherCommand) | |
| 167 | + | } | |
| 168 | + | return command{kind: kindProductionBrokerLauncher}, nil | |
| 169 | + | } | |
| 170 | + | ||
| 163 | 171 | func parseBrokerLauncherArgs(args []string) (command, error) { | |
| 164 | 172 | var options brokerLauncherOptions | |
| 165 | 173 | var seenRoot, seenGrace bool | |
| @@ -261,6 +269,10 @@ func parseBrokerStatusArgs(args []string) (command, error) { | |||
| 261 | 269 | ||
| 262 | 270 | // runBrokerLauncherCommand runs the hidden launcher. It changes no ordinary | |
| 263 | 271 | // command and never runs in production flows. | |
| 272 | + | func runProductionBrokerLauncherCommand(context.Context) error { | |
| 273 | + | return startDetachedBrokerServe([]string{productionBrokerServeCommand}) | |
| 274 | + | } | |
| 275 | + | ||
| 264 | 276 | func runBrokerLauncherCommand(_ context.Context, options brokerLauncherOptions) error { | |
| 265 | 277 | return runBrokerLauncher(options) | |
| 266 | 278 | } | |
| @@ -326,7 +338,19 @@ func startDetachedBrokerServe(args []string) error { | |||
| 326 | 338 | // signal kills a launcher that is still waiting, while killing the launcher | |
| 327 | 339 | // after it has started the detached `_broker-serve` does not touch that serve -- | |
| 328 | 340 | // it is in its own session and was released, so it survives. | |
| 341 | + | func spawnProductionBrokerLauncher(ctx context.Context) error { | |
| 342 | + | return runBrokerLauncherProcess(ctx, []string{productionBrokerLauncherCommand}) | |
| 343 | + | } | |
| 344 | + | ||
| 329 | 345 | func spawnBrokerLauncher(ctx context.Context, root string, grace time.Duration, graceSet bool) error { | |
| 346 | + | args := []string{brokerLauncherCommand, "--offline-root", root} | |
| 347 | + | if graceSet { | |
| 348 | + | args = append(args, "--idle-grace", grace.String()) | |
| 349 | + | } | |
| 350 | + | return runBrokerLauncherProcess(ctx, args) | |
| 351 | + | } | |
| 352 | + | ||
| 353 | + | func runBrokerLauncherProcess(ctx context.Context, args []string) error { | |
| 330 | 354 | exePath, err := selfExePath() | |
| 331 | 355 | if err != nil { | |
| 332 | 356 | return fmt.Errorf("vev: resolving executable path: %w", err) | |
| @@ -337,10 +361,6 @@ func spawnBrokerLauncher(ctx context.Context, root string, grace time.Duration, | |||
| 337 | 361 | } | |
| 338 | 362 | defer func() { _ = devNull.Close() }() | |
| 339 | 363 | ||
| 340 | - | args := []string{brokerLauncherCommand, "--offline-root", root} | |
| 341 | - | if graceSet { | |
| 342 | - | args = append(args, "--idle-grace", grace.String()) | |
| 343 | - | } | |
| 344 | 364 | var stderr bytes.Buffer | |
| 345 | 365 | cmd := exec.CommandContext(ctx, exePath, args...) | |
| 346 | 366 | cmd.Env = withoutPerformanceTraceEnv(os.Environ()) | |
M internal/app/broker_offline_status_test.go +3 -4
| @@ -296,7 +296,7 @@ func TestBrokerStatusDialOnlyReadyAgainstLiveSandbox(t *testing.T) { | |||
| 296 | 296 | ctx, cancel := context.WithCancel(context.Background()) | |
| 297 | 297 | defer cancel() | |
| 298 | 298 | done := runSandbox(ctx, brokerServeOptions{offlineRoot: root}, serveDeps) | |
| 299 | - | awaitSandboxReady(t, ready) | |
| 299 | + | awaitSandboxReady(t, ready, done) | |
| 300 | 300 | ||
| 301 | 301 | deps := defaultBrokerStatusDeps() | |
| 302 | 302 | var out bytes.Buffer | |
| @@ -306,9 +306,8 @@ func TestBrokerStatusDialOnlyReadyAgainstLiveSandbox(t *testing.T) { | |||
| 306 | 306 | require.Equal(t, "ready", report.Status) | |
| 307 | 307 | require.Positive(t, report.Epoch) | |
| 308 | 308 | require.Positive(t, report.Revision) | |
| 309 | - | // The sandbox registry restores from its own empty durable store; the | |
| 310 | - | // provisioned endpoints feed the resolver, not the host inventory. | |
| 311 | - | require.Zero(t, report.HostCount) | |
| 309 | + | // Provisioned endpoints seed durable broker membership before serving. | |
| 310 | + | require.Equal(t, 1, report.HostCount) | |
| 312 | 311 | ||
| 313 | 312 | awaitSandboxCancel(t, cancel, done) | |
| 314 | 313 | requireProductionUntouched(t, prodRuntime, prodState) | |
M internal/app/broker_offline_test.go +15 -8
| @@ -309,7 +309,7 @@ func TestBrokerServeRoundTrip(t *testing.T) { | |||
| 309 | 309 | defer cancel() | |
| 310 | 310 | done := runSandbox(ctx, brokerServeOptions{offlineRoot: root}, deps) | |
| 311 | 311 | ||
| 312 | - | socketPath := awaitSandboxReady(t, ready) | |
| 312 | + | socketPath := awaitSandboxReady(t, ready, done) | |
| 313 | 313 | ||
| 314 | 314 | clientCtx, clientCancel := context.WithTimeout(context.Background(), brokerTestWait) | |
| 315 | 315 | defer clientCancel() | |
| @@ -317,11 +317,15 @@ func TestBrokerServeRoundTrip(t *testing.T) { | |||
| 317 | 317 | require.NoError(t, err) | |
| 318 | 318 | defer func() { _ = service.Close() }() | |
| 319 | 319 | ||
| 320 | + | streamID, err := service.NextStreamID() | |
| 321 | + | require.NoError(t, err) | |
| 320 | 322 | stream, err := service.OpenStream(clientCtx, ports.BrokerOpenStreamRequest{ | |
| 321 | 323 | Purpose: ports.BrokerStreamControl, | |
| 324 | + | Stream: streamID, | |
| 322 | 325 | Endpoint: brokerTestEndpoint, | |
| 323 | 326 | Registration: brokerTestRegistration(), | |
| 324 | 327 | Policy: policy, | |
| 328 | + | StartMode: ports.BrokerDaemonStartIfNeeded, | |
| 325 | 329 | }) | |
| 326 | 330 | require.NoError(t, err) | |
| 327 | 331 | defer func() { _ = stream.Close() }() | |
| @@ -355,14 +359,17 @@ func TestBrokerServeRoundTrip(t *testing.T) { | |||
| 355 | 359 | ||
| 356 | 360 | // awaitSandboxReady waits for the sandbox readiness hook and returns the | |
| 357 | 361 | // reported socket path. | |
| 358 | - | func awaitSandboxReady(t *testing.T, ready <-chan string) string { | |
| 362 | + | func awaitSandboxReady(t *testing.T, ready <-chan string, done <-chan error) string { | |
| 359 | 363 | t.Helper() | |
| 360 | 364 | select { | |
| 361 | 365 | case socketPath := <-ready: | |
| 362 | 366 | require.NotEmpty(t, socketPath) | |
| 363 | 367 | return socketPath | |
| 368 | + | case err := <-done: | |
| 369 | + | t.Fatalf("sandbox exited before readiness: %v", err) | |
| 370 | + | return "" | |
| 364 | 371 | case <-time.After(brokerTestWait): | |
| 365 | - | t.Fatal("sandbox never reported readiness") | |
| 372 | + | t.Fatal("sandbox never reported readiness and did not exit") | |
| 366 | 373 | return "" | |
| 367 | 374 | } | |
| 368 | 375 | } | |
| @@ -403,7 +410,7 @@ func TestBrokerServeIdleShutdownOnEmptyConfig(t *testing.T) { | |||
| 403 | 410 | defer cancel() | |
| 404 | 411 | done := runSandbox(ctx, brokerServeOptions{offlineRoot: root}, deps) | |
| 405 | 412 | ||
| 406 | - | socketPath := awaitSandboxReady(t, ready) | |
| 413 | + | socketPath := awaitSandboxReady(t, ready, done) | |
| 407 | 414 | waitForSandboxShutdown(t, clk, done) | |
| 408 | 415 | ||
| 409 | 416 | requireSocketRemoved(t, socketPath) | |
| @@ -430,7 +437,7 @@ func TestBrokerServeCancelCleanup(t *testing.T) { | |||
| 430 | 437 | defer cancel() | |
| 431 | 438 | done := runSandbox(ctx, brokerServeOptions{offlineRoot: root}, deps) | |
| 432 | 439 | ||
| 433 | - | socketPath := awaitSandboxReady(t, ready) | |
| 440 | + | socketPath := awaitSandboxReady(t, ready, done) | |
| 434 | 441 | awaitSandboxCancel(t, cancel, done) | |
| 435 | 442 | ||
| 436 | 443 | requireSocketRemoved(t, socketPath) | |
| @@ -474,7 +481,7 @@ func TestBrokerServeShutsDownOnUnexpectedAcceptFailure(t *testing.T) { | |||
| 474 | 481 | defer cancel() | |
| 475 | 482 | done := runSandbox(ctx, brokerServeOptions{offlineRoot: root}, deps) | |
| 476 | 483 | ||
| 477 | - | awaitSandboxReady(t, ready) | |
| 484 | + | awaitSandboxReady(t, ready, done) | |
| 478 | 485 | select { | |
| 479 | 486 | case err := <-done: | |
| 480 | 487 | require.NoError(t, err) | |
| @@ -496,7 +503,7 @@ func TestBrokerServeRefusesDuplicateOwnership(t *testing.T) { | |||
| 496 | 503 | ctx, cancel := context.WithCancel(context.Background()) | |
| 497 | 504 | defer cancel() | |
| 498 | 505 | done := runSandbox(ctx, brokerServeOptions{offlineRoot: root}, deps) | |
| 499 | - | socketPath := awaitSandboxReady(t, ready) | |
| 506 | + | socketPath := awaitSandboxReady(t, ready, done) | |
| 500 | 507 | ||
| 501 | 508 | // A second foreground owner fails fast on the lifetime lock. | |
| 502 | 509 | secondDeps, _ := testBrokerServeDeps(newSandboxClock()) | |
| @@ -554,7 +561,7 @@ func newBrokerMuxFixture(t *testing.T, policy ports.BrokerPolicy) *brokerMuxFixt | |||
| 554 | 561 | for i := range incarnation { | |
| 555 | 562 | incarnation[i] = byte(0x10 + i) | |
| 556 | 563 | } | |
| 557 | - | binding, err := daemonmux.NewServerBinding(ports.BrokerDaemonIdentity(brokerTestIdentity), incarnation, policy) | |
| 564 | + | binding, err := daemonmux.NewServerBindings(ports.BrokerDaemonIdentity(brokerTestIdentity), incarnation, []daemonmux.ServerPolicyAdmission{{Policy: policy, Origin: ports.SessionOriginRemote}}) | |
| 558 | 565 | require.NoError(t, err) | |
| 559 | 566 | supervisor, err := daemonmux.NewServerSupervisor(aggregate, binding, daemonmux.DefaultMuxCeilings(), 8) | |
| 560 | 567 | require.NoError(t, err) | |
M internal/app/broker_production.go +24 -0
| @@ -6,6 +6,9 @@ import ( | |||
| 6 | 6 | "github.com/bnema/vev/internal/adapters/brokerconfig" | |
| 7 | 7 | "github.com/bnema/vev/internal/adapters/ipc" | |
| 8 | 8 | "github.com/bnema/vev/internal/platform" | |
| 9 | + | "github.com/bnema/vev/internal/ports" | |
| 10 | + | "github.com/bnema/vev/internal/protocol" | |
| 11 | + | "github.com/bnema/vev/internal/protocol/catalogue" | |
| 9 | 12 | ) | |
| 10 | 13 | ||
| 11 | 14 | // productionBrokerLayout is the single production broker filesystem contract. | |
| @@ -15,6 +18,27 @@ func productionBrokerConfigPath() string { | |||
| 15 | 18 | return filepath.Join(filepath.Dir(platform.ConfigPath()), "broker.json") | |
| 16 | 19 | } | |
| 17 | 20 | ||
| 21 | + | func remoteBrokerPolicy(transport string) ports.BrokerPolicy { | |
| 22 | + | if transport == "stdio" { | |
| 23 | + | transport = "ssh-stdio" | |
| 24 | + | } else { | |
| 25 | + | transport = "ssh-quic" | |
| 26 | + | } | |
| 27 | + | return ports.BrokerPolicy{ | |
| 28 | + | ProtocolVersion: protocol.Version, CatalogSchemaVersion: catalogue.RemoteCatalogSchemaVersion, | |
| 29 | + | EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned, Transport: transport, | |
| 30 | + | Trust: "openssh-config-v1", Launch: "explicit", Isolation: "per-user", | |
| 31 | + | } | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | func localDaemonPolicy() ports.BrokerPolicy { | |
| 35 | + | return ports.BrokerPolicy{ | |
| 36 | + | ProtocolVersion: protocol.Version, CatalogSchemaVersion: catalogue.RemoteCatalogSchemaVersion, | |
| 37 | + | EnvironmentPolicy: protocol.EnvironmentPolicyClientOwned, Transport: "unix-mux", | |
| 38 | + | Trust: "same-user", Launch: "explicit", Isolation: "per-user", | |
| 39 | + | } | |
| 40 | + | } | |
| 41 | + | ||
| 18 | 42 | func productionBrokerLayout() brokerconfig.Layout { | |
| 19 | 43 | root := filepath.Join(platform.StateDir(), "broker") | |
| 20 | 44 | runtime := filepath.Join(ipc.SocketDir(), "broker") | |
A internal/app/broker_production_connect.go +61 -0
| @@ -0,0 +1,61 @@ | |||
| 1 | + | package app | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | "time" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/vev/internal/adapters/brokeripc" | |
| 9 | + | "github.com/bnema/vev/internal/ports" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | const productionBrokerStartupTimeout = 10 * time.Second | |
| 13 | + | ||
| 14 | + | // connectProductionBroker connects to the sole per-user production broker. | |
| 15 | + | // Process election and idle lifetime remain owned by the existing broker | |
| 16 | + | // launcher and broker.Supervisor; callers receive only the semantic façade. | |
| 17 | + | func connectProductionBroker(ctx context.Context) (ports.BrokerService, error) { | |
| 18 | + | layout := productionBrokerLayout() | |
| 19 | + | request := brokerStatusRequest{ | |
| 20 | + | layout: layout, socketPath: brokeripc.SocketPath(layout.Runtime), | |
| 21 | + | deadline: time.Now().Add(productionBrokerStartupTimeout), timeout: productionBrokerStartupTimeout, | |
| 22 | + | root: layout.Root, | |
| 23 | + | } | |
| 24 | + | deps := defaultBrokerStatusDeps() | |
| 25 | + | deps.spawn = func(ctx context.Context, _ string, _ time.Duration, _ bool) error { | |
| 26 | + | return spawnProductionBrokerLauncher(ctx) | |
| 27 | + | } | |
| 28 | + | if _, err := ensureBrokerReady(ctx, request, deps); err != nil { | |
| 29 | + | return nil, fmt.Errorf("vev: ensure broker: %w", err) | |
| 30 | + | } | |
| 31 | + | service, err := brokeripc.NewConnector(request.socketPath, brokeripc.Config{}).Connect(ctx) | |
| 32 | + | if err != nil { | |
| 33 | + | return nil, fmt.Errorf("vev: connect broker: %w", err) | |
| 34 | + | } | |
| 35 | + | if err := awaitBrokerPublication(ctx, service); err != nil { | |
| 36 | + | _ = service.Close() | |
| 37 | + | return nil, fmt.Errorf("vev: await broker publication: %w", err) | |
| 38 | + | } | |
| 39 | + | return service, nil | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | func awaitBrokerPublication(ctx context.Context, service ports.BrokerService) error { | |
| 43 | + | sub, err := service.Subscribe() | |
| 44 | + | if err != nil { | |
| 45 | + | return err | |
| 46 | + | } | |
| 47 | + | defer sub.Close() | |
| 48 | + | for { | |
| 49 | + | snapshot := service.Snapshot() | |
| 50 | + | if snapshot.Epoch != 0 { | |
| 51 | + | return snapshot.Validate() | |
| 52 | + | } | |
| 53 | + | select { | |
| 54 | + | case <-ctx.Done(): | |
| 55 | + | return ctx.Err() | |
| 56 | + | case <-service.Done(): | |
| 57 | + | return service.Err() | |
| 58 | + | case <-sub.Changed(): | |
| 59 | + | } | |
| 60 | + | } | |
| 61 | + | } | |
M internal/app/broker_production_test.go +16 -0
| @@ -7,6 +7,22 @@ import ( | |||
| 7 | 7 | "github.com/stretchr/testify/require" | |
| 8 | 8 | ) | |
| 9 | 9 | ||
| 10 | + | func TestProductionBrokerHiddenCommandsRejectSandboxOptions(t *testing.T) { | |
| 11 | + | serve, err := parseArgs([]string{productionBrokerServeCommand}) | |
| 12 | + | require.NoError(t, err) | |
| 13 | + | require.Equal(t, kindProductionBrokerServe, serve.kind) | |
| 14 | + | require.True(t, serve.brokerServe.production) | |
| 15 | + | ||
| 16 | + | launcher, err := parseArgs([]string{productionBrokerLauncherCommand}) | |
| 17 | + | require.NoError(t, err) | |
| 18 | + | require.Equal(t, kindProductionBrokerLauncher, launcher.kind) | |
| 19 | + | ||
| 20 | + | for _, name := range []string{productionBrokerServeCommand, productionBrokerLauncherCommand} { | |
| 21 | + | _, err = parseArgs([]string{name, "--offline-root", t.TempDir()}) | |
| 22 | + | require.Error(t, err) | |
| 23 | + | } | |
| 24 | + | } | |
| 25 | + | ||
| 10 | 26 | func TestProductionBrokerLayoutUsesDedicatedRuntimeStateAndConfig(t *testing.T) { | |
| 11 | 27 | t.Setenv("XDG_RUNTIME_DIR", t.TempDir()) | |
| 12 | 28 | t.Setenv("XDG_STATE_HOME", t.TempDir()) | |
A internal/app/broker_ready.go +301 -0
| @@ -0,0 +1,301 @@ | |||
| 1 | + | package app | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/hex" | |
| 6 | + | "encoding/json" | |
| 7 | + | "errors" | |
| 8 | + | "fmt" | |
| 9 | + | "io" | |
| 10 | + | "io/fs" | |
| 11 | + | "os" | |
| 12 | + | "path/filepath" | |
| 13 | + | "strconv" | |
| 14 | + | "time" | |
| 15 | + | ||
| 16 | + | "github.com/bnema/vev/internal/adapters/brokerconfig" | |
| 17 | + | "github.com/bnema/vev/internal/adapters/brokeripc" | |
| 18 | + | "github.com/bnema/vev/internal/adapters/ipc" | |
| 19 | + | "github.com/bnema/vev/internal/domain" | |
| 20 | + | "github.com/bnema/vev/internal/ports" | |
| 21 | + | "github.com/bnema/vev/internal/protocol" | |
| 22 | + | ) | |
| 23 | + | ||
| 24 | + | const brokerReadyCommand = "_broker-ready" | |
| 25 | + | ||
| 26 | + | type brokerReadyOptions struct { | |
| 27 | + | offlineRoot string | |
| 28 | + | require string | |
| 29 | + | timeout time.Duration | |
| 30 | + | maxAge time.Duration | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | type brokerReadyResult struct { | |
| 34 | + | Schema string `json:"schema"` | |
| 35 | + | Status string `json:"status"` | |
| 36 | + | Require string `json:"require"` | |
| 37 | + | Reason string `json:"reason"` | |
| 38 | + | Pending []string `json:"pending"` | |
| 39 | + | Epoch string `json:"epoch"` | |
| 40 | + | Revision string `json:"revision"` | |
| 41 | + | Local *brokerReadyLocal `json:"local"` | |
| 42 | + | } | |
| 43 | + | type brokerReadyLocal struct { | |
| 44 | + | Identity string `json:"identity"` | |
| 45 | + | Incarnation string `json:"incarnation"` | |
| 46 | + | Version string `json:"version"` | |
| 47 | + | Availability string `json:"availability"` | |
| 48 | + | InventoryKnown bool `json:"inventory_known"` | |
| 49 | + | LastSuccess string `json:"last_success"` | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | func parseBrokerReadyArgs(args []string) (brokerReadyOptions, error) { | |
| 53 | + | o := brokerReadyOptions{timeout: 10 * time.Second, maxAge: 5 * time.Second} | |
| 54 | + | seen := map[string]bool{} | |
| 55 | + | for i := 0; i < len(args); i += 2 { | |
| 56 | + | if i+1 >= len(args) { | |
| 57 | + | return o, usagef("`%s` requires a value", args[i]) | |
| 58 | + | } | |
| 59 | + | name, value := args[i], args[i+1] | |
| 60 | + | if seen[name] { | |
| 61 | + | return o, usagef("duplicate `%s`", name) | |
| 62 | + | } | |
| 63 | + | seen[name] = true | |
| 64 | + | switch name { | |
| 65 | + | case "--offline-root": | |
| 66 | + | o.offlineRoot = value | |
| 67 | + | case "--require": | |
| 68 | + | o.require = value | |
| 69 | + | case "--timeout": | |
| 70 | + | d, err := time.ParseDuration(value) | |
| 71 | + | if err != nil || d <= 0 { | |
| 72 | + | return o, usagef("`--timeout` must be positive") | |
| 73 | + | } | |
| 74 | + | o.timeout = d | |
| 75 | + | case "--max-observation-age": | |
| 76 | + | d, err := time.ParseDuration(value) | |
| 77 | + | if err != nil || d <= 0 { | |
| 78 | + | return o, usagef("`--max-observation-age` must be positive") | |
| 79 | + | } | |
| 80 | + | o.maxAge = d | |
| 81 | + | default: | |
| 82 | + | return o, usagef("unknown `%s` option", name) | |
| 83 | + | } | |
| 84 | + | } | |
| 85 | + | if o.require != "local-authority" && o.require != "local-catalogue" { | |
| 86 | + | return o, usagef("`--require` must be local-authority or local-catalogue") | |
| 87 | + | } | |
| 88 | + | if o.require != "local-catalogue" && seen["--max-observation-age"] { | |
| 89 | + | return o, usagef("`--max-observation-age` is only valid for local-catalogue") | |
| 90 | + | } | |
| 91 | + | if o.offlineRoot != "" && (!filepath.IsAbs(o.offlineRoot) || filepath.Clean(o.offlineRoot) != o.offlineRoot) { | |
| 92 | + | return o, usagef("`--offline-root` must be an absolute clean path") | |
| 93 | + | } | |
| 94 | + | return o, nil | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | var brokerReadyDial = func(ctx context.Context, path string) (ports.BrokerService, error) { | |
| 98 | + | return brokeripc.Dial(ctx, path, brokeripc.Config{}) | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | func runBrokerReady(ctx context.Context, o brokerReadyOptions, out io.Writer) error { | |
| 102 | + | deadline, cancel := context.WithTimeout(ctx, o.timeout) | |
| 103 | + | defer cancel() | |
| 104 | + | path := brokeripc.SocketPath(productionBrokerLayout().Runtime) | |
| 105 | + | if o.offlineRoot != "" { | |
| 106 | + | layout, err := brokerconfig.ResolveLayout(o.offlineRoot, []string{productionBrokerLayout().Runtime, productionBrokerLayout().State}) | |
| 107 | + | if err != nil { | |
| 108 | + | return writeReady(out, o, "terminal", "config", nil, 4) | |
| 109 | + | } | |
| 110 | + | path = brokeripc.SocketPath(layout.Runtime) | |
| 111 | + | } | |
| 112 | + | backoff := 5 * time.Millisecond | |
| 113 | + | for { | |
| 114 | + | if err := deadline.Err(); err != nil { | |
| 115 | + | if errors.Is(ctx.Err(), context.Canceled) { | |
| 116 | + | return writeReady(out, o, "canceled", "canceled", nil, 5) | |
| 117 | + | } | |
| 118 | + | return writeReady(out, o, "timeout", "deadline", nil, 3) | |
| 119 | + | } | |
| 120 | + | // A path that exists but is not a Unix socket, and a path this process | |
| 121 | + | // may not inspect, are terminal security failures: retrying them into | |
| 122 | + | // the deadline would report a foreign path as a slow broker. Only | |
| 123 | + | // proven absence is retried, which is what lets an on-demand broker | |
| 124 | + | // still come up under this probe. | |
| 125 | + | if reason, terminal := brokerReadyEndpointSecurity(path); terminal { | |
| 126 | + | return writeReady(out, o, "terminal", reason, nil, 4) | |
| 127 | + | } | |
| 128 | + | service, err := brokerReadyDial(deadline, path) | |
| 129 | + | if err != nil { | |
| 130 | + | if reason, terminal := terminalReadyReason(err); terminal { | |
| 131 | + | return writeReady(out, o, "terminal", reason, nil, 4) | |
| 132 | + | } | |
| 133 | + | select { | |
| 134 | + | case <-deadline.Done(): | |
| 135 | + | continue | |
| 136 | + | case <-time.After(backoff): | |
| 137 | + | } | |
| 138 | + | if backoff < 50*time.Millisecond { | |
| 139 | + | backoff *= 2 | |
| 140 | + | if backoff > 50*time.Millisecond { | |
| 141 | + | backoff = 50 * time.Millisecond | |
| 142 | + | } | |
| 143 | + | } | |
| 144 | + | continue | |
| 145 | + | } | |
| 146 | + | result, reconnect := observeBrokerReady(deadline, o, service) | |
| 147 | + | _ = service.Close() | |
| 148 | + | if result != nil { | |
| 149 | + | return emitReady(out, *result) | |
| 150 | + | } | |
| 151 | + | if !reconnect { | |
| 152 | + | continue | |
| 153 | + | } | |
| 154 | + | } | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | func observeBrokerReady(ctx context.Context, o brokerReadyOptions, service ports.BrokerService) (*brokerReadyResult, bool) { | |
| 158 | + | sub, err := service.Subscribe() | |
| 159 | + | if err != nil { | |
| 160 | + | r := readyResult(o, "terminal", "protocol", nil) | |
| 161 | + | return &r, false | |
| 162 | + | } | |
| 163 | + | defer sub.Close() | |
| 164 | + | for { | |
| 165 | + | snapshot := service.Snapshot() | |
| 166 | + | if snapshot.Epoch != 0 { | |
| 167 | + | if snapshot.Validate() != nil { | |
| 168 | + | r := readyResult(o, "terminal", "protocol", nil) | |
| 169 | + | return &r, false | |
| 170 | + | } | |
| 171 | + | locals := make([]ports.BrokerDaemonObservation, 0, 1) | |
| 172 | + | for _, d := range snapshot.Daemons { | |
| 173 | + | if d.Local { | |
| 174 | + | locals = append(locals, d) | |
| 175 | + | } | |
| 176 | + | } | |
| 177 | + | if len(locals) > 1 { | |
| 178 | + | r := readyResult(o, "terminal", "protocol", nil) | |
| 179 | + | return &r, false | |
| 180 | + | } | |
| 181 | + | if len(locals) == 1 { | |
| 182 | + | r := evaluateReady(o, snapshot, locals[0]) | |
| 183 | + | if r.Status == "ready" || r.Status == "terminal" { | |
| 184 | + | return &r, false | |
| 185 | + | } | |
| 186 | + | } | |
| 187 | + | } | |
| 188 | + | select { | |
| 189 | + | case <-ctx.Done(): | |
| 190 | + | return nil, false | |
| 191 | + | case <-service.Done(): | |
| 192 | + | return nil, true | |
| 193 | + | case <-sub.Changed(): | |
| 194 | + | } | |
| 195 | + | } | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | func evaluateReady(o brokerReadyOptions, s ports.BrokerSnapshot, local ports.BrokerDaemonObservation) brokerReadyResult { | |
| 199 | + | r := readyResult(o, "pending", "local-not-ready", &local) | |
| 200 | + | r.Epoch = strconv.FormatUint(uint64(s.Epoch), 10) | |
| 201 | + | r.Revision = strconv.FormatUint(uint64(s.Revision), 10) | |
| 202 | + | if o.require == "local-authority" { | |
| 203 | + | r.Status = "ready" | |
| 204 | + | r.Reason = "authority" | |
| 205 | + | r.Pending = []string{} | |
| 206 | + | return r | |
| 207 | + | } | |
| 208 | + | valid := local.Availability == domain.RemoteAvailabilityReachable && local.InventoryKnown && local.Identity.Validate() == nil && local.Incarnation.Validate() == nil && local.ProtocolVersion == protocol.Version && !local.LastSuccess.IsZero() | |
| 209 | + | age := time.Since(local.LastSuccess) | |
| 210 | + | valid = valid && age >= 0 && age <= o.maxAge | |
| 211 | + | if valid { | |
| 212 | + | r.Status = "ready" | |
| 213 | + | r.Reason = "catalogue" | |
| 214 | + | r.Pending = []string{} | |
| 215 | + | } else { | |
| 216 | + | r.Pending = []string{"local-catalogue"} | |
| 217 | + | } | |
| 218 | + | return r | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | func readyResult(o brokerReadyOptions, status, reason string, local *ports.BrokerDaemonObservation) brokerReadyResult { | |
| 222 | + | r := brokerReadyResult{Schema: "vev.broker-ready/v1", Status: status, Require: o.require, Reason: reason, Pending: []string{o.require}, Epoch: "0", Revision: "0"} | |
| 223 | + | if local != nil { | |
| 224 | + | r.Local = &brokerReadyLocal{Identity: string(local.Identity), Incarnation: hex.EncodeToString(local.Incarnation[:]), Version: strconv.FormatUint(uint64(local.ProtocolVersion), 10), Availability: local.Availability.String(), InventoryKnown: local.InventoryKnown, LastSuccess: local.LastSuccess.UTC().Format(time.RFC3339Nano)} | |
| 225 | + | } | |
| 226 | + | return r | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | // brokerReadyEndpointSecurity classifies the selected endpoint path before any | |
| 230 | + | // dial. Absence stays retryable; a path that exists and is not a Unix socket, | |
| 231 | + | // and a path this process may not inspect, are terminal security failures. Only | |
| 232 | + | // the specific not-exist outcome is absence, so an unreadable parent, a foreign | |
| 233 | + | // path, or any other inspection failure fails closed. | |
| 234 | + | func brokerReadyEndpointSecurity(path string) (string, bool) { | |
| 235 | + | info, err := os.Lstat(path) | |
| 236 | + | switch { | |
| 237 | + | case errors.Is(err, fs.ErrNotExist): | |
| 238 | + | return "", false | |
| 239 | + | case err != nil: | |
| 240 | + | return "security", true | |
| 241 | + | case info.Mode()&os.ModeSocket == 0: | |
| 242 | + | return "security", true | |
| 243 | + | default: | |
| 244 | + | return "", false | |
| 245 | + | } | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | // terminalReadyReason classifies one failed dial attempt that must never be | |
| 249 | + | // retried into the readiness deadline. A refusal that proves the path, the peer, | |
| 250 | + | // the credentials, the configuration, or the broker conversation itself is not | |
| 251 | + | // the expected broker is terminal; every other outcome, including an absent or | |
| 252 | + | // refused socket, stays retryable so a broker can still come up. | |
| 253 | + | func terminalReadyReason(err error) (string, bool) { | |
| 254 | + | switch { | |
| 255 | + | case err == nil: | |
| 256 | + | return "", false | |
| 257 | + | case errors.Is(err, context.Canceled), errors.Is(err, context.DeadlineExceeded): | |
| 258 | + | return "", false | |
| 259 | + | case errors.Is(err, ipc.ErrMuxPath), errors.Is(err, ipc.ErrMuxForeignPath), | |
| 260 | + | errors.Is(err, ipc.ErrMuxPeerRejected), errors.Is(err, ipc.ErrMuxUnsupported), | |
| 261 | + | errors.Is(err, os.ErrPermission): | |
| 262 | + | return "security", true | |
| 263 | + | case errors.Is(err, brokeripc.ErrConfig): | |
| 264 | + | return "config", true | |
| 265 | + | case errors.Is(err, brokeripc.ErrProtocol), errors.Is(err, brokeripc.ErrMalformedFrame), | |
| 266 | + | errors.Is(err, ipc.ErrFrameTooLarge), errors.Is(err, ipc.ErrZeroLengthFrame): | |
| 267 | + | return "protocol", true | |
| 268 | + | default: | |
| 269 | + | return "", false | |
| 270 | + | } | |
| 271 | + | } | |
| 272 | + | ||
| 273 | + | func emitReady(w io.Writer, r brokerReadyResult) error { | |
| 274 | + | b, err := json.Marshal(r) | |
| 275 | + | if err != nil || len(b)+1 > 4096 { | |
| 276 | + | return &exitCoded{code: 1, err: errors.New("broker readiness output failure")} | |
| 277 | + | } | |
| 278 | + | if _, err = w.Write(append(b, '\n')); err != nil { | |
| 279 | + | return &exitCoded{code: 1, err: errors.New("broker readiness output failure")} | |
| 280 | + | } | |
| 281 | + | switch r.Status { | |
| 282 | + | case "ready": | |
| 283 | + | return nil | |
| 284 | + | case "timeout": | |
| 285 | + | return &exitCoded{code: 3, err: errors.New("broker readiness timeout")} | |
| 286 | + | case "terminal": | |
| 287 | + | return &exitCoded{code: 4, err: errors.New("broker readiness terminal failure")} | |
| 288 | + | case "canceled": | |
| 289 | + | return &exitCoded{code: 5, err: context.Canceled} | |
| 290 | + | default: | |
| 291 | + | return &exitCoded{code: 3, err: errors.New("broker readiness timeout")} | |
| 292 | + | } | |
| 293 | + | } | |
| 294 | + | func writeReady(w io.Writer, o brokerReadyOptions, status, reason string, local *ports.BrokerDaemonObservation, code int) error { | |
| 295 | + | r := readyResult(o, status, reason, local) | |
| 296 | + | err := emitReady(w, r) | |
| 297 | + | if code != 0 { | |
| 298 | + | return &exitCoded{code: code, err: fmt.Errorf("broker readiness %s", reason)} | |
| 299 | + | } | |
| 300 | + | return err | |
| 301 | + | } | |
A internal/app/broker_ready_subprocess_test.go +352 -0
| @@ -0,0 +1,352 @@ | |||
| 1 | + | package app | |
| 2 | + | ||
| 3 | + | // Real-subprocess coverage for the hidden `_broker-ready` probe. | |
| 4 | + | // | |
| 5 | + | // These tests re-execute the test binary through the production dispatch path | |
| 6 | + | // (TestMain intercepts the hidden broker commands when brokerHelperEnv is set), | |
| 7 | + | // so argument parsing, endpoint classification, the real broker IPC setup, the | |
| 8 | + | // readiness evaluation, the JSON document, and the process exit code are all | |
| 9 | + | // exercised exactly as an operator would observe them. A real `_broker-serve` | |
| 10 | + | // composes the offline broker over one isolated root, and the probe reads it | |
| 11 | + | // over a real AF_UNIX carriage. | |
| 12 | + | ||
| 13 | + | import ( | |
| 14 | + | "encoding/json" | |
| 15 | + | "errors" | |
| 16 | + | "fmt" | |
| 17 | + | "net" | |
| 18 | + | "os" | |
| 19 | + | "os/exec" | |
| 20 | + | "path/filepath" | |
| 21 | + | "strings" | |
| 22 | + | "syscall" | |
| 23 | + | "testing" | |
| 24 | + | "time" | |
| 25 | + | ||
| 26 | + | "github.com/stretchr/testify/require" | |
| 27 | + | ||
| 28 | + | "github.com/bnema/vev/internal/adapters/brokerconfig" | |
| 29 | + | "github.com/bnema/vev/internal/adapters/brokeripc" | |
| 30 | + | ) | |
| 31 | + | ||
| 32 | + | // readySubprocessSandbox isolates one real offline root and records every | |
| 33 | + | // spawned broker helper, exactly like the status subprocess sandbox. | |
| 34 | + | type readySubprocessSandbox struct { | |
| 35 | + | t *testing.T | |
| 36 | + | root string | |
| 37 | + | layout brokerconfig.Layout | |
| 38 | + | recordDir string | |
| 39 | + | prodRuntime string | |
| 40 | + | prodState string | |
| 41 | + | broker *exec.Cmd | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | // newReadySubprocessSandbox writes one marker-valid empty sandbox and arranges | |
| 45 | + | // cleanup of every recorded broker process. | |
| 46 | + | func newReadySubprocessSandbox(t *testing.T) *readySubprocessSandbox { | |
| 47 | + | t.Helper() | |
| 48 | + | prodRuntime, prodState := t.TempDir(), t.TempDir() | |
| 49 | + | t.Setenv("XDG_RUNTIME_DIR", prodRuntime) | |
| 50 | + | t.Setenv("XDG_STATE_HOME", prodState) | |
| 51 | + | root := filepath.Join(shortTempDir(t, "vevr"), "s") | |
| 52 | + | require.NoError(t, os.MkdirAll(root, 0o700)) | |
| 53 | + | writeSandboxConfig(t, root, sandboxEmptyDocument("3s")) | |
| 54 | + | recordDir := filepath.Join(shortTempDir(t, "vevw"), "r") | |
| 55 | + | require.NoError(t, os.MkdirAll(recordDir, 0o700)) | |
| 56 | + | t.Setenv(brokerHelperEnv, "1") | |
| 57 | + | t.Setenv(brokerHelperRecordDirEnv, recordDir) | |
| 58 | + | layout, err := offlineLayout(root) | |
| 59 | + | require.NoError(t, err) | |
| 60 | + | ||
| 61 | + | sandbox := &readySubprocessSandbox{t: t, root: root, layout: layout, recordDir: recordDir, prodRuntime: prodRuntime, prodState: prodState} | |
| 62 | + | t.Cleanup(sandbox.cleanup) | |
| 63 | + | return sandbox | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | // socketPath is the broker endpoint inside the sandbox runtime directory. | |
| 67 | + | func (s *readySubprocessSandbox) socketPath() string { | |
| 68 | + | return brokeripc.SocketPath(s.layout.Runtime) | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | // ready runs `_broker-ready` for this sandbox with the supplied extra flags and | |
| 72 | + | // returns its stdout, stderr, and exit code. | |
| 73 | + | func (s *readySubprocessSandbox) ready(timeout string, extra ...string) (string, string, int) { | |
| 74 | + | s.t.Helper() | |
| 75 | + | args := append([]string{brokerReadyCommand, "--offline-root", s.root, "--timeout", timeout}, extra...) | |
| 76 | + | return runBrokerCommand(s.t, args...) | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | // startBroker runs one foreground `_broker-serve` helper in the background. | |
| 80 | + | func (s *readySubprocessSandbox) startBroker() { | |
| 81 | + | s.t.Helper() | |
| 82 | + | command := exec.Command(os.Args[0], brokerServeCommand, "--offline-root", s.root) | |
| 83 | + | command.Env = os.Environ() | |
| 84 | + | command.Stdout = os.Stdout | |
| 85 | + | command.Stderr = os.Stderr | |
| 86 | + | require.NoError(s.t, command.Start()) | |
| 87 | + | s.broker = command | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | // waitForBrokerSocket waits until the broker has bound its endpoint. | |
| 91 | + | func (s *readySubprocessSandbox) waitForBrokerSocket(timeout time.Duration) bool { | |
| 92 | + | s.t.Helper() | |
| 93 | + | deadline := time.Now().Add(timeout) | |
| 94 | + | for time.Now().Before(deadline) { | |
| 95 | + | if info, err := os.Lstat(s.socketPath()); err == nil && info.Mode()&os.ModeSocket != 0 { | |
| 96 | + | return true | |
| 97 | + | } | |
| 98 | + | time.Sleep(10 * time.Millisecond) | |
| 99 | + | } | |
| 100 | + | return false | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | // cleanup terminates the background broker and every recorded serve process so | |
| 104 | + | // no test leaks an orphan. | |
| 105 | + | func (s *readySubprocessSandbox) cleanup() { | |
| 106 | + | if s.broker != nil && s.broker.Process != nil { | |
| 107 | + | _ = s.broker.Process.Signal(syscall.SIGTERM) | |
| 108 | + | done := make(chan struct{}) | |
| 109 | + | go func() { _ = s.broker.Wait(); close(done) }() | |
| 110 | + | select { | |
| 111 | + | case <-done: | |
| 112 | + | case <-time.After(10 * time.Second): | |
| 113 | + | _ = s.broker.Process.Kill() | |
| 114 | + | <-done | |
| 115 | + | } | |
| 116 | + | } | |
| 117 | + | for _, pid := range recordedPIDsIn(s.t, s.recordDir, brokerServeCommand) { | |
| 118 | + | if err := syscall.Kill(pid, syscall.SIGKILL); err != nil && !errors.Is(err, syscall.ESRCH) { | |
| 119 | + | continue | |
| 120 | + | } | |
| 121 | + | _ = waitForProcessExit(pid, 10*time.Second) | |
| 122 | + | } | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | // readyDoc is the bounded readiness report the probe prints. | |
| 126 | + | type readyDoc struct { | |
| 127 | + | Schema string `json:"schema"` | |
| 128 | + | Status string `json:"status"` | |
| 129 | + | Require string `json:"require"` | |
| 130 | + | Reason string `json:"reason"` | |
| 131 | + | Pending []string `json:"pending"` | |
| 132 | + | Epoch string `json:"epoch"` | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | // decodeReadyReport decodes exactly one bounded JSON report from stdout. | |
| 136 | + | func decodeReadyReport(t *testing.T, raw string) readyDoc { | |
| 137 | + | t.Helper() | |
| 138 | + | trimmed := strings.TrimSpace(raw) | |
| 139 | + | require.Equal(t, 1, strings.Count(trimmed, "\n")+1, "the probe prints exactly one report line") | |
| 140 | + | var report readyDoc | |
| 141 | + | require.NoError(t, json.Unmarshal([]byte(trimmed), &report)) | |
| 142 | + | require.Equal(t, "vev.broker-ready/v1", report.Schema) | |
| 143 | + | return report | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | // TestBrokerReadySubprocessReadsARealBrokerAuthority proves the positive path | |
| 147 | + | // end to end: a real `_broker-serve` over an isolated root publishes its local | |
| 148 | + | // daemon entry, `--require local-authority` is satisfied with exit zero and the | |
| 149 | + | // broker epoch, and `--require local-catalogue` stays pending because the | |
| 150 | + | // provisioned local route is absent, so it is never falsely reported ready. | |
| 151 | + | func TestBrokerReadySubprocessReadsARealBrokerAuthority(t *testing.T) { | |
| 152 | + | sandbox := newReadySubprocessSandbox(t) | |
| 153 | + | // One provisioned local binding whose route nothing listens on: the broker | |
| 154 | + | // publishes its local entry as configured authority, so `local-authority` is | |
| 155 | + | // observable while the catalogue is not. | |
| 156 | + | require.NoError(t, os.WriteFile(filepath.Join(sandbox.root, brokerconfig.ConfigFileName), | |
| 157 | + | []byte(fmt.Sprintf(`{"marker":%q,"registrations":[],"local":{"identity":"subprocess-local","displayOrigin":"local","route":%q,"policy":{"protocolVersion":%d,"catalogSchemaVersion":3,"environmentPolicy":"client-owned","transport":"unix-mux","trust":"same-user","launch":"explicit","isolation":"per-user"}}}`, | |
| 158 | + | brokerconfig.Marker, filepath.Join(sandbox.root, "absent-mux.sock"), readyTestPolicy().ProtocolVersion)), 0o600)) | |
| 159 | + | layout, err := offlineLayout(sandbox.root) | |
| 160 | + | require.NoError(t, err) | |
| 161 | + | sandbox.layout = layout | |
| 162 | + | ||
| 163 | + | sandbox.startBroker() | |
| 164 | + | require.True(t, sandbox.waitForBrokerSocket(15*time.Second), "the broker must bind its endpoint") | |
| 165 | + | ||
| 166 | + | stdout, stderr, code := sandbox.ready("5s", "--require", "local-authority") | |
| 167 | + | require.Equal(t, 0, code, "stderr: %s", stderr) | |
| 168 | + | authority := decodeReadyReport(t, stdout) | |
| 169 | + | require.Equal(t, "ready", authority.Status) | |
| 170 | + | require.Equal(t, "authority", authority.Reason) | |
| 171 | + | require.Equal(t, []string{}, authority.Pending) | |
| 172 | + | require.Equal(t, "local-authority", authority.Require) | |
| 173 | + | require.NotEqual(t, "0", authority.Epoch, "a live broker reports its own epoch") | |
| 174 | + | require.NotEmpty(t, strings.TrimSpace(stdout)) | |
| 175 | + | ||
| 176 | + | stdout, stderr, code = sandbox.ready("5s", "--require", "local-catalogue") | |
| 177 | + | require.Equal(t, 3, code, "stderr: %s", stderr) | |
| 178 | + | catalogue := decodeReadyReport(t, stdout) | |
| 179 | + | require.Equal(t, "timeout", catalogue.Status) | |
| 180 | + | require.Equal(t, []string{"local-catalogue"}, catalogue.Pending) | |
| 181 | + | require.NotEqual(t, "ready", catalogue.Status, "an unobserved catalogue is never reported ready") | |
| 182 | + | ||
| 183 | + | requireProductionUntouched(t, sandbox.prodRuntime, sandbox.prodState) | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | // TestBrokerReadySubprocessStaleSocketIsNeverReady proves a socket file left by | |
| 187 | + | // a dead owner is retried into the deadline: the probe reports timeout, never a | |
| 188 | + | // false ready, and never leaves a second socket behind. | |
| 189 | + | func TestBrokerReadySubprocessStaleSocketIsNeverReady(t *testing.T) { | |
| 190 | + | sandbox := newReadySubprocessSandbox(t) | |
| 191 | + | require.NoError(t, os.MkdirAll(sandbox.layout.Runtime, 0o700)) | |
| 192 | + | bindStaleUnixSocket(t, sandbox.socketPath()) | |
| 193 | + | ||
| 194 | + | start := time.Now() | |
| 195 | + | stdout, _, code := sandbox.ready("500ms", "--require", "local-authority") | |
| 196 | + | require.Equal(t, 3, code) | |
| 197 | + | report := decodeReadyReport(t, stdout) | |
| 198 | + | require.Equal(t, "timeout", report.Status) | |
| 199 | + | require.Equal(t, "deadline", report.Reason) | |
| 200 | + | require.Less(t, time.Since(start), 10*time.Second, "the probe is bounded by its own timeout") | |
| 201 | + | ||
| 202 | + | // A stale socket is recoverable absence, so nothing was started over it: the | |
| 203 | + | // path is still the same dead socket file and no helper process ran. | |
| 204 | + | info, err := os.Lstat(sandbox.socketPath()) | |
| 205 | + | require.NoError(t, err) | |
| 206 | + | require.NotZero(t, info.Mode()&os.ModeSocket) | |
| 207 | + | require.Empty(t, recordedPIDsIn(t, sandbox.recordDir, brokerServeCommand)) | |
| 208 | + | requireProductionUntouched(t, sandbox.prodRuntime, sandbox.prodState) | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | // TestBrokerReadySubprocessBlockedHandshakeIsNeverReady proves a live endpoint | |
| 212 | + | // that never answers the broker preamble is never reported ready: the probe | |
| 213 | + | // stays inside its own bound and reports timeout. | |
| 214 | + | func TestBrokerReadySubprocessBlockedHandshakeIsNeverReady(t *testing.T) { | |
| 215 | + | sandbox := newReadySubprocessSandbox(t) | |
| 216 | + | require.NoError(t, os.MkdirAll(sandbox.layout.Runtime, 0o700)) | |
| 217 | + | // A listener that accepts and never answers: the broker preamble stalls. | |
| 218 | + | _ = bindUnixSocket(t, sandbox.socketPath()) | |
| 219 | + | ||
| 220 | + | start := time.Now() | |
| 221 | + | stdout, _, code := sandbox.ready("700ms", "--require", "local-authority") | |
| 222 | + | require.Equal(t, 3, code) | |
| 223 | + | report := decodeReadyReport(t, stdout) | |
| 224 | + | require.Equal(t, "timeout", report.Status) | |
| 225 | + | require.NotEqual(t, "ready", report.Status, "a stalled handshake is never a false ready") | |
| 226 | + | require.Less(t, time.Since(start), 10*time.Second) | |
| 227 | + | ||
| 228 | + | // A stalled-but-live endpoint is never respawned over. | |
| 229 | + | require.Empty(t, recordedPIDsIn(t, sandbox.recordDir, brokerServeCommand)) | |
| 230 | + | requireProductionUntouched(t, sandbox.prodRuntime, sandbox.prodState) | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | // TestBrokerReadySubprocessNonSocketPathIsTerminal proves a path that exists but | |
| 234 | + | // is not a Unix socket is a terminal security failure (exit 4) rather than a | |
| 235 | + | // retryable absence, for both a regular file and a directory. | |
| 236 | + | func TestBrokerReadySubprocessNonSocketPathIsTerminal(t *testing.T) { | |
| 237 | + | tests := []struct { | |
| 238 | + | name string | |
| 239 | + | place func(t *testing.T, socketPath string) | |
| 240 | + | }{ | |
| 241 | + | { | |
| 242 | + | name: "regular file", | |
| 243 | + | place: func(t *testing.T, socketPath string) { | |
| 244 | + | require.NoError(t, os.WriteFile(socketPath, []byte("not a socket"), 0o600)) | |
| 245 | + | }, | |
| 246 | + | }, | |
| 247 | + | { | |
| 248 | + | name: "directory", | |
| 249 | + | place: func(t *testing.T, socketPath string) { | |
| 250 | + | require.NoError(t, os.MkdirAll(socketPath, 0o700)) | |
| 251 | + | }, | |
| 252 | + | }, | |
| 253 | + | } | |
| 254 | + | for _, tt := range tests { | |
| 255 | + | t.Run(tt.name, func(t *testing.T) { | |
| 256 | + | sandbox := newReadySubprocessSandbox(t) | |
| 257 | + | require.NoError(t, os.MkdirAll(sandbox.layout.Runtime, 0o700)) | |
| 258 | + | tt.place(t, sandbox.socketPath()) | |
| 259 | + | ||
| 260 | + | stdout, stderr, code := sandbox.ready("500ms", "--require", "local-authority") | |
| 261 | + | require.Equal(t, 4, code, "stderr: %s", stderr) | |
| 262 | + | report := decodeReadyReport(t, stdout) | |
| 263 | + | require.Equal(t, "terminal", report.Status) | |
| 264 | + | require.Equal(t, "security", report.Reason) | |
| 265 | + | require.Empty(t, recordedPIDsIn(t, sandbox.recordDir, brokerServeCommand)) | |
| 266 | + | }) | |
| 267 | + | } | |
| 268 | + | } | |
| 269 | + | ||
| 270 | + | // TestBrokerReadySubprocessPermissionRefusalIsTerminal proves a path this | |
| 271 | + | // process may not inspect is a terminal security failure with exit 4, and never | |
| 272 | + | // a timeout or an absence that would invite a spawn. | |
| 273 | + | func TestBrokerReadySubprocessPermissionRefusalIsTerminal(t *testing.T) { | |
| 274 | + | if os.Geteuid() == 0 { | |
| 275 | + | t.Skip("an unreadable directory does not deny root") | |
| 276 | + | } | |
| 277 | + | sandbox := newReadySubprocessSandbox(t) | |
| 278 | + | require.NoError(t, os.MkdirAll(sandbox.layout.Runtime, 0o700)) | |
| 279 | + | require.NoError(t, os.Chmod(sandbox.layout.Runtime, 0o000)) | |
| 280 | + | t.Cleanup(func() { _ = os.Chmod(sandbox.layout.Runtime, 0o700) }) | |
| 281 | + | ||
| 282 | + | stdout, stderr, code := sandbox.ready("500ms", "--require", "local-authority") | |
| 283 | + | require.Equal(t, 4, code, "stderr: %s", stderr) | |
| 284 | + | report := decodeReadyReport(t, stdout) | |
| 285 | + | require.Equal(t, "terminal", report.Status) | |
| 286 | + | require.Equal(t, "security", report.Reason) | |
| 287 | + | require.Empty(t, recordedPIDsIn(t, sandbox.recordDir, brokerServeCommand)) | |
| 288 | + | } | |
| 289 | + | ||
| 290 | + | // TestBrokerReadySubprocessAbsentSocketTimesOut proves a missing endpoint is | |
| 291 | + | // retried into the deadline and reported as a timeout with no broker spawned. | |
| 292 | + | func TestBrokerReadySubprocessAbsentSocketTimesOut(t *testing.T) { | |
| 293 | + | sandbox := newReadySubprocessSandbox(t) | |
| 294 | + | _, statErr := os.Lstat(sandbox.socketPath()) | |
| 295 | + | require.ErrorIs(t, statErr, os.ErrNotExist) | |
| 296 | + | ||
| 297 | + | stdout, _, code := sandbox.ready("400ms", "--require", "local-authority") | |
| 298 | + | require.Equal(t, 3, code) | |
| 299 | + | report := decodeReadyReport(t, stdout) | |
| 300 | + | require.Equal(t, "timeout", report.Status) | |
| 301 | + | require.Equal(t, "deadline", report.Reason) | |
| 302 | + | require.Empty(t, recordedPIDsIn(t, sandbox.recordDir, brokerServeCommand), "the readiness probe never ensures or spawns") | |
| 303 | + | } | |
| 304 | + | ||
| 305 | + | // TestBrokerReadySubprocessNonBrokerEndpointIsTerminal proves a live endpoint | |
| 306 | + | // that answers the broker setup with foreign framing is a terminal protocol | |
| 307 | + | // failure (exit 4) rather than a retryable absence, so a socket that is not the | |
| 308 | + | // expected broker is never waited out as if it were merely slow. | |
| 309 | + | func TestBrokerReadySubprocessNonBrokerEndpointIsTerminal(t *testing.T) { | |
| 310 | + | sandbox := newReadySubprocessSandbox(t) | |
| 311 | + | require.NoError(t, os.MkdirAll(sandbox.layout.Runtime, 0o700)) | |
| 312 | + | listener := bindUnixSocket(t, sandbox.socketPath()) | |
| 313 | + | go answerWithForeignBytes(listener) | |
| 314 | + | ||
| 315 | + | stdout, stderr, code := sandbox.ready("700ms", "--require", "local-authority") | |
| 316 | + | require.Equal(t, 4, code, "stderr: %s", stderr) | |
| 317 | + | report := decodeReadyReport(t, stdout) | |
| 318 | + | require.Equal(t, "terminal", report.Status) | |
| 319 | + | require.Equal(t, "protocol", report.Reason) | |
| 320 | + | require.Empty(t, recordedPIDsIn(t, sandbox.recordDir, brokerServeCommand)) | |
| 321 | + | requireProductionUntouched(t, sandbox.prodRuntime, sandbox.prodState) | |
| 322 | + | } | |
| 323 | + | ||
| 324 | + | // answerWithForeignBytes serves one connection per accept with a bounded | |
| 325 | + | // non-framed answer, standing in for a Unix socket that is not a broker. | |
| 326 | + | func answerWithForeignBytes(listener net.Listener) { | |
| 327 | + | for { | |
| 328 | + | conn, err := listener.Accept() | |
| 329 | + | if err != nil { | |
| 330 | + | return | |
| 331 | + | } | |
| 332 | + | go func() { | |
| 333 | + | defer func() { _ = conn.Close() }() | |
| 334 | + | buf := make([]byte, 4096) | |
| 335 | + | _ = conn.SetReadDeadline(time.Now().Add(3 * time.Second)) | |
| 336 | + | _, _ = conn.Read(buf) | |
| 337 | + | _, _ = conn.Write([]byte("this endpoint is not a vev broker at all.........")) | |
| 338 | + | }() | |
| 339 | + | } | |
| 340 | + | } | |
| 341 | + | ||
| 342 | + | // TestBrokerReadySubprocessArgumentRefusalIsUsage proves the production dispatch | |
| 343 | + | // path refuses an invalid invocation with the usage exit code and prints no | |
| 344 | + | // report at all. | |
| 345 | + | func TestBrokerReadySubprocessArgumentRefusalIsUsage(t *testing.T) { | |
| 346 | + | sandbox := newReadySubprocessSandbox(t) | |
| 347 | + | ||
| 348 | + | stdout, stderr, code := sandbox.ready("1s", "--require", "remote-catalogue") | |
| 349 | + | require.Equal(t, 2, code, "stderr: %s", stderr) | |
| 350 | + | require.Empty(t, strings.TrimSpace(stdout), "an argument refusal prints no report") | |
| 351 | + | require.NotEmpty(t, strings.TrimSpace(stderr)) | |
| 352 | + | } | |
A internal/app/broker_ready_test.go +970 -0
| @@ -0,0 +1,970 @@ | |||
| 1 | + | package app | |
| 2 | + | ||
| 3 | + | // Hidden `_broker-ready` dial-only probe contract (Plan 001 P3.4). | |
| 4 | + | // | |
| 5 | + | // The probe selects one broker IPC endpoint, dials it, registers, subscribes, | |
| 6 | + | // and reads complete snapshots. These tests pin the whole caller contract | |
| 7 | + | // without a production broker process: argument parsing, the bounded JSON | |
| 8 | + | // document, the closed exit-code table, the readiness evaluation of both | |
| 9 | + | // `--require` modes, the terminal classification that must never retry, the | |
| 10 | + | // reconnect on a new broker epoch, and the zero-effect guarantee (no ensure, no | |
| 11 | + | // spawn, no logical stream, no reconcile, and no membership mutation). | |
| 12 | + | ||
| 13 | + | import ( | |
| 14 | + | "bytes" | |
| 15 | + | "context" | |
| 16 | + | "encoding/json" | |
| 17 | + | "errors" | |
| 18 | + | "net" | |
| 19 | + | "os" | |
| 20 | + | "path/filepath" | |
| 21 | + | "strconv" | |
| 22 | + | "strings" | |
| 23 | + | "sync" | |
| 24 | + | "syscall" | |
| 25 | + | "testing" | |
| 26 | + | "time" | |
| 27 | + | ||
| 28 | + | "github.com/stretchr/testify/require" | |
| 29 | + | ||
| 30 | + | "github.com/bnema/vev/internal/adapters/brokeripc" | |
| 31 | + | "github.com/bnema/vev/internal/adapters/ipc" | |
| 32 | + | "github.com/bnema/vev/internal/domain" | |
| 33 | + | "github.com/bnema/vev/internal/ports" | |
| 34 | + | "github.com/bnema/vev/internal/protocol" | |
| 35 | + | ) | |
| 36 | + | ||
| 37 | + | const ( | |
| 38 | + | readyTestEpoch = ports.BrokerEpoch(0x2a) | |
| 39 | + | readyTestRevision = ports.BrokerRevision(7) | |
| 40 | + | readyTestIdentity = ports.BrokerDaemonIdentity("ready-local-daemon") | |
| 41 | + | readyTestObserveMax = 60 * time.Millisecond | |
| 42 | + | ) | |
| 43 | + | ||
| 44 | + | // readyTestIncarnate is one valid, non-zero daemon incarnation. | |
| 45 | + | var readyTestIncarnate = ports.BrokerDaemonIncarnation{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} | |
| 46 | + | ||
| 47 | + | // readyTestPolicy is one valid exactly-provisioned connection policy. | |
| 48 | + | func readyTestPolicy() ports.BrokerPolicy { | |
| 49 | + | return ports.BrokerPolicy{ | |
| 50 | + | ProtocolVersion: protocol.Version, | |
| 51 | + | CatalogSchemaVersion: 3, | |
| 52 | + | EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned, | |
| 53 | + | Transport: "unix-mux", | |
| 54 | + | Trust: "same-user", | |
| 55 | + | Launch: "explicit", | |
| 56 | + | Isolation: "per-user", | |
| 57 | + | } | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | // readyTestLocal builds one local daemon observation with the supplied observed | |
| 61 | + | // state, so a test states exactly which readiness input it varies. | |
| 62 | + | func readyTestLocal(mutate func(*ports.BrokerDaemonObservation)) ports.BrokerDaemonObservation { | |
| 63 | + | local := ports.BrokerDaemonObservation{ | |
| 64 | + | Local: true, | |
| 65 | + | DisplayOrigin: "local", | |
| 66 | + | Policy: readyTestPolicy(), | |
| 67 | + | Identity: readyTestIdentity, | |
| 68 | + | Incarnation: readyTestIncarnate, | |
| 69 | + | ProtocolVersion: protocol.Version, | |
| 70 | + | Availability: domain.RemoteAvailabilityReachable, | |
| 71 | + | InventoryKnown: true, | |
| 72 | + | LastSuccess: time.Now().UTC(), | |
| 73 | + | } | |
| 74 | + | if mutate != nil { | |
| 75 | + | mutate(&local) | |
| 76 | + | } | |
| 77 | + | return local | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | // readyTestSnapshot builds one valid publication carrying exactly one local | |
| 81 | + | // daemon observation. | |
| 82 | + | func readyTestSnapshot(mutate func(*ports.BrokerDaemonObservation)) ports.BrokerSnapshot { | |
| 83 | + | return ports.BrokerSnapshot{ | |
| 84 | + | Epoch: readyTestEpoch, | |
| 85 | + | Revision: readyTestRevision, | |
| 86 | + | Daemons: []ports.BrokerDaemonObservation{readyTestLocal(mutate)}, | |
| 87 | + | } | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | // readyScriptedSubscription is one coalescing capacity-one subscription. | |
| 91 | + | type readyScriptedSubscription struct { | |
| 92 | + | mu sync.Mutex | |
| 93 | + | changed chan struct{} | |
| 94 | + | closed int | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | func newReadyScriptedSubscription() *readyScriptedSubscription { | |
| 98 | + | return &readyScriptedSubscription{changed: make(chan struct{}, 1)} | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | func (s *readyScriptedSubscription) Changed() <-chan struct{} { return s.changed } | |
| 102 | + | ||
| 103 | + | func (s *readyScriptedSubscription) Close() { | |
| 104 | + | s.mu.Lock() | |
| 105 | + | s.closed++ | |
| 106 | + | s.mu.Unlock() | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | func (s *readyScriptedSubscription) closeCount() int { | |
| 110 | + | s.mu.Lock() | |
| 111 | + | defer s.mu.Unlock() | |
| 112 | + | return s.closed | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | // readyScriptedService is a scripted ports.BrokerService. It serves one fixed | |
| 116 | + | // snapshot, records every mutating or stream method it is asked for, and lets a | |
| 117 | + | // test end its connection to drive the reconnect path. | |
| 118 | + | type readyScriptedService struct { | |
| 119 | + | snapshot ports.BrokerSnapshot | |
| 120 | + | subErr error | |
| 121 | + | ||
| 122 | + | mu sync.Mutex | |
| 123 | + | done chan struct{} | |
| 124 | + | doneOnce sync.Once | |
| 125 | + | subs []*readyScriptedSubscription | |
| 126 | + | closes int | |
| 127 | + | mutations []string | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | func newReadyScriptedService(snapshot ports.BrokerSnapshot) *readyScriptedService { | |
| 131 | + | return &readyScriptedService{snapshot: snapshot, done: make(chan struct{})} | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | func (s *readyScriptedService) record(name string) { | |
| 135 | + | s.mu.Lock() | |
| 136 | + | s.mutations = append(s.mutations, name) | |
| 137 | + | s.mu.Unlock() | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | func (s *readyScriptedService) ConnectionID() ports.BrokerConnectionID { | |
| 141 | + | return ports.BrokerConnectionID{0x2a} | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | func (s *readyScriptedService) NextStreamID() (ports.BrokerStreamID, error) { return 1, nil } | |
| 145 | + | ||
| 146 | + | func (s *readyScriptedService) Done() <-chan struct{} { return s.done } | |
| 147 | + | ||
| 148 | + | func (s *readyScriptedService) Err() error { return nil } | |
| 149 | + | ||
| 150 | + | func (s *readyScriptedService) Snapshot() ports.BrokerSnapshot { return s.snapshot.Clone() } | |
| 151 | + | ||
| 152 | + | func (s *readyScriptedService) Subscribe() (ports.BrokerSubscription, error) { | |
| 153 | + | if s.subErr != nil { | |
| 154 | + | return nil, s.subErr | |
| 155 | + | } | |
| 156 | + | sub := newReadyScriptedSubscription() | |
| 157 | + | s.mu.Lock() | |
| 158 | + | s.subs = append(s.subs, sub) | |
| 159 | + | s.mu.Unlock() | |
| 160 | + | return sub, nil | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | func (s *readyScriptedService) OpenStream(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) { | |
| 164 | + | s.record("OpenStream") | |
| 165 | + | return nil, ports.BrokerAdmissionClosed | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | func (s *readyScriptedService) CloseStream(ports.BrokerConnectionID, ports.BrokerStreamID) error { | |
| 169 | + | s.record("CloseStream") | |
| 170 | + | return nil | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | func (s *readyScriptedService) AddHost(context.Context, string, ports.BrokerPolicy) (domain.RemoteRegistration, error) { | |
| 174 | + | s.record("AddHost") | |
| 175 | + | return domain.RemoteRegistration{}, nil | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | func (s *readyScriptedService) RemoveHost(context.Context, domain.RemoteRegistration) (bool, error) { | |
| 179 | + | s.record("RemoveHost") | |
| 180 | + | return false, nil | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | func (s *readyScriptedService) UpdateHostPolicy(context.Context, domain.RemoteRegistration, ports.BrokerPolicy) (domain.RemoteRegistration, error) { | |
| 184 | + | s.record("UpdateHostPolicy") | |
| 185 | + | return domain.RemoteRegistration{}, nil | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | func (s *readyScriptedService) RequestReconcile(string) { s.record("RequestReconcile") } | |
| 189 | + | ||
| 190 | + | func (s *readyScriptedService) Close() error { | |
| 191 | + | s.mu.Lock() | |
| 192 | + | s.closes++ | |
| 193 | + | s.mu.Unlock() | |
| 194 | + | return nil | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | // finish closes Done, so the probe observes the connection going terminal. | |
| 198 | + | func (s *readyScriptedService) finish() { s.doneOnce.Do(func() { close(s.done) }) } | |
| 199 | + | ||
| 200 | + | func (s *readyScriptedService) closeCount() int { | |
| 201 | + | s.mu.Lock() | |
| 202 | + | defer s.mu.Unlock() | |
| 203 | + | return s.closes | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | // mutationNames returns the effectful methods the probe asked for, if any. | |
| 207 | + | func (s *readyScriptedService) mutationNames() []string { | |
| 208 | + | s.mu.Lock() | |
| 209 | + | defer s.mu.Unlock() | |
| 210 | + | return append([]string(nil), s.mutations...) | |
| 211 | + | } | |
| 212 | + | ||
| 213 | + | // subscriptionCloses returns the total Close count across this service's | |
| 214 | + | // subscriptions. | |
| 215 | + | func (s *readyScriptedService) subscriptionCloses() int { | |
| 216 | + | s.mu.Lock() | |
| 217 | + | subs := append([]*readyScriptedSubscription(nil), s.subs...) | |
| 218 | + | s.mu.Unlock() | |
| 219 | + | total := 0 | |
| 220 | + | for _, sub := range subs { | |
| 221 | + | total += sub.closeCount() | |
| 222 | + | } | |
| 223 | + | return total | |
| 224 | + | } | |
| 225 | + | ||
| 226 | + | var _ ports.BrokerService = (*readyScriptedService)(nil) | |
| 227 | + | ||
| 228 | + | // readyDialScript records every dial the probe performs and serves one fresh | |
| 229 | + | // scripted service per call, so a probe is proven to reconnect rather than | |
| 230 | + | // reuse a connection. | |
| 231 | + | type readyDialScript struct { | |
| 232 | + | mu sync.Mutex | |
| 233 | + | dials int | |
| 234 | + | services []*readyScriptedService | |
| 235 | + | produce func() (ports.BrokerService, error) | |
| 236 | + | } | |
| 237 | + | ||
| 238 | + | // scriptReadyDial replaces the probe's dial seam for one test. | |
| 239 | + | func scriptReadyDial(t *testing.T, produce func() (ports.BrokerService, error)) *readyDialScript { | |
| 240 | + | t.Helper() | |
| 241 | + | script := &readyDialScript{produce: produce} | |
| 242 | + | previous := brokerReadyDial | |
| 243 | + | brokerReadyDial = func(context.Context, string) (ports.BrokerService, error) { | |
| 244 | + | script.mu.Lock() | |
| 245 | + | script.dials++ | |
| 246 | + | script.mu.Unlock() | |
| 247 | + | service, err := script.produce() | |
| 248 | + | if recorded, ok := service.(*readyScriptedService); ok { | |
| 249 | + | script.mu.Lock() | |
| 250 | + | script.services = append(script.services, recorded) | |
| 251 | + | script.mu.Unlock() | |
| 252 | + | } | |
| 253 | + | return service, err | |
| 254 | + | } | |
| 255 | + | t.Cleanup(func() { brokerReadyDial = previous }) | |
| 256 | + | return script | |
| 257 | + | } | |
| 258 | + | ||
| 259 | + | func (s *readyDialScript) dialCount() int { | |
| 260 | + | s.mu.Lock() | |
| 261 | + | defer s.mu.Unlock() | |
| 262 | + | return s.dials | |
| 263 | + | } | |
| 264 | + | ||
| 265 | + | func (s *readyDialScript) createdServices() []*readyScriptedService { | |
| 266 | + | s.mu.Lock() | |
| 267 | + | defer s.mu.Unlock() | |
| 268 | + | return append([]*readyScriptedService(nil), s.services...) | |
| 269 | + | } | |
| 270 | + | ||
| 271 | + | // readyTestOptions builds one parsed probe invocation over a fresh isolated | |
| 272 | + | // offline root, so no production path is ever inspected. | |
| 273 | + | func readyTestOptions(t *testing.T, requirement string, timeout time.Duration) brokerReadyOptions { | |
| 274 | + | t.Helper() | |
| 275 | + | root, _, _ := isolateSandboxEnv(t) | |
| 276 | + | require.NoError(t, os.MkdirAll(root, 0o700)) | |
| 277 | + | writeSandboxConfig(t, root, sandboxEmptyDocument("")) | |
| 278 | + | options, err := parseBrokerReadyArgs([]string{"--offline-root", root, "--require", requirement, "--timeout", timeout.String()}) | |
| 279 | + | require.NoError(t, err) | |
| 280 | + | return options | |
| 281 | + | } | |
| 282 | + | ||
| 283 | + | // runReadyProbe drives the real probe entry point over the scripted dial seam. | |
| 284 | + | func runReadyProbe(t *testing.T, options brokerReadyOptions) (string, error) { | |
| 285 | + | t.Helper() | |
| 286 | + | var out bytes.Buffer | |
| 287 | + | err := runBrokerReady(context.Background(), options, &out) | |
| 288 | + | return out.String(), err | |
| 289 | + | } | |
| 290 | + | ||
| 291 | + | // decodeReadyDocument asserts stdout holds exactly one bounded JSON line and | |
| 292 | + | // returns the decoded object. | |
| 293 | + | func decodeReadyDocument(t *testing.T, raw string) map[string]any { | |
| 294 | + | t.Helper() | |
| 295 | + | trimmed := strings.TrimSpace(raw) | |
| 296 | + | require.Equal(t, 1, strings.Count(trimmed, "\n")+1, "the probe writes exactly one JSON line") | |
| 297 | + | require.LessOrEqual(t, len(trimmed)+1, 4096, "the document stays inside its bound") | |
| 298 | + | var decoded map[string]any | |
| 299 | + | require.NoError(t, json.Unmarshal([]byte(trimmed), &decoded)) | |
| 300 | + | require.Equal(t, "vev.broker-ready/v1", decoded["schema"]) | |
| 301 | + | return decoded | |
| 302 | + | } | |
| 303 | + | ||
| 304 | + | // TestParseBrokerReadyArgs pins the strict argument contract: unknown, | |
| 305 | + | // duplicated, incomplete, and non-positive options are refused, a require mode | |
| 306 | + | // outside the closed pair is refused, the observation-age bound is admitted only | |
| 307 | + | // for the catalogue mode, and every default is explicit. | |
| 308 | + | func TestParseBrokerReadyArgs(t *testing.T) { | |
| 309 | + | t.Parallel() | |
| 310 | + | ||
| 311 | + | tests := []struct { | |
| 312 | + | name string | |
| 313 | + | args []string | |
| 314 | + | want brokerReadyOptions | |
| 315 | + | wantErr string | |
| 316 | + | }{ | |
| 317 | + | { | |
| 318 | + | name: "authority defaults", | |
| 319 | + | args: []string{"--require", "local-authority"}, | |
| 320 | + | want: brokerReadyOptions{require: "local-authority", timeout: 10 * time.Second, maxAge: 5 * time.Second}, | |
| 321 | + | }, | |
| 322 | + | { | |
| 323 | + | name: "catalogue with an explicit observation age", | |
| 324 | + | args: []string{"--require", "local-catalogue", "--max-observation-age", "2s", "--timeout", "3s", "--offline-root", "/srv/broker"}, | |
| 325 | + | want: brokerReadyOptions{offlineRoot: "/srv/broker", require: "local-catalogue", timeout: 3 * time.Second, maxAge: 2 * time.Second}, | |
| 326 | + | }, | |
| 327 | + | {name: "missing require", args: nil, wantErr: "`--require` must be local-authority or local-catalogue"}, | |
| 328 | + | {name: "unknown require", args: []string{"--require", "remote-catalogue"}, wantErr: "`--require` must be local-authority or local-catalogue"}, | |
| 329 | + | {name: "missing value", args: []string{"--require"}, wantErr: "requires a value"}, | |
| 330 | + | {name: "duplicate option", args: []string{"--require", "local-authority", "--require", "local-authority"}, wantErr: "duplicate"}, | |
| 331 | + | {name: "unknown option", args: []string{"--require", "local-authority", "--ensure", "1"}, wantErr: "unknown `--ensure` option"}, | |
| 332 | + | {name: "timeout is not a duration", args: []string{"--require", "local-authority", "--timeout", "soon"}, wantErr: "`--timeout` must be positive"}, | |
| 333 | + | {name: "timeout is zero", args: []string{"--require", "local-authority", "--timeout", "0s"}, wantErr: "`--timeout` must be positive"}, | |
| 334 | + | {name: "timeout is negative", args: []string{"--require", "local-authority", "--timeout", "-1s"}, wantErr: "`--timeout` must be positive"}, | |
| 335 | + | {name: "observation age is not a duration", args: []string{"--require", "local-catalogue", "--max-observation-age", "later"}, wantErr: "`--max-observation-age` must be positive"}, | |
| 336 | + | {name: "observation age is zero", args: []string{"--require", "local-catalogue", "--max-observation-age", "0s"}, wantErr: "`--max-observation-age` must be positive"}, | |
| 337 | + | { | |
| 338 | + | name: "observation age is authority-only", | |
| 339 | + | args: []string{"--require", "local-authority", "--max-observation-age", "2s"}, | |
| 340 | + | wantErr: "`--max-observation-age` is only valid for local-catalogue", | |
| 341 | + | }, | |
| 342 | + | {name: "relative offline root", args: []string{"--require", "local-authority", "--offline-root", "relative/root"}, wantErr: "`--offline-root` must be an absolute clean path"}, | |
| 343 | + | {name: "unclean offline root", args: []string{"--require", "local-authority", "--offline-root", "/srv/../srv/broker"}, wantErr: "`--offline-root` must be an absolute clean path"}, | |
| 344 | + | } | |
| 345 | + | for _, tt := range tests { | |
| 346 | + | t.Run(tt.name, func(t *testing.T) { | |
| 347 | + | t.Parallel() | |
| 348 | + | got, err := parseBrokerReadyArgs(tt.args) | |
| 349 | + | if tt.wantErr != "" { | |
| 350 | + | require.Error(t, err) | |
| 351 | + | require.ErrorContains(t, err, tt.wantErr) | |
| 352 | + | require.Equal(t, 2, ExitCode(err), "an argument refusal is a usage exit") | |
| 353 | + | return | |
| 354 | + | } | |
| 355 | + | require.NoError(t, err) | |
| 356 | + | require.Equal(t, tt.want, got) | |
| 357 | + | }) | |
| 358 | + | } | |
| 359 | + | } | |
| 360 | + | ||
| 361 | + | // TestBrokerReadyDocumentIsOneBoundedJSONLine pins the machine contract: exactly | |
| 362 | + | // one JSON line, decimal-string epoch/revision IDs, the pending list, and the | |
| 363 | + | // observed local projection, including its hex incarnation and RFC3339 | |
| 364 | + | // observation time. | |
| 365 | + | func TestBrokerReadyDocumentIsOneBoundedJSONLine(t *testing.T) { | |
| 366 | + | t.Parallel() | |
| 367 | + | ||
| 368 | + | local := readyTestLocal(func(local *ports.BrokerDaemonObservation) { | |
| 369 | + | local.LastSuccess = time.Date(2026, 9, 21, 12, 0, 0, 123456789, time.UTC) | |
| 370 | + | }) | |
| 371 | + | options := brokerReadyOptions{require: "local-catalogue", timeout: time.Second, maxAge: time.Second} | |
| 372 | + | ||
| 373 | + | tests := []struct { | |
| 374 | + | name string | |
| 375 | + | result brokerReadyResult | |
| 376 | + | }{ | |
| 377 | + | {name: "absent local", result: readyResult(options, "pending", "local-not-ready", nil)}, | |
| 378 | + | {name: "observed local", result: readyResult(options, "pending", "local-not-ready", &local)}, | |
| 379 | + | {name: "ready snapshot", result: evaluateReady(options, readyTestSnapshot(func(observed *ports.BrokerDaemonObservation) { *observed = local }), local)}, | |
| 380 | + | } | |
| 381 | + | for _, tt := range tests { | |
| 382 | + | t.Run(tt.name, func(t *testing.T) { | |
| 383 | + | t.Parallel() | |
| 384 | + | var out bytes.Buffer | |
| 385 | + | // emitReady reports the exit code for a non-ready status; the document | |
| 386 | + | // is written either way, which is the property under test here. | |
| 387 | + | _ = emitReady(&out, tt.result) | |
| 388 | + | ||
| 389 | + | raw := out.String() | |
| 390 | + | require.True(t, strings.HasSuffix(raw, "\n"), "the document ends with exactly one newline") | |
| 391 | + | decoded := decodeReadyDocument(t, raw) | |
| 392 | + | require.Equal(t, tt.result.Status, decoded["status"]) | |
| 393 | + | require.Equal(t, tt.result.Require, decoded["require"]) | |
| 394 | + | require.Equal(t, tt.result.Reason, decoded["reason"]) | |
| 395 | + | require.Equal(t, toAnySlice(tt.result.Pending), decoded["pending"]) | |
| 396 | + | require.Equal(t, tt.result.Epoch, decoded["epoch"], "epoch travels as a decimal string") | |
| 397 | + | require.Equal(t, tt.result.Revision, decoded["revision"], "revision travels as a decimal string") | |
| 398 | + | ||
| 399 | + | if tt.result.Local == nil { | |
| 400 | + | require.Nil(t, decoded["local"]) | |
| 401 | + | return | |
| 402 | + | } | |
| 403 | + | observed, ok := decoded["local"].(map[string]any) | |
| 404 | + | require.True(t, ok, "an observed local is a JSON object") | |
| 405 | + | require.Equal(t, "ready-local-daemon", observed["identity"]) | |
| 406 | + | require.Equal(t, "0102030405060708090a0b0c0d0e0f10", observed["incarnation"]) | |
| 407 | + | require.Equal(t, strconv.FormatUint(uint64(protocol.Version), 10), observed["version"]) | |
| 408 | + | require.Equal(t, "reachable", observed["availability"]) | |
| 409 | + | require.Equal(t, true, observed["inventory_known"]) | |
| 410 | + | require.Equal(t, "2026-09-21T12:00:00.123456789Z", observed["last_success"]) | |
| 411 | + | }) | |
| 412 | + | } | |
| 413 | + | } | |
| 414 | + | ||
| 415 | + | // TestBrokerReadyExitCodeTable pins the closed status-to-exit-code mapping: 0 | |
| 416 | + | // ready, 3 timeout (including the default pending case), 4 terminal, and 5 | |
| 417 | + | // cancellation. The document is written before the exit code is returned. | |
| 418 | + | func TestBrokerReadyExitCodeTable(t *testing.T) { | |
| 419 | + | t.Parallel() | |
| 420 | + | ||
| 421 | + | tests := []struct { | |
| 422 | + | name string | |
| 423 | + | status string | |
| 424 | + | wantCode int | |
| 425 | + | wantErrMsg string | |
| 426 | + | }{ | |
| 427 | + | {name: "ready", status: "ready", wantCode: 0}, | |
| 428 | + | {name: "timeout", status: "timeout", wantCode: 3, wantErrMsg: "broker readiness timeout"}, | |
| 429 | + | {name: "terminal", status: "terminal", wantCode: 4, wantErrMsg: "broker readiness terminal failure"}, | |
| 430 | + | {name: "canceled", status: "canceled", wantCode: 5, wantErrMsg: "context canceled"}, | |
| 431 | + | {name: "pending", status: "pending", wantCode: 3, wantErrMsg: "broker readiness timeout"}, | |
| 432 | + | } | |
| 433 | + | for _, tt := range tests { | |
| 434 | + | t.Run(tt.name, func(t *testing.T) { | |
| 435 | + | t.Parallel() | |
| 436 | + | var out bytes.Buffer | |
| 437 | + | result := brokerReadyResult{Schema: "vev.broker-ready/v1", Status: tt.status, Require: "local-authority", Pending: []string{}} | |
| 438 | + | err := emitReady(&out, result) | |
| 439 | + | require.Equal(t, tt.wantCode, ExitCode(err)) | |
| 440 | + | if tt.wantErrMsg == "" { | |
| 441 | + | require.NoError(t, err) | |
| 442 | + | } else { | |
| 443 | + | require.ErrorContains(t, err, tt.wantErrMsg) | |
| 444 | + | } | |
| 445 | + | require.Equal(t, 1, strings.Count(out.String(), "\n"), "the report is written even when the exit code is non-zero") | |
| 446 | + | }) | |
| 447 | + | } | |
| 448 | + | } | |
| 449 | + | ||
| 450 | + | // TestBrokerReadyObservationTable pins the readiness evaluation itself: | |
| 451 | + | // `local-authority` is satisfied by any snapshot that carries a valid local | |
| 452 | + | // entry, `local-catalogue` requires a reachable, known, fresh, version-exact | |
| 453 | + | // observation, an empty known inventory is a success, and a structurally | |
| 454 | + | // invalid or doubled local publication is terminal rather than pending. | |
| 455 | + | func TestBrokerReadyObservationTable(t *testing.T) { | |
| 456 | + | tests := []struct { | |
| 457 | + | name string | |
| 458 | + | require string | |
| 459 | + | snapshot ports.BrokerSnapshot | |
| 460 | + | subErr error | |
| 461 | + | wantNil bool | |
| 462 | + | wantStatus string | |
| 463 | + | wantReason string | |
| 464 | + | wantEpoch string | |
| 465 | + | wantRevision string | |
| 466 | + | wantPending []string | |
| 467 | + | }{ | |
| 468 | + | { | |
| 469 | + | name: "unknown availability satisfies the authority requirement", | |
| 470 | + | require: "local-authority", | |
| 471 | + | snapshot: readyTestSnapshot(func(local *ports.BrokerDaemonObservation) { local.Availability = domain.RemoteAvailabilityUnknown }), | |
| 472 | + | wantStatus: "ready", | |
| 473 | + | wantReason: "authority", | |
| 474 | + | wantEpoch: "42", | |
| 475 | + | wantRevision: "7", | |
| 476 | + | wantPending: []string{}, | |
| 477 | + | }, | |
| 478 | + | { | |
| 479 | + | name: "reachable known empty inventory satisfies the catalogue requirement", | |
| 480 | + | require: "local-catalogue", | |
| 481 | + | snapshot: readyTestSnapshot(nil), | |
| 482 | + | wantStatus: "ready", | |
| 483 | + | wantReason: "catalogue", | |
| 484 | + | wantEpoch: "42", | |
| 485 | + | wantRevision: "7", | |
| 486 | + | wantPending: []string{}, | |
| 487 | + | }, | |
| 488 | + | { | |
| 489 | + | name: "unreachable local stays pending", | |
| 490 | + | require: "local-catalogue", | |
| 491 | + | snapshot: readyTestSnapshot(func(local *ports.BrokerDaemonObservation) { local.Availability = domain.RemoteAvailabilityUnreachable }), | |
| 492 | + | wantNil: true, | |
| 493 | + | wantStatus: "pending", | |
| 494 | + | wantPending: []string{"local-catalogue"}, | |
| 495 | + | }, | |
| 496 | + | { | |
| 497 | + | name: "unknown inventory stays pending", | |
| 498 | + | require: "local-catalogue", | |
| 499 | + | snapshot: readyTestSnapshot(func(local *ports.BrokerDaemonObservation) { local.InventoryKnown = false }), | |
| 500 | + | wantNil: true, | |
| 501 | + | wantStatus: "pending", | |
| 502 | + | wantPending: []string{"local-catalogue"}, | |
| 503 | + | }, | |
| 504 | + | { | |
| 505 | + | name: "stale observation stays pending", | |
| 506 | + | require: "local-catalogue", | |
| 507 | + | snapshot: readyTestSnapshot(func(local *ports.BrokerDaemonObservation) { local.LastSuccess = time.Now().Add(-time.Hour) }), | |
| 508 | + | wantNil: true, | |
| 509 | + | wantStatus: "pending", | |
| 510 | + | wantPending: []string{"local-catalogue"}, | |
| 511 | + | }, | |
| 512 | + | { | |
| 513 | + | name: "missing observation time stays pending", | |
| 514 | + | require: "local-catalogue", | |
| 515 | + | snapshot: readyTestSnapshot(func(local *ports.BrokerDaemonObservation) { local.LastSuccess = time.Time{} }), | |
| 516 | + | wantNil: true, | |
| 517 | + | wantStatus: "pending", | |
| 518 | + | wantPending: []string{"local-catalogue"}, | |
| 519 | + | }, | |
| 520 | + | { | |
| 521 | + | name: "incompatible protocol version stays pending", | |
| 522 | + | require: "local-catalogue", | |
| 523 | + | snapshot: readyTestSnapshot(func(local *ports.BrokerDaemonObservation) { local.ProtocolVersion = protocol.Version + 1 }), | |
| 524 | + | wantNil: true, | |
| 525 | + | wantStatus: "pending", | |
| 526 | + | wantPending: []string{"local-catalogue"}, | |
| 527 | + | }, | |
| 528 | + | { | |
| 529 | + | name: "unobserved local stays pending", | |
| 530 | + | require: "local-catalogue", | |
| 531 | + | snapshot: readyTestSnapshot(func(local *ports.BrokerDaemonObservation) { | |
| 532 | + | *local = ports.BrokerDaemonObservation{Local: true, DisplayOrigin: "local", Policy: readyTestPolicy(), Availability: domain.RemoteAvailabilityUnknown} | |
| 533 | + | }), | |
| 534 | + | wantNil: true, | |
| 535 | + | wantStatus: "pending", | |
| 536 | + | wantPending: []string{"local-catalogue"}, | |
| 537 | + | }, | |
| 538 | + | { | |
| 539 | + | name: "snapshot without an epoch is no observation at all", | |
| 540 | + | require: "local-authority", | |
| 541 | + | snapshot: ports.BrokerSnapshot{}, | |
| 542 | + | wantNil: true, | |
| 543 | + | wantStatus: "pending", | |
| 544 | + | wantPending: []string{"local-authority"}, | |
| 545 | + | }, | |
| 546 | + | { | |
| 547 | + | name: "snapshot without a revision is terminal", | |
| 548 | + | require: "local-authority", | |
| 549 | + | snapshot: ports.BrokerSnapshot{Epoch: readyTestEpoch, Revision: 0, Daemons: []ports.BrokerDaemonObservation{readyTestLocal(nil)}}, | |
| 550 | + | wantStatus: "terminal", | |
| 551 | + | wantReason: "protocol", | |
| 552 | + | wantEpoch: "0", | |
| 553 | + | wantRevision: "0", | |
| 554 | + | wantPending: []string{"local-authority"}, | |
| 555 | + | }, | |
| 556 | + | { | |
| 557 | + | name: "two local daemons are terminal", | |
| 558 | + | require: "local-authority", | |
| 559 | + | snapshot: ports.BrokerSnapshot{ | |
| 560 | + | Epoch: readyTestEpoch, Revision: readyTestRevision, | |
| 561 | + | Daemons: []ports.BrokerDaemonObservation{readyTestLocal(nil), readyTestLocal(nil)}, | |
| 562 | + | }, | |
| 563 | + | wantStatus: "terminal", | |
| 564 | + | wantReason: "protocol", | |
| 565 | + | wantEpoch: "0", | |
| 566 | + | wantRevision: "0", | |
| 567 | + | wantPending: []string{"local-authority"}, | |
| 568 | + | }, | |
| 569 | + | { | |
| 570 | + | name: "subscription refusal is terminal", | |
| 571 | + | require: "local-authority", | |
| 572 | + | snapshot: readyTestSnapshot(nil), | |
| 573 | + | subErr: errors.Join(brokeripc.ErrProtocol, errors.New("broker refused the subscription")), | |
| 574 | + | wantStatus: "terminal", | |
| 575 | + | wantReason: "protocol", | |
| 576 | + | wantEpoch: "0", | |
| 577 | + | wantRevision: "0", | |
| 578 | + | wantPending: []string{"local-authority"}, | |
| 579 | + | }, | |
| 580 | + | } | |
| 581 | + | for _, tt := range tests { | |
| 582 | + | t.Run(tt.name, func(t *testing.T) { | |
| 583 | + | service := newReadyScriptedService(tt.snapshot) | |
| 584 | + | service.subErr = tt.subErr | |
| 585 | + | ctx, cancel := context.WithTimeout(context.Background(), readyTestObserveMax) | |
| 586 | + | defer cancel() | |
| 587 | + | ||
| 588 | + | result, reconnect := observeBrokerReady(ctx, brokerReadyOptions{require: tt.require, timeout: time.Second, maxAge: time.Second}, service) | |
| 589 | + | ||
| 590 | + | require.False(t, reconnect, "a terminal or satisfied observation never reconnects") | |
| 591 | + | require.Empty(t, service.mutationNames(), "readiness must never open a stream, reconcile, or mutate membership") | |
| 592 | + | if tt.subErr != nil { | |
| 593 | + | require.Zero(t, service.subscriptionCloses(), "a refused subscription is never closed as if it existed") | |
| 594 | + | } else { | |
| 595 | + | require.Equal(t, 1, service.subscriptionCloses(), "the probe closes the subscription it opened") | |
| 596 | + | } | |
| 597 | + | if tt.wantNil { | |
| 598 | + | require.Nil(t, result, "an unsatisfied observation publishes nothing") | |
| 599 | + | return | |
| 600 | + | } | |
| 601 | + | require.NotNil(t, result) | |
| 602 | + | require.Equal(t, tt.wantStatus, result.Status) | |
| 603 | + | require.Equal(t, tt.wantReason, result.Reason) | |
| 604 | + | require.Equal(t, tt.wantEpoch, result.Epoch) | |
| 605 | + | require.Equal(t, tt.wantRevision, result.Revision) | |
| 606 | + | require.Equal(t, tt.wantPending, result.Pending) | |
| 607 | + | require.Equal(t, "vev.broker-ready/v1", result.Schema) | |
| 608 | + | }) | |
| 609 | + | } | |
| 610 | + | } | |
| 611 | + | ||
| 612 | + | // TestBrokerReadyRunOutcomeTable drives the real probe entry point for every | |
| 613 | + | // exit class: an on-demand broker that is merely absent or refused is retried | |
| 614 | + | // into the deadline (3), a security, configuration, or protocol refusal is | |
| 615 | + | // terminal (4), a satisfied requirement is 0, and every connection the probe | |
| 616 | + | // opened is closed exactly once with no effectful call. | |
| 617 | + | func TestBrokerReadyRunOutcomeTable(t *testing.T) { | |
| 618 | + | tests := []struct { | |
| 619 | + | name string | |
| 620 | + | require string | |
| 621 | + | produce func() (ports.BrokerService, error) | |
| 622 | + | wantCode int | |
| 623 | + | wantStatus string | |
| 624 | + | wantReason string | |
| 625 | + | wantPending []string | |
| 626 | + | }{ | |
| 627 | + | { | |
| 628 | + | name: "authority satisfied", | |
| 629 | + | require: "local-authority", | |
| 630 | + | produce: func() (ports.BrokerService, error) { return newReadyScriptedService(readyTestSnapshot(nil)), nil }, | |
| 631 | + | wantCode: 0, | |
| 632 | + | wantStatus: "ready", | |
| 633 | + | wantReason: "authority", | |
| 634 | + | wantPending: []string{}, | |
| 635 | + | }, | |
| 636 | + | { | |
| 637 | + | name: "catalogue satisfied by a known empty inventory", | |
| 638 | + | require: "local-catalogue", | |
| 639 | + | produce: func() (ports.BrokerService, error) { return newReadyScriptedService(readyTestSnapshot(nil)), nil }, | |
| 640 | + | wantCode: 0, | |
| 641 | + | wantStatus: "ready", | |
| 642 | + | wantReason: "catalogue", | |
| 643 | + | wantPending: []string{}, | |
| 644 | + | }, | |
| 645 | + | { | |
| 646 | + | name: "catalogue with an incompatible version times out", | |
| 647 | + | require: "local-catalogue", | |
| 648 | + | produce: func() (ports.BrokerService, error) { | |
| 649 | + | return newReadyScriptedService(readyTestSnapshot(func(local *ports.BrokerDaemonObservation) { local.ProtocolVersion = protocol.Version + 1 })), nil | |
| 650 | + | }, | |
| 651 | + | wantCode: 3, | |
| 652 | + | wantStatus: "timeout", | |
| 653 | + | wantReason: "deadline", | |
| 654 | + | wantPending: []string{"local-catalogue"}, | |
| 655 | + | }, | |
| 656 | + | { | |
| 657 | + | name: "stale catalogue observation times out", | |
| 658 | + | require: "local-catalogue", | |
| 659 | + | produce: func() (ports.BrokerService, error) { | |
| 660 | + | return newReadyScriptedService(readyTestSnapshot(func(local *ports.BrokerDaemonObservation) { local.LastSuccess = time.Now().Add(-time.Hour) })), nil | |
| 661 | + | }, | |
| 662 | + | wantCode: 3, | |
| 663 | + | wantStatus: "timeout", | |
| 664 | + | wantReason: "deadline", | |
| 665 | + | wantPending: []string{"local-catalogue"}, | |
| 666 | + | }, | |
| 667 | + | { | |
| 668 | + | name: "no snapshot times out", | |
| 669 | + | require: "local-authority", | |
| 670 | + | produce: func() (ports.BrokerService, error) { return newReadyScriptedService(ports.BrokerSnapshot{}), nil }, | |
| 671 | + | wantCode: 3, | |
| 672 | + | wantStatus: "timeout", | |
| 673 | + | wantReason: "deadline", | |
| 674 | + | wantPending: []string{"local-authority"}, | |
| 675 | + | }, | |
| 676 | + | { | |
| 677 | + | name: "absent endpoint is retried then times out", | |
| 678 | + | require: "local-authority", | |
| 679 | + | produce: func() (ports.BrokerService, error) { | |
| 680 | + | return nil, &net.OpError{Op: "dial", Net: "unix", Err: syscall.ENOENT} | |
| 681 | + | }, | |
| 682 | + | wantCode: 3, | |
| 683 | + | wantStatus: "timeout", | |
| 684 | + | wantReason: "deadline", | |
| 685 | + | wantPending: []string{"local-authority"}, | |
| 686 | + | }, | |
| 687 | + | { | |
| 688 | + | name: "refused endpoint is retried then times out", | |
| 689 | + | require: "local-authority", | |
| 690 | + | produce: func() (ports.BrokerService, error) { | |
| 691 | + | return nil, &net.OpError{Op: "dial", Net: "unix", Err: syscall.ECONNREFUSED} | |
| 692 | + | }, | |
| 693 | + | wantCode: 3, | |
| 694 | + | wantStatus: "timeout", | |
| 695 | + | wantReason: "deadline", | |
| 696 | + | wantPending: []string{"local-authority"}, | |
| 697 | + | }, | |
| 698 | + | { | |
| 699 | + | name: "invalid snapshot is terminal", | |
| 700 | + | require: "local-authority", | |
| 701 | + | produce: func() (ports.BrokerService, error) { | |
| 702 | + | return newReadyScriptedService(ports.BrokerSnapshot{Epoch: readyTestEpoch}), nil | |
| 703 | + | }, | |
| 704 | + | wantCode: 4, | |
| 705 | + | wantStatus: "terminal", | |
| 706 | + | wantReason: "protocol", | |
| 707 | + | wantPending: []string{"local-authority"}, | |
| 708 | + | }, | |
| 709 | + | { | |
| 710 | + | name: "rejected peer is terminal", | |
| 711 | + | require: "local-authority", | |
| 712 | + | produce: func() (ports.BrokerService, error) { return nil, ipc.ErrMuxPeerRejected }, | |
| 713 | + | wantCode: 4, | |
| 714 | + | wantStatus: "terminal", | |
| 715 | + | wantReason: "security", | |
| 716 | + | wantPending: []string{"local-authority"}, | |
| 717 | + | }, | |
| 718 | + | { | |
| 719 | + | name: "permission refusal is terminal", | |
| 720 | + | require: "local-authority", | |
| 721 | + | produce: func() (ports.BrokerService, error) { return nil, os.ErrPermission }, | |
| 722 | + | wantCode: 4, | |
| 723 | + | wantStatus: "terminal", | |
| 724 | + | wantReason: "security", | |
| 725 | + | wantPending: []string{"local-authority"}, | |
| 726 | + | }, | |
| 727 | + | { | |
| 728 | + | name: "invalid configuration is terminal", | |
| 729 | + | require: "local-authority", | |
| 730 | + | produce: func() (ports.BrokerService, error) { return nil, brokeripc.ErrConfig }, | |
| 731 | + | wantCode: 4, | |
| 732 | + | wantStatus: "terminal", | |
| 733 | + | wantReason: "config", | |
| 734 | + | wantPending: []string{"local-authority"}, | |
| 735 | + | }, | |
| 736 | + | { | |
| 737 | + | name: "broker protocol violation is terminal", | |
| 738 | + | require: "local-authority", | |
| 739 | + | produce: func() (ports.BrokerService, error) { | |
| 740 | + | return nil, errors.Join(brokeripc.ErrMalformedFrame, errors.New("truncated")) | |
| 741 | + | }, | |
| 742 | + | wantCode: 4, | |
| 743 | + | wantStatus: "terminal", | |
| 744 | + | wantReason: "protocol", | |
| 745 | + | wantPending: []string{"local-authority"}, | |
| 746 | + | }, | |
| 747 | + | } | |
| 748 | + | for _, tt := range tests { | |
| 749 | + | t.Run(tt.name, func(t *testing.T) { | |
| 750 | + | options := readyTestOptions(t, tt.require, 80*time.Millisecond) | |
| 751 | + | script := scriptReadyDial(t, tt.produce) | |
| 752 | + | ||
| 753 | + | out, err := runReadyProbe(t, options) | |
| 754 | + | require.Equal(t, tt.wantCode, ExitCode(err), "probe outcome %q", out) | |
| 755 | + | ||
| 756 | + | decoded := decodeReadyDocument(t, out) | |
| 757 | + | require.Equal(t, tt.wantStatus, decoded["status"]) | |
| 758 | + | require.Equal(t, tt.wantReason, decoded["reason"]) | |
| 759 | + | require.Equal(t, tt.require, decoded["require"]) | |
| 760 | + | require.Equal(t, toAnySlice(tt.wantPending), decoded["pending"]) | |
| 761 | + | ||
| 762 | + | services := script.createdServices() | |
| 763 | + | for _, service := range services { | |
| 764 | + | require.Equal(t, 1, service.closeCount(), "every dialed connection is closed exactly once") | |
| 765 | + | require.Empty(t, service.mutationNames(), "readiness opens no stream, reconciles nothing, and mutates no membership") | |
| 766 | + | require.Equal(t, 1, service.subscriptionCloses(), "the probe closes the subscription it opened") | |
| 767 | + | } | |
| 768 | + | if tt.wantCode == 0 { | |
| 769 | + | require.Equal(t, 1, script.dialCount(), "a satisfied probe dials exactly once") | |
| 770 | + | } | |
| 771 | + | }) | |
| 772 | + | } | |
| 773 | + | } | |
| 774 | + | ||
| 775 | + | // TestBrokerReadyNeverEnsuresSpawnsOrMutates pins the zero-effect guarantee at | |
| 776 | + | // the probe level: the dial seam is the only I/O seam it uses, every connection | |
| 777 | + | // it opens is closed, every subscription is closed, and no logical stream, | |
| 778 | + | // reconcile, or membership mutation is ever attempted. | |
| 779 | + | func TestBrokerReadyNeverEnsuresSpawnsOrMutates(t *testing.T) { | |
| 780 | + | options := readyTestOptions(t, "local-authority", 80*time.Millisecond) | |
| 781 | + | script := scriptReadyDial(t, func() (ports.BrokerService, error) { | |
| 782 | + | return newReadyScriptedService(readyTestSnapshot(nil)), nil | |
| 783 | + | }) | |
| 784 | + | ||
| 785 | + | out, err := runReadyProbe(t, options) | |
| 786 | + | require.NoError(t, err, "outcome %q", out) | |
| 787 | + | require.Equal(t, 1, script.dialCount(), "a satisfied probe dials exactly once") | |
| 788 | + | require.Len(t, script.createdServices(), 1) | |
| 789 | + | ||
| 790 | + | service := script.createdServices()[0] | |
| 791 | + | require.Empty(t, service.mutationNames(), "readiness opens no stream, reconciles nothing, and mutates no membership") | |
| 792 | + | require.Equal(t, 1, service.closeCount()) | |
| 793 | + | require.Equal(t, 1, service.subscriptionCloses()) | |
| 794 | + | ||
| 795 | + | decoded := decodeReadyDocument(t, out) | |
| 796 | + | require.Equal(t, "ready", decoded["status"]) | |
| 797 | + | observed, ok := decoded["local"].(map[string]any) | |
| 798 | + | require.True(t, ok) | |
| 799 | + | require.Equal(t, "reachable", observed["availability"]) | |
| 800 | + | require.Equal(t, true, observed["inventory_known"]) | |
| 801 | + | } | |
| 802 | + | ||
| 803 | + | // TestBrokerReadyReconnectUsesTheNewEpoch pins that a broker connection that | |
| 804 | + | // ends before readiness is replaced by a fresh dial, and that the report carries | |
| 805 | + | // the new broker epoch: a retired epoch is never reused as readiness authority. | |
| 806 | + | func TestBrokerReadyReconnectUsesTheNewEpoch(t *testing.T) { | |
| 807 | + | freshSnapshot := ports.BrokerSnapshot{Epoch: ports.BrokerEpoch(2), Revision: 3, Daemons: []ports.BrokerDaemonObservation{readyTestLocal(nil)}} | |
| 808 | + | ||
| 809 | + | options := readyTestOptions(t, "local-authority", 2*time.Second) | |
| 810 | + | var ( | |
| 811 | + | mu sync.Mutex | |
| 812 | + | first *readyScriptedService | |
| 813 | + | attempts int | |
| 814 | + | ) | |
| 815 | + | scriptReadyDial(t, func() (ports.BrokerService, error) { | |
| 816 | + | mu.Lock() | |
| 817 | + | defer mu.Unlock() | |
| 818 | + | attempts++ | |
| 819 | + | if attempts == 1 { | |
| 820 | + | // The first connection commits no snapshot before it retires: the | |
| 821 | + | // probe must reconnect rather than report the retired connection. | |
| 822 | + | first = newReadyScriptedService(ports.BrokerSnapshot{}) | |
| 823 | + | first.finish() | |
| 824 | + | return first, nil | |
| 825 | + | } | |
| 826 | + | return newReadyScriptedService(freshSnapshot), nil | |
| 827 | + | }) | |
| 828 | + | ||
| 829 | + | out, err := runReadyProbe(t, options) | |
| 830 | + | require.NoError(t, err, "outcome %q", out) | |
| 831 | + | ||
| 832 | + | decoded := decodeReadyDocument(t, out) | |
| 833 | + | require.Equal(t, "ready", decoded["status"]) | |
| 834 | + | require.Equal(t, "2", decoded["epoch"], "the report carries the epoch of the connection that answered") | |
| 835 | + | require.NotEqual(t, "1", decoded["epoch"], "a retired epoch is never reused") | |
| 836 | + | ||
| 837 | + | mu.Lock() | |
| 838 | + | defer mu.Unlock() | |
| 839 | + | require.Equal(t, 2, attempts, "a retired connection is replaced by exactly one fresh dial") | |
| 840 | + | require.NotNil(t, first) | |
| 841 | + | require.Equal(t, 1, first.closeCount(), "the retired connection is closed exactly once") | |
| 842 | + | } | |
| 843 | + | ||
| 844 | + | // TestBrokerReadyCancellationIsExitFiveAndClosesEverything pins the | |
| 845 | + | // cancellation contract: the caller sees exit 5, the document says canceled, | |
| 846 | + | // and the connection and subscription the probe opened are closed rather than | |
| 847 | + | // leaked. | |
| 848 | + | func TestBrokerReadyCancellationIsExitFiveAndClosesEverything(t *testing.T) { | |
| 849 | + | options := readyTestOptions(t, "local-authority", 5*time.Second) | |
| 850 | + | script := scriptReadyDial(t, func() (ports.BrokerService, error) { | |
| 851 | + | return newReadyScriptedService(ports.BrokerSnapshot{}), nil | |
| 852 | + | }) | |
| 853 | + | ||
| 854 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 855 | + | var out bytes.Buffer | |
| 856 | + | results := make(chan error, 1) | |
| 857 | + | go func() { results <- runBrokerReady(ctx, options, &out) }() | |
| 858 | + | ||
| 859 | + | require.Eventually(t, func() bool { return script.dialCount() == 1 }, time.Second, 5*time.Millisecond, "the probe dials before it is cancelled") | |
| 860 | + | cancel() | |
| 861 | + | ||
| 862 | + | select { | |
| 863 | + | case err := <-results: | |
| 864 | + | require.Equal(t, 5, ExitCode(err)) | |
| 865 | + | case <-time.After(2 * time.Second): | |
| 866 | + | t.Fatal("a cancelled probe must return promptly") | |
| 867 | + | } | |
| 868 | + | ||
| 869 | + | decoded := decodeReadyDocument(t, out.String()) | |
| 870 | + | require.Equal(t, "canceled", decoded["status"]) | |
| 871 | + | require.Equal(t, "canceled", decoded["reason"]) | |
| 872 | + | ||
| 873 | + | services := script.createdServices() | |
| 874 | + | require.Len(t, services, 1) | |
| 875 | + | require.Equal(t, 1, services[0].closeCount(), "cancellation closes the connection") | |
| 876 | + | require.Equal(t, 1, services[0].subscriptionCloses(), "cancellation closes the subscription") | |
| 877 | + | require.Empty(t, services[0].mutationNames()) | |
| 878 | + | } | |
| 879 | + | ||
| 880 | + | // TestBrokerReadyEndpointSecurityClassificationTable pins the endpoint check the | |
| 881 | + | // probe performs before any dial: absence is retryable, and a foreign path or an | |
| 882 | + | // uninspectable path is a terminal security failure. | |
| 883 | + | func TestBrokerReadyEndpointSecurityClassificationTable(t *testing.T) { | |
| 884 | + | t.Parallel() | |
| 885 | + | ||
| 886 | + | dir := t.TempDir() | |
| 887 | + | regular := filepath.Join(dir, "not-a-socket") | |
| 888 | + | require.NoError(t, os.WriteFile(regular, []byte("x"), 0o600)) | |
| 889 | + | ||
| 890 | + | socketDir := t.TempDir() | |
| 891 | + | socketPath := filepath.Join(socketDir, "broker.sock") | |
| 892 | + | listener, err := net.Listen("unix", socketPath) | |
| 893 | + | require.NoError(t, err) | |
| 894 | + | t.Cleanup(func() { _ = listener.Close() }) | |
| 895 | + | ||
| 896 | + | unreadableDir := filepath.Join(t.TempDir(), "unreadable") | |
| 897 | + | require.NoError(t, os.Mkdir(unreadableDir, 0o700)) | |
| 898 | + | unreadable := filepath.Join(unreadableDir, "broker.sock") | |
| 899 | + | require.NoError(t, os.Chmod(unreadableDir, 0o000)) | |
| 900 | + | t.Cleanup(func() { _ = os.Chmod(unreadableDir, 0o700) }) | |
| 901 | + | ||
| 902 | + | tests := []struct { | |
| 903 | + | name string | |
| 904 | + | path string | |
| 905 | + | wantTerminal bool | |
| 906 | + | wantReason string | |
| 907 | + | }{ | |
| 908 | + | {name: "absent endpoint is retryable", path: filepath.Join(dir, "absent.sock")}, | |
| 909 | + | {name: "bound socket is dialable", path: socketPath}, | |
| 910 | + | {name: "regular file is terminal", path: regular, wantTerminal: true, wantReason: "security"}, | |
| 911 | + | {name: "directory is terminal", path: socketDir, wantTerminal: true, wantReason: "security"}, | |
| 912 | + | {name: "uninspectable path is terminal", path: unreadable, wantTerminal: true, wantReason: "security"}, | |
| 913 | + | } | |
| 914 | + | for _, tt := range tests { | |
| 915 | + | t.Run(tt.name, func(t *testing.T) { | |
| 916 | + | t.Parallel() | |
| 917 | + | reason, terminal := brokerReadyEndpointSecurity(tt.path) | |
| 918 | + | require.Equal(t, tt.wantTerminal, terminal) | |
| 919 | + | require.Equal(t, tt.wantReason, reason) | |
| 920 | + | }) | |
| 921 | + | } | |
| 922 | + | } | |
| 923 | + | ||
| 924 | + | // TestBrokerReadyDialClassificationTable pins the dial-failure taxonomy: only a | |
| 925 | + | // refusal that proves the peer, the path, the credentials, the configuration, or | |
| 926 | + | // the broker conversation itself is not the expected broker is terminal. | |
| 927 | + | func TestBrokerReadyDialClassificationTable(t *testing.T) { | |
| 928 | + | t.Parallel() | |
| 929 | + | ||
| 930 | + | tests := []struct { | |
| 931 | + | name string | |
| 932 | + | err error | |
| 933 | + | wantTerminal bool | |
| 934 | + | wantReason string | |
| 935 | + | }{ | |
| 936 | + | {name: "nil is not a failure", err: nil}, | |
| 937 | + | {name: "absent endpoint is retryable", err: &net.OpError{Op: "dial", Net: "unix", Err: syscall.ENOENT}}, | |
| 938 | + | {name: "refused endpoint is retryable", err: &net.OpError{Op: "dial", Net: "unix", Err: syscall.ECONNREFUSED}}, | |
| 939 | + | {name: "cancellation is retryable", err: context.Canceled}, | |
| 940 | + | {name: "deadline is retryable", err: context.DeadlineExceeded}, | |
| 941 | + | {name: "unclassified failure is retryable", err: errors.New("io error")}, | |
| 942 | + | {name: "rejected peer is terminal", err: ipc.ErrMuxPeerRejected, wantTerminal: true, wantReason: "security"}, | |
| 943 | + | {name: "foreign path is terminal", err: ipc.ErrMuxForeignPath, wantTerminal: true, wantReason: "security"}, | |
| 944 | + | {name: "invalid mux path is terminal", err: ipc.ErrMuxPath, wantTerminal: true, wantReason: "security"}, | |
| 945 | + | {name: "unsupported peer check is terminal", err: ipc.ErrMuxUnsupported, wantTerminal: true, wantReason: "security"}, | |
| 946 | + | {name: "permission failure is terminal", err: &net.OpError{Op: "dial", Net: "unix", Err: syscall.EACCES}, wantTerminal: true, wantReason: "security"}, | |
| 947 | + | {name: "invalid configuration is terminal", err: brokeripc.ErrConfig, wantTerminal: true, wantReason: "config"}, | |
| 948 | + | {name: "broker protocol violation is terminal", err: brokeripc.ErrProtocol, wantTerminal: true, wantReason: "protocol"}, | |
| 949 | + | {name: "malformed frame is terminal", err: brokeripc.ErrMalformedFrame, wantTerminal: true, wantReason: "protocol"}, | |
| 950 | + | {name: "oversize non-broker frame is terminal", err: ipc.ErrFrameTooLarge, wantTerminal: true, wantReason: "protocol"}, | |
| 951 | + | {name: "zero-length non-broker frame is terminal", err: ipc.ErrZeroLengthFrame, wantTerminal: true, wantReason: "protocol"}, | |
| 952 | + | } | |
| 953 | + | for _, tt := range tests { | |
| 954 | + | t.Run(tt.name, func(t *testing.T) { | |
| 955 | + | t.Parallel() | |
| 956 | + | reason, terminal := terminalReadyReason(tt.err) | |
| 957 | + | require.Equal(t, tt.wantTerminal, terminal, "cause %v", tt.err) | |
| 958 | + | require.Equal(t, tt.wantReason, reason) | |
| 959 | + | }) | |
| 960 | + | } | |
| 961 | + | } | |
| 962 | + | ||
| 963 | + | // toAnySlice renders a decoded JSON string array comparison input. | |
| 964 | + | func toAnySlice(values []string) []any { | |
| 965 | + | out := make([]any, 0, len(values)) | |
| 966 | + | for _, value := range values { | |
| 967 | + | out = append(out, value) | |
| 968 | + | } | |
| 969 | + | return out | |
| 970 | + | } | |
M internal/app/broker_remote_probe.go +98 -20
| @@ -14,29 +14,39 @@ import ( | |||
| 14 | 14 | "github.com/bnema/vev/internal/protocol/catalogue" | |
| 15 | 15 | ) | |
| 16 | 16 | ||
| 17 | + | // Remote daemon observation composition (Plan 001 P7.4b). | |
| 18 | + | // | |
| 17 | 19 | // brokerRemoteProbe observes one configured daemon over the broker-owned | |
| 18 | 20 | // multiplexed carriage. It opens an observation stream, never an attachment, | |
| 19 | 21 | // and asks the daemon for its local catalogue through the existing typed | |
| 20 | 22 | // command protocol. Authenticated daemon identity and process incarnation come | |
| 21 | 23 | // from the physical handshake rather than from the configured hostname. | |
| 24 | + | // | |
| 25 | + | // Remote and local observation share one semantic implementation below: | |
| 26 | + | // observeDaemonCatalogue is the single "observe an already authenticated | |
| 27 | + | // daemon" path, so the two producers cannot drift apart and neither becomes a | |
| 28 | + | // second owner of a transport. The broker-owned local probe composes the same | |
| 29 | + | // daemonmux carriage and the same pooled connector the broker already owns | |
| 30 | + | // rather than dialing or authenticating a daemon itself. | |
| 22 | 31 | type brokerRemoteProbe struct { | |
| 23 | 32 | epoch ports.BrokerEpoch | |
| 24 | - | resolver ports.BrokerEndpointResolver | |
| 33 | + | routes ports.BrokerRouteAuthority | |
| 34 | + | binder ports.BrokerIdentityBinder | |
| 25 | 35 | connector ports.BrokerEndpointConnector | |
| 26 | - | policy func(string) (ports.BrokerPolicy, bool) | |
| 36 | + | hosts ports.BrokerHostAuthorityReader | |
| 27 | 37 | } | |
| 28 | 38 | ||
| 29 | 39 | var _ ports.BrokerHostProbe = (*brokerRemoteProbe)(nil) | |
| 30 | 40 | ||
| 31 | 41 | func (p *brokerRemoteProbe) Probe(ctx context.Context, registration domain.RemoteRegistration) (ports.BrokerDaemonObservation, error) { | |
| 32 | - | if p == nil || p.epoch == 0 || p.resolver == nil || p.connector == nil { | |
| 42 | + | if p == nil || p.epoch == 0 || p.routes == nil || p.binder == nil || p.connector == nil { | |
| 33 | 43 | return ports.BrokerDaemonObservation{}, errors.New("vev: incomplete broker remote probe") | |
| 34 | 44 | } | |
| 35 | - | request, err := p.request(registration) | |
| 45 | + | request, err := p.request(ctx, registration) | |
| 36 | 46 | if err != nil { | |
| 37 | 47 | return ports.BrokerDaemonObservation{}, err | |
| 38 | 48 | } | |
| 39 | - | endpoint, err := p.resolver.Resolve(ctx, request) | |
| 49 | + | endpoint, err := p.routes.ResolveDialTarget(ctx, request) | |
| 40 | 50 | if err != nil { | |
| 41 | 51 | return ports.BrokerDaemonObservation{}, err | |
| 42 | 52 | } | |
| @@ -45,35 +55,59 @@ func (p *brokerRemoteProbe) Probe(ctx context.Context, registration domain.Remot | |||
| 45 | 55 | return ports.BrokerDaemonObservation{}, err | |
| 46 | 56 | } | |
| 47 | 57 | defer physical.Close() | |
| 48 | - | if physical.Identity() != endpoint.Identity || physical.Policy() != endpoint.Policy { | |
| 58 | + | if (endpoint.ExpectedIdentity.Bound && physical.Identity() != endpoint.ExpectedIdentity.Identity) || physical.Policy() != endpoint.Policy { | |
| 49 | 59 | return ports.BrokerDaemonObservation{}, errors.New("vev: remote probe authenticated binding mismatch") | |
| 50 | 60 | } | |
| 61 | + | identity, err := p.binder.BindAuthenticatedIdentity(ctx, ports.BrokerIdentityBindingRequest{Fence: endpoint.Fence, Policy: endpoint.Policy, Identity: physical.Identity()}) | |
| 62 | + | if err != nil || identity != physical.Identity() { | |
| 63 | + | return ports.BrokerDaemonObservation{}, errors.New("vev: remote probe could not commit authenticated binding") | |
| 64 | + | } | |
| 65 | + | return observeDaemonCatalogue(ctx, physical, request) | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | // observeDaemonCatalogue observes one already authenticated daemon over its | |
| 69 | + | // physical connection by opening exactly one observation logical stream and | |
| 70 | + | // asking the daemon for its own catalogue through the existing typed command | |
| 71 | + | // protocol. It sends no Hello, claims no geometry, requests no admission, and | |
| 72 | + | // creates no session or process: the only traffic on the stream is the | |
| 73 | + | // observation open and the one correlated remote-catalog command. The observed | |
| 74 | + | // identity and incarnation are the physical handshake's authenticated values, | |
| 75 | + | // never a configured hostname. | |
| 76 | + | // | |
| 77 | + | // A refused open, an unmatched or invalid result, a failed outcome, a malformed | |
| 78 | + | // catalogue, and a catalogue outside the exact current schema all fail closed: | |
| 79 | + | // the caller receives an error and no observation it could publish, so a probe | |
| 80 | + | // never invents inventory it did not observe. | |
| 81 | + | func observeDaemonCatalogue(ctx context.Context, physical ports.BrokerPhysicalConnection, request ports.BrokerOpenStreamRequest) (ports.BrokerDaemonObservation, error) { | |
| 82 | + | if physical == nil || request.Purpose != ports.BrokerStreamObservation { | |
| 83 | + | return ports.BrokerDaemonObservation{}, errors.New("vev: invalid daemon observation request") | |
| 84 | + | } | |
| 51 | 85 | logical, err := physical.OpenStream(ctx, request) | |
| 52 | 86 | if err != nil { | |
| 53 | 87 | return ports.BrokerDaemonObservation{}, err | |
| 54 | 88 | } | |
| 55 | - | defer logical.Close() | |
| 89 | + | if logical == nil { | |
| 90 | + | return ports.BrokerDaemonObservation{}, errors.New("vev: daemon observation opened no logical stream") | |
| 91 | + | } | |
| 92 | + | defer func() { _ = logical.Close() }() | |
| 56 | 93 | commandID, err := randomNonzeroUint64() | |
| 57 | 94 | if err != nil { | |
| 58 | 95 | return ports.BrokerDaemonObservation{}, err | |
| 59 | 96 | } | |
| 60 | - | if err := logical.SendClient(protocol.CommandRequest{RequestID: commandID, Version: protocol.Version, Slug: "remote-catalog", JSON: true}); err != nil { | |
| 61 | - | return ports.BrokerDaemonObservation{}, err | |
| 62 | - | } | |
| 63 | - | reply, err := logical.ReceiveServer() | |
| 97 | + | reply, err := exchangeObservationCommand(ctx, logical, protocol.CommandRequest{RequestID: commandID, Version: protocol.Version, Slug: "remote-catalog", JSON: true}) | |
| 64 | 98 | if err != nil { | |
| 65 | 99 | return ports.BrokerDaemonObservation{}, err | |
| 66 | 100 | } | |
| 67 | 101 | result, ok := reply.(protocol.CommandResult) | |
| 68 | 102 | if !ok || result.RequestID != commandID || !result.Valid() { | |
| 69 | - | return ports.BrokerDaemonObservation{}, errors.New("vev: invalid remote observation result") | |
| 103 | + | return ports.BrokerDaemonObservation{}, errors.New("vev: invalid daemon observation result") | |
| 70 | 104 | } | |
| 71 | 105 | if result.Outcome != protocol.CommandSucceeded { | |
| 72 | - | return ports.BrokerDaemonObservation{}, fmt.Errorf("vev: remote observation failed: %s", result.Text) | |
| 106 | + | return ports.BrokerDaemonObservation{}, fmt.Errorf("vev: daemon observation failed: %s", result.Text) | |
| 73 | 107 | } | |
| 74 | 108 | var catalog catalogue.RemoteCatalog | |
| 75 | 109 | if err := json.Unmarshal([]byte(result.Output), &catalog); err != nil { | |
| 76 | - | return ports.BrokerDaemonObservation{}, fmt.Errorf("vev: decode remote observation: %w", err) | |
| 110 | + | return ports.BrokerDaemonObservation{}, fmt.Errorf("vev: decode daemon observation: %w", err) | |
| 77 | 111 | } | |
| 78 | 112 | if err := catalogue.ValidateRemoteCatalog(catalog); err != nil { | |
| 79 | 113 | return ports.BrokerDaemonObservation{}, err | |
| @@ -82,21 +116,62 @@ func (p *brokerRemoteProbe) Probe(ctx context.Context, registration domain.Remot | |||
| 82 | 116 | Identity: physical.Identity(), | |
| 83 | 117 | Incarnation: physical.Incarnation(), | |
| 84 | 118 | ProtocolVersion: catalog.ProtocolVersion, | |
| 85 | - | Capabilities: 0, | |
| 86 | 119 | Availability: domain.RemoteAvailabilityReachable, | |
| 87 | 120 | InventoryKnown: true, | |
| 88 | 121 | Sessions: catalog.Sessions, | |
| 89 | 122 | }, nil | |
| 90 | 123 | } | |
| 91 | 124 | ||
| 92 | - | func (p *brokerRemoteProbe) request(registration domain.RemoteRegistration) (ports.BrokerOpenStreamRequest, error) { | |
| 93 | - | if p.policy == nil { | |
| 125 | + | // exchangeObservationCommand sends one typed command and waits for one reply | |
| 126 | + | // under the caller's context. A cancelled or expired context closes exactly | |
| 127 | + | // this logical stream, which unblocks the pending read, and the worker is | |
| 128 | + | // joined before returning, so an observation always returns promptly once its | |
| 129 | + | // attempt is retired instead of parking the daemon's reader (or a probe | |
| 130 | + | // attempt) on a daemon that answers nothing. | |
| 131 | + | func exchangeObservationCommand(ctx context.Context, logical ports.BrokerLogicalConnection, command protocol.CommandRequest) (protocol.ServerMessage, error) { | |
| 132 | + | if ctx == nil { | |
| 133 | + | ctx = context.Background() | |
| 134 | + | } | |
| 135 | + | type outcome struct { | |
| 136 | + | message protocol.ServerMessage | |
| 137 | + | err error | |
| 138 | + | } | |
| 139 | + | done := make(chan outcome, 1) | |
| 140 | + | go func() { | |
| 141 | + | if err := logical.SendClient(command); err != nil { | |
| 142 | + | done <- outcome{err: err} | |
| 143 | + | return | |
| 144 | + | } | |
| 145 | + | message, err := logical.ReceiveServer() | |
| 146 | + | done <- outcome{message: message, err: err} | |
| 147 | + | }() | |
| 148 | + | select { | |
| 149 | + | case result := <-done: | |
| 150 | + | if err := ctx.Err(); err != nil { | |
| 151 | + | return nil, err | |
| 152 | + | } | |
| 153 | + | return result.message, result.err | |
| 154 | + | case <-ctx.Done(): | |
| 155 | + | // Closing the stream unblocks the admitted read; the worker is joined so | |
| 156 | + | // no observation goroutine outlives its attempt. | |
| 157 | + | _ = logical.Close() | |
| 158 | + | <-done | |
| 159 | + | return nil, ctx.Err() | |
| 160 | + | } | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | func (p *brokerRemoteProbe) request(ctx context.Context, registration domain.RemoteRegistration) (ports.BrokerOpenStreamRequest, error) { | |
| 164 | + | if p.hosts == nil { | |
| 94 | 165 | return ports.BrokerOpenStreamRequest{}, errors.New("vev: remote probe has no policy authority") | |
| 95 | 166 | } | |
| 96 | - | policy, ok := p.policy(registration.Endpoint) | |
| 97 | - | if !ok { | |
| 98 | - | return ports.BrokerOpenStreamRequest{}, errors.New("vev: remote probe endpoint is not configured") | |
| 167 | + | record, ok, err := p.hosts.LookupHost(ctx, registration.Endpoint) | |
| 168 | + | if err != nil { | |
| 169 | + | return ports.BrokerOpenStreamRequest{}, err | |
| 170 | + | } | |
| 171 | + | if !ok || !record.Registration.Equal(registration) { | |
| 172 | + | return ports.BrokerOpenStreamRequest{}, ports.BrokerError{Code: ports.BrokerErrorUnavailable, Text: "unknown or stale probe registration"} | |
| 99 | 173 | } | |
| 174 | + | policy := record.Policy | |
| 100 | 175 | connection, err := randomConnectionID() | |
| 101 | 176 | if err != nil { | |
| 102 | 177 | return ports.BrokerOpenStreamRequest{}, err | |
| @@ -109,6 +184,9 @@ func (p *brokerRemoteProbe) request(registration domain.RemoteRegistration) (por | |||
| 109 | 184 | Epoch: p.epoch, Purpose: ports.BrokerStreamObservation, | |
| 110 | 185 | Connection: connection, Stream: ports.BrokerStreamID(stream), | |
| 111 | 186 | Endpoint: registration.Endpoint, Registration: registration, Policy: policy, | |
| 187 | + | // Observation must never start a stopped daemon, so it always carries | |
| 188 | + | // the existing-only authorization. | |
| 189 | + | StartMode: ports.BrokerDaemonExistingOnly, | |
| 112 | 190 | }, nil | |
| 113 | 191 | } | |
| 114 | 192 | ||
M internal/app/broker_remote_probe_test.go +9 -5
| @@ -15,12 +15,10 @@ func TestBrokerRemoteProbeRequestIsObservationOnly(t *testing.T) { | |||
| 15 | 15 | policy := ports.BrokerPolicy{ProtocolVersion: protocol.Version, CatalogSchemaVersion: 3, Transport: "stdio", Trust: "ssh", Launch: "explicit", Isolation: "user", EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned} | |
| 16 | 16 | probe := &brokerRemoteProbe{ | |
| 17 | 17 | epoch: 7, | |
| 18 | - | policy: func(endpoint string) (ports.BrokerPolicy, bool) { | |
| 19 | - | return policy, endpoint == registration.Endpoint | |
| 20 | - | }, | |
| 18 | + | hosts: &routeTestHosts{record: ports.BrokerHostRecord{Registration: registration, Policy: policy}, found: true}, | |
| 21 | 19 | } | |
| 22 | 20 | ||
| 23 | - | request, err := probe.request(registration) | |
| 21 | + | request, err := probe.request(context.Background(), registration) | |
| 24 | 22 | require.NoError(t, err) | |
| 25 | 23 | require.NoError(t, request.Validate()) | |
| 26 | 24 | require.Equal(t, ports.BrokerStreamObservation, request.Purpose) | |
| @@ -28,10 +26,16 @@ func TestBrokerRemoteProbeRequestIsObservationOnly(t *testing.T) { | |||
| 28 | 26 | require.Empty(t, request.Env) | |
| 29 | 27 | require.Equal(t, registration, request.Registration) | |
| 30 | 28 | require.Equal(t, policy, request.Policy) | |
| 29 | + | // Observation must never start the target: the request carries the | |
| 30 | + | // existing-only authorization, and a spawn-capable one would be refused. | |
| 31 | + | require.Equal(t, ports.BrokerDaemonExistingOnly, request.StartMode) | |
| 32 | + | startable := request | |
| 33 | + | startable.StartMode = ports.BrokerDaemonStartIfNeeded | |
| 34 | + | require.Error(t, startable.Validate()) | |
| 31 | 35 | } | |
| 32 | 36 | ||
| 33 | 37 | func TestBrokerRemoteProbeRejectsUnconfiguredEndpointBeforeDial(t *testing.T) { | |
| 34 | - | probe := &brokerRemoteProbe{epoch: 7, policy: func(string) (ports.BrokerPolicy, bool) { return ports.BrokerPolicy{}, false }} | |
| 38 | + | probe := &brokerRemoteProbe{epoch: 7, hosts: &routeTestHosts{}} | |
| 35 | 39 | _, err := probe.Probe(context.Background(), domain.RemoteRegistration{Endpoint: "user@example.test", Incarnation: [16]byte{1}, Generation: 1}) | |
| 36 | 40 | require.ErrorContains(t, err, "incomplete broker remote probe") | |
| 37 | 41 | } | |
A internal/app/broker_routes.go +65 -0
| @@ -0,0 +1,65 @@ | |||
| 1 | + | package app | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/internal/adapters/brokerconfig" | |
| 7 | + | "github.com/bnema/vev/internal/ports" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // brokerRoutes splits authority deliberately: local routing is provisioned by | |
| 11 | + | // composition; remote routing is read from committed registry membership, never | |
| 12 | + | // from its advisory snapshot or the startup import. hosts is wired before Run. | |
| 13 | + | type brokerRoutes struct { | |
| 14 | + | local ports.BrokerRouteAuthority | |
| 15 | + | hosts ports.BrokerHostAuthorityReader | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | func (r *brokerRoutes) ResolveDialTarget(ctx context.Context, request ports.BrokerOpenStreamRequest) (ports.BrokerDialTarget, error) { | |
| 19 | + | if err := ctx.Err(); err != nil { | |
| 20 | + | return ports.BrokerDialTarget{}, err | |
| 21 | + | } | |
| 22 | + | if request.Local { | |
| 23 | + | return r.local.ResolveDialTarget(ctx, request) | |
| 24 | + | } | |
| 25 | + | record, found, err := r.hosts.LookupHost(ctx, request.Endpoint) | |
| 26 | + | if err != nil { | |
| 27 | + | return ports.BrokerDialTarget{}, err | |
| 28 | + | } | |
| 29 | + | if !found || !record.Registration.Equal(request.Registration) { | |
| 30 | + | return ports.BrokerDialTarget{}, ports.BrokerError{Code: ports.BrokerErrorUnavailable, Text: "unknown or stale registration"} | |
| 31 | + | } | |
| 32 | + | if !record.Policy.Compatible(request.Policy) { | |
| 33 | + | return ports.BrokerDialTarget{}, ports.BrokerError{Code: ports.BrokerErrorConflictingPolicy} | |
| 34 | + | } | |
| 35 | + | route, err := brokerconfig.RouteFromSpec(record.Route) | |
| 36 | + | if err != nil { | |
| 37 | + | return ports.BrokerDialTarget{}, ports.BrokerError{Code: ports.BrokerErrorIncompatible, Cause: err} | |
| 38 | + | } | |
| 39 | + | target := ports.BrokerDialTarget{ | |
| 40 | + | Fence: ports.BrokerEndpointFence{Registration: record.Registration}, | |
| 41 | + | Policy: record.Policy, Address: route.Address(), StartMode: request.StartMode, | |
| 42 | + | ExpectedIdentity: ports.BrokerExpectedIdentity{Identity: record.Identity, Bound: record.Identity != ""}, | |
| 43 | + | } | |
| 44 | + | return target, target.Validate() | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | // remoteRoute rechecks the whole target immediately before dialing. A removed, | |
| 48 | + | // replaced, rebound, or rerouted record cannot authorize a stale dial target. | |
| 49 | + | func (r *brokerRoutes) remoteRoute(ctx context.Context, target ports.BrokerDialTarget) (brokerconfig.Route, error) { | |
| 50 | + | record, found, err := r.hosts.LookupHost(ctx, target.Fence.Registration.Endpoint) | |
| 51 | + | if err != nil { | |
| 52 | + | return brokerconfig.Route{}, err | |
| 53 | + | } | |
| 54 | + | if !found || !record.Registration.Equal(target.Fence.Registration) || record.Policy != target.Policy || record.Identity != target.ExpectedIdentity.Identity { | |
| 55 | + | return brokerconfig.Route{}, ports.BrokerError{Code: ports.BrokerErrorUnavailable, Text: "route authority changed"} | |
| 56 | + | } | |
| 57 | + | route, err := brokerconfig.RouteFromSpec(record.Route) | |
| 58 | + | if err != nil { | |
| 59 | + | return brokerconfig.Route{}, err | |
| 60 | + | } | |
| 61 | + | if route.Address() != target.Address { | |
| 62 | + | return brokerconfig.Route{}, ports.BrokerError{Code: ports.BrokerErrorUnavailable, Text: "route authority changed"} | |
| 63 | + | } | |
| 64 | + | return route, nil | |
| 65 | + | } | |
A internal/app/broker_routes_test.go +212 -0
| @@ -0,0 +1,212 @@ | |||
| 1 | + | package app | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/json" | |
| 6 | + | "os" | |
| 7 | + | "path/filepath" | |
| 8 | + | "testing" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/vev/internal/adapters/brokerconfig" | |
| 11 | + | "github.com/bnema/vev/internal/adapters/brokeripc" | |
| 12 | + | "github.com/bnema/vev/internal/adapters/brokerstore" | |
| 13 | + | "github.com/bnema/vev/internal/adapters/clock" | |
| 14 | + | "github.com/bnema/vev/internal/adapters/daemonmux" | |
| 15 | + | "github.com/bnema/vev/internal/adapters/ipc" | |
| 16 | + | "github.com/bnema/vev/internal/domain" | |
| 17 | + | "github.com/bnema/vev/internal/ports" | |
| 18 | + | "github.com/bnema/vev/internal/usecase/broker" | |
| 19 | + | "github.com/bnema/vev/pkg/safedir" | |
| 20 | + | "github.com/stretchr/testify/require" | |
| 21 | + | ) | |
| 22 | + | ||
| 23 | + | type routeTestHosts struct { | |
| 24 | + | record ports.BrokerHostRecord | |
| 25 | + | found bool | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | func (h *routeTestHosts) LookupHost(ctx context.Context, endpoint string) (ports.BrokerHostRecord, bool, error) { | |
| 29 | + | return h.record, h.found && endpoint == h.record.Registration.Endpoint, ctx.Err() | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | func TestBrokerRoutesDurableFirstAddAndRestart(t *testing.T) { | |
| 33 | + | ctx := context.Background() | |
| 34 | + | dir := filepath.Join(t.TempDir(), "state") | |
| 35 | + | store, err := brokerstore.Open(brokerstore.Options{Dir: dir}) | |
| 36 | + | require.NoError(t, err) | |
| 37 | + | defer func() { require.NoError(t, store.Close()) }() | |
| 38 | + | routes := &brokerRoutes{} | |
| 39 | + | probe := &brokerRemoteProbe{epoch: 1, routes: routes} | |
| 40 | + | registry, err := broker.NewRegistryWithConfig(1, store, probe, clock.New(), nil, broker.RegistryConfig{MembershipMode: broker.MembershipMutable}) | |
| 41 | + | require.NoError(t, err) | |
| 42 | + | routes.hosts = registry | |
| 43 | + | probe.hosts = registry | |
| 44 | + | policy := remoteBrokerPolicy("stdio") | |
| 45 | + | reg, err := registry.AddHost(ctx, "user@example.test", policy) | |
| 46 | + | require.NoError(t, err) | |
| 47 | + | request, err := probe.request(ctx, reg) | |
| 48 | + | require.NoError(t, err) | |
| 49 | + | target, err := routes.ResolveDialTarget(ctx, request) | |
| 50 | + | require.NoError(t, err) | |
| 51 | + | require.False(t, target.ExpectedIdentity.Bound) | |
| 52 | + | route, err := routes.remoteRoute(ctx, target) | |
| 53 | + | require.NoError(t, err) | |
| 54 | + | require.Equal(t, brokerconfig.RouteSSHStdio, route.Kind()) | |
| 55 | + | require.Equal(t, []string{"vev", brokerMuxStdioCommand, "--production"}, route.Argv()) | |
| 56 | + | identity, err := registry.BindAuthenticatedIdentity(ctx, ports.BrokerIdentityBindingRequest{Fence: target.Fence, Policy: policy, Identity: "daemon-test"}) | |
| 57 | + | require.NoError(t, err) | |
| 58 | + | _, err = routes.remoteRoute(ctx, target) | |
| 59 | + | require.Error(t, err, "an unbound target cannot bypass a newly committed identity") | |
| 60 | + | target, err = routes.ResolveDialTarget(ctx, request) | |
| 61 | + | require.NoError(t, err) | |
| 62 | + | require.Equal(t, identity, target.ExpectedIdentity.Identity) | |
| 63 | + | require.True(t, target.ExpectedIdentity.Bound) | |
| 64 | + | cancelled, cancel := context.WithCancel(ctx) | |
| 65 | + | cancel() | |
| 66 | + | registry.Run(cancelled) // flush advisory writes before closing durable ownership | |
| 67 | + | require.NoError(t, store.Close()) | |
| 68 | + | store, err = brokerstore.Open(brokerstore.Options{Dir: dir}) | |
| 69 | + | require.NoError(t, err) | |
| 70 | + | restarted, err := broker.NewRegistryWithConfig(2, store, probe, clock.New(), nil, broker.RegistryConfig{MembershipMode: broker.MembershipMutable}) | |
| 71 | + | require.NoError(t, err) | |
| 72 | + | routes.hosts = restarted | |
| 73 | + | recovered, err := routes.ResolveDialTarget(ctx, request) | |
| 74 | + | require.NoError(t, err) | |
| 75 | + | require.Equal(t, target, recovered) | |
| 76 | + | restarted.Run(cancelled) | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | func TestBrokerRoutesRejectChangedAuthority(t *testing.T) { | |
| 80 | + | reg := domain.RemoteRegistration{Endpoint: "user@example.test", Incarnation: [16]byte{1}, Generation: 1} | |
| 81 | + | policy := remoteBrokerPolicy("stdio") | |
| 82 | + | spec, err := ports.BrokerRouteForTransport(policy.Transport, reg.Endpoint) | |
| 83 | + | require.NoError(t, err) | |
| 84 | + | original := ports.BrokerHostRecord{Registration: reg, Policy: policy, Route: spec} | |
| 85 | + | for _, tc := range []struct { | |
| 86 | + | name string | |
| 87 | + | change func(*routeTestHosts) | |
| 88 | + | }{ | |
| 89 | + | {"removed", func(h *routeTestHosts) { h.found = false }}, | |
| 90 | + | {"readded", func(h *routeTestHosts) { h.record.Registration.Incarnation[0]++ }}, | |
| 91 | + | {"policy", func(h *routeTestHosts) { h.record.Policy.Trust = "other" }}, | |
| 92 | + | {"route", func(h *routeTestHosts) { h.record.Route.Target = "other@example.test" }}, | |
| 93 | + | {"identity", func(h *routeTestHosts) { h.record.Identity = "other-daemon" }}, | |
| 94 | + | } { | |
| 95 | + | t.Run(tc.name, func(t *testing.T) { | |
| 96 | + | hosts := &routeTestHosts{record: original, found: true} | |
| 97 | + | routes := &brokerRoutes{hosts: hosts} | |
| 98 | + | req := ports.BrokerOpenStreamRequest{Endpoint: reg.Endpoint, Registration: reg, Policy: policy, StartMode: ports.BrokerDaemonExistingOnly} | |
| 99 | + | target, err := routes.ResolveDialTarget(context.Background(), req) | |
| 100 | + | require.NoError(t, err) | |
| 101 | + | tc.change(hosts) | |
| 102 | + | _, err = routes.remoteRoute(context.Background(), target) | |
| 103 | + | require.Error(t, err) | |
| 104 | + | }) | |
| 105 | + | } | |
| 106 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 107 | + | cancel() | |
| 108 | + | _, err = (&brokerRoutes{}).ResolveDialTarget(ctx, ports.BrokerOpenStreamRequest{}) | |
| 109 | + | require.ErrorIs(t, err, context.Canceled) | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | func TestCanonicalBrokerRoutesSelectProductionHelpers(t *testing.T) { | |
| 113 | + | for _, transport := range []string{"ssh-stdio", "ssh-quic"} { | |
| 114 | + | t.Run(transport, func(t *testing.T) { | |
| 115 | + | spec, err := ports.BrokerRouteForTransport(transport, "user@example.test") | |
| 116 | + | require.NoError(t, err) | |
| 117 | + | cmd, err := parseArgs(spec.Argv[1:]) | |
| 118 | + | require.NoError(t, err) | |
| 119 | + | require.True(t, cmd.brokerMux.production) | |
| 120 | + | require.Equal(t, ports.BrokerDaemonExistingOnly, cmd.brokerMux.startMode) | |
| 121 | + | for _, args := range [][]string{{"--production", "--production"}, {"--production", "--offline-root", "/tmp/root"}, {"--offline-root", "/tmp/root", "--production"}} { | |
| 122 | + | _, err = parseBrokerMuxArgs(spec.Argv[1], cmd.kind, args) | |
| 123 | + | require.Error(t, err) | |
| 124 | + | } | |
| 125 | + | }) | |
| 126 | + | } | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | // The real production composition starts with no remote import. The first IPC | |
| 130 | + | // add must succeed and the same running registry must retain it across restart. | |
| 131 | + | func TestProductionBrokerFirstHostAdd(t *testing.T) { | |
| 132 | + | t.Setenv("XDG_RUNTIME_DIR", shortTempDir(t, "vevroute")) | |
| 133 | + | t.Setenv("XDG_STATE_HOME", t.TempDir()) | |
| 134 | + | t.Setenv("XDG_CONFIG_HOME", t.TempDir()) | |
| 135 | + | require.NoError(t, safedir.EnsurePrivate(filepath.Dir(productionBrokerConfigPath()))) | |
| 136 | + | raw, err := json.Marshal(map[string]any{"marker": brokerconfig.Marker, "registrations": []any{}}) | |
| 137 | + | require.NoError(t, err) | |
| 138 | + | require.NoError(t, os.WriteFile(productionBrokerConfigPath(), raw, 0600)) | |
| 139 | + | var registered domain.RemoteRegistration | |
| 140 | + | for i := 0; i < 2; i++ { | |
| 141 | + | deps, ready := testBrokerServeDeps(newSandboxClock()) | |
| 142 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 143 | + | done := runSandbox(ctx, brokerServeOptions{production: true}, deps) | |
| 144 | + | socket := awaitSandboxReady(t, ready, done) | |
| 145 | + | service, err := brokeripc.NewConnector(socket, brokeripc.Config{}).Connect(ctx) | |
| 146 | + | require.NoError(t, err) | |
| 147 | + | reg, err := service.AddHost(ctx, "user@127.0.0.1:1", remoteBrokerPolicy("stdio")) | |
| 148 | + | require.NoError(t, err) | |
| 149 | + | if i == 0 { | |
| 150 | + | registered = reg | |
| 151 | + | } else { | |
| 152 | + | require.Equal(t, registered, reg) | |
| 153 | + | } | |
| 154 | + | require.NoError(t, service.Close()) | |
| 155 | + | awaitSandboxCancel(t, cancel, done) | |
| 156 | + | } | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | func TestBrokerDynamicConnectorDialsDurableRouteWithoutConfigEntry(t *testing.T) { | |
| 160 | + | policy := brokerTestPolicy() | |
| 161 | + | fixture := newBrokerMuxFixture(t, policy) | |
| 162 | + | reg := brokerTestRegistration() | |
| 163 | + | hosts := &routeTestHosts{found: true, record: ports.BrokerHostRecord{ | |
| 164 | + | Registration: reg, Policy: policy, Route: ports.BrokerRouteSpec{Kind: ports.BrokerRouteUnix, Path: fixture.route}, | |
| 165 | + | }} | |
| 166 | + | routes := &brokerRoutes{hosts: hosts} | |
| 167 | + | target, err := routes.ResolveDialTarget(context.Background(), ports.BrokerOpenStreamRequest{ | |
| 168 | + | Endpoint: reg.Endpoint, Registration: reg, Policy: policy, StartMode: ports.BrokerDaemonExistingOnly, | |
| 169 | + | }) | |
| 170 | + | require.NoError(t, err) | |
| 171 | + | connector, err := brokerDynamicMuxConnector(nil, routes, nil) | |
| 172 | + | require.NoError(t, err) | |
| 173 | + | physical, err := connector.Connect(context.Background(), target) | |
| 174 | + | require.NoError(t, err) | |
| 175 | + | require.Equal(t, ports.BrokerDaemonIdentity(brokerTestIdentity), physical.Identity()) | |
| 176 | + | require.NoError(t, physical.Close()) | |
| 177 | + | hosts.found = false | |
| 178 | + | _, err = connector.Connect(context.Background(), target) | |
| 179 | + | require.Error(t, err) | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | func TestProductionMuxHelperExistingOnlyNeverSpawns(t *testing.T) { | |
| 183 | + | t.Setenv("XDG_RUNTIME_DIR", shortTempDir(t, "vevhelper")) | |
| 184 | + | previous := localDaemonStarter | |
| 185 | + | defer func() { localDaemonStarter = previous }() | |
| 186 | + | dials, spawns := 0, 0 | |
| 187 | + | localDaemonStarter = brokerDaemonStarter{ | |
| 188 | + | dial: func(_ context.Context, path string) (daemonmux.RawFramedTransport, error) { | |
| 189 | + | dials++ | |
| 190 | + | require.Equal(t, daemonmux.SocketPath(ipc.SocketDir()), path) | |
| 191 | + | return nil, os.ErrNotExist | |
| 192 | + | }, | |
| 193 | + | spawn: func() error { spawns++; return nil }, | |
| 194 | + | } | |
| 195 | + | _, err := dialBrokerMuxHelper(context.Background(), brokerMuxOptions{production: true, startMode: ports.BrokerDaemonExistingOnly}) | |
| 196 | + | require.ErrorIs(t, err, os.ErrNotExist) | |
| 197 | + | require.Equal(t, 1, dials) | |
| 198 | + | require.Zero(t, spawns) | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | func TestBrokerRoutesLocalRemainsConfigured(t *testing.T) { | |
| 202 | + | config, binding := testLocalSandboxConfigForCarriage(t, filepath.Join(t.TempDir(), "daemon.sock")) | |
| 203 | + | routes := &brokerRoutes{local: config.Resolver()} // no remote authority at all | |
| 204 | + | request := ports.BrokerOpenStreamRequest{Local: true, Policy: binding.Policy, StartMode: ports.BrokerDaemonExistingOnly} | |
| 205 | + | target, err := routes.ResolveDialTarget(context.Background(), request) | |
| 206 | + | require.NoError(t, err) | |
| 207 | + | require.Equal(t, binding.Route.Address(), target.Address) | |
| 208 | + | require.Equal(t, binding.Identity, target.ExpectedIdentity.Identity) | |
| 209 | + | request.Policy.Trust = "caller-selected" | |
| 210 | + | _, err = routes.ResolveDialTarget(context.Background(), request) | |
| 211 | + | require.Error(t, err) | |
| 212 | + | } | |
D internal/app/client_hosts.go +0 -91
| @@ -1,91 +0,0 @@ | |||
| 1 | - | package app | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "context" | |
| 5 | - | "errors" | |
| 6 | - | "fmt" | |
| 7 | - | "log/slog" | |
| 8 | - | ||
| 9 | - | remoteadapter "github.com/bnema/vev/internal/adapters/remote" | |
| 10 | - | "github.com/bnema/vev/internal/adapters/sessionwire" | |
| 11 | - | "github.com/bnema/vev/internal/domain" | |
| 12 | - | "github.com/bnema/vev/internal/ports" | |
| 13 | - | "github.com/bnema/vev/internal/usecase/remotes" | |
| 14 | - | ) | |
| 15 | - | ||
| 16 | - | // This file composes the endpoint bindings a launching client reuses across | |
| 17 | - | // handoffs. Remote discovery and catalogue publication remain daemon-owned. | |
| 18 | - | ||
| 19 | - | // clientEndpointFactory resolves one endpoint into the carriage the client | |
| 20 | - | // reuses for every session on that endpoint. Transport mode, launch | |
| 21 | - | // configuration, and endpoint environment are frozen here by the composition | |
| 22 | - | // root; the client only receives the resulting typed dialer. | |
| 23 | - | type clientEndpointFactory struct { | |
| 24 | - | factory remoteDialerForTarget | |
| 25 | - | mode remoteadapter.TransportMode | |
| 26 | - | // modeErr is the transport-mode validation failure, reported for every | |
| 27 | - | // endpoint so an invalid configured mode fails a handoff instead of | |
| 28 | - | // silently selecting another carriage. | |
| 29 | - | modeErr error | |
| 30 | - | // allowlistErr is the launch-allowlist validation failure, reported for | |
| 31 | - | // every endpoint so a malformed configured allowlist fails remote | |
| 32 | - | // resolution instead of admitting an unvetted endpoint. It is deliberately | |
| 33 | - | // not fatal to construction: a local attach never resolves an endpoint and | |
| 34 | - | // must keep working with a malformed allowlist in the environment. | |
| 35 | - | allowlistErr error | |
| 36 | - | environment func(string) []string | |
| 37 | - | allowed map[string]struct{} | |
| 38 | - | // restricted reports whether an allowlist was configured at all. A | |
| 39 | - | // configured allowlist is authoritative even when it lists nothing. | |
| 40 | - | restricted bool | |
| 41 | - | log *slog.Logger | |
| 42 | - | } | |
| 43 | - | ||
| 44 | - | var _ ports.RemoteEndpointFactory = clientEndpointFactory{} | |
| 45 | - | ||
| 46 | - | func (f clientEndpointFactory) ResolveEndpoint(ctx context.Context, endpoint string) (ports.RemoteEndpointBinding, error) { | |
| 47 | - | if err := ctx.Err(); err != nil { | |
| 48 | - | return ports.RemoteEndpointBinding{}, err | |
| 49 | - | } | |
| 50 | - | if err := domain.ValidateRemoteHostTarget(endpoint); err != nil { | |
| 51 | - | return ports.RemoteEndpointBinding{}, fmt.Errorf("vev: invalid remote endpoint: %w", err) | |
| 52 | - | } | |
| 53 | - | if f.modeErr != nil { | |
| 54 | - | return ports.RemoteEndpointBinding{}, f.modeErr | |
| 55 | - | } | |
| 56 | - | if f.allowlistErr != nil { | |
| 57 | - | return ports.RemoteEndpointBinding{}, f.allowlistErr | |
| 58 | - | } | |
| 59 | - | if f.restricted && !allowlistedRemoteEndpoint(f.allowed, endpoint) { | |
| 60 | - | return ports.RemoteEndpointBinding{}, fmt.Errorf("vev: remote endpoint %q is not allowed", endpoint) | |
| 61 | - | } | |
| 62 | - | if f.factory == nil { | |
| 63 | - | return ports.RemoteEndpointBinding{}, errors.New("vev: remote dialer factory unavailable") | |
| 64 | - | } | |
| 65 | - | // The carriage is endpoint-scoped: the session name only labels logs, so a | |
| 66 | - | // binding resolved for one session serves every later session on it. | |
| 67 | - | dialer, err := f.factory(endpoint, "", f.mode, f.log) | |
| 68 | - | if err != nil { | |
| 69 | - | return ports.RemoteEndpointBinding{}, err | |
| 70 | - | } | |
| 71 | - | if dialer == nil { | |
| 72 | - | return ports.RemoteEndpointBinding{}, errors.New("vev: remote dialer factory returned no dialer") | |
| 73 | - | } | |
| 74 | - | return ports.RemoteEndpointBinding{ | |
| 75 | - | Dialer: sessionwire.NewClientDialer(dialer), | |
| 76 | - | Environment: clientEnvironment(f.environment, endpoint), | |
| 77 | - | }, nil | |
| 78 | - | } | |
| 79 | - | ||
| 80 | - | // newClientHostRegistry composes the runner-scoped endpoint cache. A malformed | |
| 81 | - | // launch allowlist is stored on the factory and deferred to ResolveEndpoint, so | |
| 82 | - | // it can only fail a remote target's resolution, never construction or a local | |
| 83 | - | // attach. Without a configured allowlist every endpoint is admitted. | |
| 84 | - | func newClientHostRegistry(deps runAttachDeps, mode remoteadapter.TransportMode, modeErr error, log *slog.Logger) ports.ClientHostRegistry { | |
| 85 | - | allowed, restricted, allowlistErr := remoteLaunchAllowlistFromEnv() | |
| 86 | - | factory := clientEndpointFactory{ | |
| 87 | - | factory: deps.remoteDialerFactory, mode: mode, modeErr: modeErr, allowlistErr: allowlistErr, | |
| 88 | - | environment: deps.remoteEnvironment, allowed: allowed, restricted: restricted, log: log, | |
| 89 | - | } | |
| 90 | - | return remotes.NewHostRegistry(factory) | |
| 91 | - | } | |
D internal/app/client_hosts_test.go +0 -262
| @@ -1,262 +0,0 @@ | |||
| 1 | - | package app | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "context" | |
| 5 | - | "errors" | |
| 6 | - | "log/slog" | |
| 7 | - | "sync" | |
| 8 | - | "testing" | |
| 9 | - | ||
| 10 | - | "github.com/stretchr/testify/require" | |
| 11 | - | ||
| 12 | - | remoteadapter "github.com/bnema/vev/internal/adapters/remote" | |
| 13 | - | "github.com/bnema/vev/internal/ports" | |
| 14 | - | "github.com/bnema/vev/internal/protocol" | |
| 15 | - | "github.com/bnema/vev/internal/protocol/catalogue" | |
| 16 | - | "github.com/bnema/vev/internal/protocol/wire" | |
| 17 | - | "github.com/bnema/vev/internal/usecase/client" | |
| 18 | - | ) | |
| 19 | - | ||
| 20 | - | type clientHostsTestDialer struct{ name string } | |
| 21 | - | ||
| 22 | - | func (d clientHostsTestDialer) Dial(context.Context) (wire.Transport, error) { | |
| 23 | - | return nil, errors.New("client hosts test dialer is never dialed") | |
| 24 | - | } | |
| 25 | - | ||
| 26 | - | var _ wire.Dialer = clientHostsTestDialer{} | |
| 27 | - | ||
| 28 | - | func clientHostsEndpointFactory(factory remoteDialerForTarget, allowed map[string]struct{}, restricted bool, environment func(string) []string) clientEndpointFactory { | |
| 29 | - | return clientEndpointFactory{ | |
| 30 | - | factory: factory, mode: remoteadapter.TransportQUIC, environment: environment, | |
| 31 | - | allowed: allowed, restricted: restricted, log: slog.New(slog.DiscardHandler), | |
| 32 | - | } | |
| 33 | - | } | |
| 34 | - | ||
| 35 | - | func TestClientEndpointFactoryResolvesOneCarriagePerEndpoint(t *testing.T) { | |
| 36 | - | factory := newRemoteDialerFactoryMock(t) | |
| 37 | - | factory.EXPECT().DialerForRemote("arch", "", remoteadapter.TransportQUIC, nil).Return(clientHostsTestDialer{name: "arch"}, nil).Once() | |
| 38 | - | factory.EXPECT().DialerForRemote("cache", "", remoteadapter.TransportQUIC, nil).Return(clientHostsTestDialer{name: "cache"}, nil).Once() | |
| 39 | - | ||
| 40 | - | resolver := clientHostsEndpointFactory(factory.DialerForRemote, nil, false, func(endpoint string) []string { | |
| 41 | - | return []string{"ENDPOINT=" + endpoint} | |
| 42 | - | }) | |
| 43 | - | first, err := resolver.ResolveEndpoint(context.Background(), "arch") | |
| 44 | - | require.NoError(t, err) | |
| 45 | - | require.NotNil(t, first.Dialer) | |
| 46 | - | require.Equal(t, []string{"ENDPOINT=arch"}, first.Environment) | |
| 47 | - | ||
| 48 | - | second, err := resolver.ResolveEndpoint(context.Background(), "cache") | |
| 49 | - | require.NoError(t, err) | |
| 50 | - | require.Equal(t, []string{"ENDPOINT=cache"}, second.Environment) | |
| 51 | - | } | |
| 52 | - | ||
| 53 | - | func TestClientEndpointFactoryRefusesEndpointsOutsideTheAllowlist(t *testing.T) { | |
| 54 | - | factory := newRemoteDialerFactoryMock(t) | |
| 55 | - | resolver := clientHostsEndpointFactory(factory.DialerForRemote, map[string]struct{}{"arch": {}}, true, nil) | |
| 56 | - | ||
| 57 | - | _, err := resolver.ResolveEndpoint(context.Background(), "other") | |
| 58 | - | require.ErrorContains(t, err, "not allowed") | |
| 59 | - | ||
| 60 | - | // A configured allowlist that lists nothing denies every endpoint, and the | |
| 61 | - | // refusal never reaches the dialer factory. | |
| 62 | - | resolver = clientHostsEndpointFactory(factory.DialerForRemote, map[string]struct{}{}, true, nil) | |
| 63 | - | _, err = resolver.ResolveEndpoint(context.Background(), "arch") | |
| 64 | - | require.ErrorContains(t, err, "not allowed") | |
| 65 | - | ||
| 66 | - | // Without a configured allowlist every valid endpoint resolves. | |
| 67 | - | factory.EXPECT().DialerForRemote("arch", "", remoteadapter.TransportQUIC, nil).Return(clientHostsTestDialer{}, nil).Once() | |
| 68 | - | resolver = clientHostsEndpointFactory(factory.DialerForRemote, nil, false, nil) | |
| 69 | - | _, err = resolver.ResolveEndpoint(context.Background(), "arch") | |
| 70 | - | require.NoError(t, err) | |
| 71 | - | } | |
| 72 | - | ||
| 73 | - | func TestClientEndpointFactoryRejectsInvalidEndpoints(t *testing.T) { | |
| 74 | - | factory := newRemoteDialerFactoryMock(t) | |
| 75 | - | resolver := clientHostsEndpointFactory(factory.DialerForRemote, nil, false, nil) | |
| 76 | - | for _, endpoint := range []string{"", "bad\x1bhost", "spaces are not a host"} { | |
| 77 | - | _, err := resolver.ResolveEndpoint(context.Background(), endpoint) | |
| 78 | - | require.Error(t, err, "endpoint %q must be refused before a carriage is built", endpoint) | |
| 79 | - | } | |
| 80 | - | } | |
| 81 | - | ||
| 82 | - | func TestClientEndpointFactoryKeepsFactoryErrorsAuthoritative(t *testing.T) { | |
| 83 | - | factory := newRemoteDialerFactoryMock(t) | |
| 84 | - | want := errors.New("transport mode refused this endpoint") | |
| 85 | - | factory.EXPECT().DialerForRemote("arch", "", remoteadapter.TransportQUIC, nil).Return(nil, want).Once() | |
| 86 | - | resolver := clientHostsEndpointFactory(factory.DialerForRemote, nil, false, nil) | |
| 87 | - | ||
| 88 | - | _, err := resolver.ResolveEndpoint(context.Background(), "arch") | |
| 89 | - | require.ErrorIs(t, err, want) | |
| 90 | - | } | |
| 91 | - | ||
| 92 | - | func TestClientEndpointFactoryHonoursContextCancellation(t *testing.T) { | |
| 93 | - | factory := newRemoteDialerFactoryMock(t) | |
| 94 | - | resolver := clientHostsEndpointFactory(factory.DialerForRemote, nil, false, nil) | |
| 95 | - | ctx, cancel := context.WithCancel(context.Background()) | |
| 96 | - | cancel() | |
| 97 | - | _, err := resolver.ResolveEndpoint(ctx, "arch") | |
| 98 | - | require.ErrorIs(t, err, context.Canceled) | |
| 99 | - | } | |
| 100 | - | ||
| 101 | - | // TestClientHostRegistryDefersMalformedLaunchAllowlist pins that a malformed | |
| 102 | - | // configured allowlist is stored on the endpoint factory instead of failing | |
| 103 | - | // registry construction: a local attach never resolves a remote endpoint, so | |
| 104 | - | // construction and local attach keep working, while remote resolution stays | |
| 105 | - | // fail-closed. | |
| 106 | - | func TestClientHostRegistryDefersMalformedLaunchAllowlist(t *testing.T) { | |
| 107 | - | t.Setenv(launchAllowedRemoteEndpointsEnv, "user@bad host") | |
| 108 | - | factory := newRemoteDialerFactoryMock(t) | |
| 109 | - | ||
| 110 | - | registry := newClientHostRegistry(runAttachDeps{remoteDialerFactory: factory.DialerForRemote}, remoteadapter.TransportQUIC, nil, slog.New(slog.DiscardHandler)) | |
| 111 | - | require.NotNil(t, registry, "a malformed allowlist must not fail registry construction") | |
| 112 | - | ||
| 113 | - | _, err := registry.ResolveEndpoint(context.Background(), "arch") | |
| 114 | - | require.ErrorContains(t, err, "invalid configured remote endpoint allowlist") | |
| 115 | - | } | |
| 116 | - | ||
| 117 | - | // TestRunAttachWithDepsLocalAttachSurvivesMalformedLaunchAllowlist pins the | |
| 118 | - | // local attach path end to end: a malformed remote allowlist in the | |
| 119 | - | // environment is deferred, so a local session still attaches. | |
| 120 | - | func TestRunAttachWithDepsLocalAttachSurvivesMalformedLaunchAllowlist(t *testing.T) { | |
| 121 | - | t.Setenv(launchAllowedRemoteEndpointsEnv, "user@bad host") | |
| 122 | - | factory := newRemoteDialerFactoryMock(t) | |
| 123 | - | attached := false | |
| 124 | - | err := runAttachWithDeps(context.Background(), protocol.IntentEphemeral, "", "", "", nil, runAttachDeps{ | |
| 125 | - | localDialer: func() wire.Dialer { return clientHostsTestDialer{name: "local"} }, | |
| 126 | - | remoteDialerFactory: factory.DialerForRemote, | |
| 127 | - | runClient: func(_ context.Context, _ client.Dependencies, request client.AttachRequest) error { | |
| 128 | - | require.False(t, request.Remote) | |
| 129 | - | attached = true | |
| 130 | - | return nil | |
| 131 | - | }, | |
| 132 | - | }) | |
| 133 | - | require.NoError(t, err, "a malformed remote allowlist must not break local attach") | |
| 134 | - | require.True(t, attached) | |
| 135 | - | } | |
| 136 | - | ||
| 137 | - | func TestClientEndpointFactoryKeepsNilEnvironmentDistinct(t *testing.T) { | |
| 138 | - | factory := newRemoteDialerFactoryMock(t) | |
| 139 | - | factory.EXPECT().DialerForRemote("arch", "", remoteadapter.TransportQUIC, nil).Return(clientHostsTestDialer{}, nil).Once() | |
| 140 | - | factory.EXPECT().DialerForRemote("cache", "", remoteadapter.TransportQUIC, nil).Return(clientHostsTestDialer{}, nil).Once() | |
| 141 | - | resolver := clientHostsEndpointFactory(factory.DialerForRemote, nil, false, func(endpoint string) []string { | |
| 142 | - | if endpoint == "cache" { | |
| 143 | - | return []string{} | |
| 144 | - | } | |
| 145 | - | return nil | |
| 146 | - | }) | |
| 147 | - | ||
| 148 | - | absent, err := resolver.ResolveEndpoint(context.Background(), "arch") | |
| 149 | - | require.NoError(t, err) | |
| 150 | - | require.Nil(t, absent.Environment) | |
| 151 | - | ||
| 152 | - | empty, err := resolver.ResolveEndpoint(context.Background(), "cache") | |
| 153 | - | require.NoError(t, err) | |
| 154 | - | require.NotNil(t, empty.Environment) | |
| 155 | - | require.Empty(t, empty.Environment) | |
| 156 | - | } | |
| 157 | - | ||
| 158 | - | // memorySeedCache is a durable-cache stand-in that records writes, so a test | |
| 159 | - | // can prove the client monitor never writes the daemon's snapshot file. | |
| 160 | - | type memorySeedCache struct { | |
| 161 | - | mu sync.Mutex | |
| 162 | - | entries []catalogue.RemoteCatalogCacheEntry | |
| 163 | - | loads int | |
| 164 | - | stores int | |
| 165 | - | loadErr error | |
| 166 | - | } | |
| 167 | - | ||
| 168 | - | func (c *memorySeedCache) Load() ([]catalogue.RemoteCatalogCacheEntry, error) { | |
| 169 | - | c.mu.Lock() | |
| 170 | - | defer c.mu.Unlock() | |
| 171 | - | c.loads++ | |
| 172 | - | if c.loadErr != nil { | |
| 173 | - | return nil, c.loadErr | |
| 174 | - | } | |
| 175 | - | return append([]catalogue.RemoteCatalogCacheEntry(nil), c.entries...), nil | |
| 176 | - | } | |
| 177 | - | ||
| 178 | - | func (c *memorySeedCache) Store(entries []catalogue.RemoteCatalogCacheEntry) error { | |
| 179 | - | c.mu.Lock() | |
| 180 | - | defer c.mu.Unlock() | |
| 181 | - | c.stores++ | |
| 182 | - | c.entries = append([]catalogue.RemoteCatalogCacheEntry(nil), entries...) | |
| 183 | - | return nil | |
| 184 | - | } | |
| 185 | - | ||
| 186 | - | func (c *memorySeedCache) writeCount() int { | |
| 187 | - | c.mu.Lock() | |
| 188 | - | defer c.mu.Unlock() | |
| 189 | - | return c.stores | |
| 190 | - | } | |
| 191 | - | ||
| 192 | - | func (c *memorySeedCache) loadCount() int { | |
| 193 | - | c.mu.Lock() | |
| 194 | - | defer c.mu.Unlock() | |
| 195 | - | return c.loads | |
| 196 | - | } | |
| 197 | - | ||
| 198 | - | var _ ports.RemoteCatalogCache = (*memorySeedCache)(nil) | |
| 199 | - | ||
| 200 | - | // TestSeededCatalogCacheNeverWritesTheDurableSnapshot pins the client cache | |
| 201 | - | // contract: it seeds from the durable entries once and keeps every later write | |
| 202 | - | // in its own memory, so a client monitor cannot race the daemon's writer. | |
| 203 | - | func TestSeededCatalogCacheNeverWritesTheDurableSnapshot(t *testing.T) { | |
| 204 | - | seed := &memorySeedCache{entries: []catalogue.RemoteCatalogCacheEntry{{Host: "arch"}}} | |
| 205 | - | client := remoteadapter.NewSeededCatalogCache(seed) | |
| 206 | - | ||
| 207 | - | seeded, err := client.Load() | |
| 208 | - | require.NoError(t, err) | |
| 209 | - | require.Len(t, seeded, 1) | |
| 210 | - | require.Equal(t, "arch", seeded[0].Host) | |
| 211 | - | require.Equal(t, 1, seed.loadCount(), "the durable cache is read once") | |
| 212 | - | ||
| 213 | - | require.NoError(t, client.Store([]catalogue.RemoteCatalogCacheEntry{{Host: "cache"}})) | |
| 214 | - | require.Zero(t, seed.writeCount(), "the client cache must never write the durable snapshot") | |
| 215 | - | require.Equal(t, 1, seed.loadCount(), "a write suppresses the seed read") | |
| 216 | - | ||
| 217 | - | stored, err := client.Load() | |
| 218 | - | require.NoError(t, err) | |
| 219 | - | require.Equal(t, "cache", stored[0].Host) | |
| 220 | - | } | |
| 221 | - | ||
| 222 | - | func TestSeededCatalogCacheCopiesEntriesDefensively(t *testing.T) { | |
| 223 | - | seed := &memorySeedCache{entries: []catalogue.RemoteCatalogCacheEntry{{ | |
| 224 | - | Host: "arch", Sessions: []catalogue.RemoteCatalogSession{{Name: "work", Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-1"}}}}, | |
| 225 | - | }}} | |
| 226 | - | client := remoteadapter.NewSeededCatalogCache(seed) | |
| 227 | - | ||
| 228 | - | first, err := client.Load() | |
| 229 | - | require.NoError(t, err) | |
| 230 | - | first[0].Sessions[0].Tabs[0].ID = "mutated" | |
| 231 | - | first[0].Host = "mutated" | |
| 232 | - | ||
| 233 | - | second, err := client.Load() | |
| 234 | - | require.NoError(t, err) | |
| 235 | - | require.Equal(t, "arch", second[0].Host) | |
| 236 | - | require.Equal(t, "tab-1", second[0].Sessions[0].Tabs[0].ID) | |
| 237 | - | ||
| 238 | - | seed.mu.Lock() | |
| 239 | - | seed.entries[0].Host = "mutated" | |
| 240 | - | seed.mu.Unlock() | |
| 241 | - | third, err := client.Load() | |
| 242 | - | require.NoError(t, err) | |
| 243 | - | require.Equal(t, "arch", third[0].Host, "the client cache is not a live view of the durable file") | |
| 244 | - | } | |
| 245 | - | ||
| 246 | - | func TestSeededCatalogCacheReportsASeedFailureOnce(t *testing.T) { | |
| 247 | - | want := errors.New("durable cache is unreadable") | |
| 248 | - | seed := &memorySeedCache{loadErr: want} | |
| 249 | - | client := remoteadapter.NewSeededCatalogCache(seed) | |
| 250 | - | ||
| 251 | - | _, err := client.Load() | |
| 252 | - | require.ErrorIs(t, err, want) | |
| 253 | - | _, err = client.Load() | |
| 254 | - | require.ErrorIs(t, err, want) | |
| 255 | - | require.Equal(t, 1, seed.loadCount(), "a broken seed is reported, never retried") | |
| 256 | - | ||
| 257 | - | var nilCache *remoteadapter.SeededCatalogCache | |
| 258 | - | entries, err := nilCache.Load() | |
| 259 | - | require.NoError(t, err) | |
| 260 | - | require.Empty(t, entries) | |
| 261 | - | require.NoError(t, nilCache.Store(nil)) | |
| 262 | - | } | |
M internal/app/cmd.go +61 -12
| @@ -16,6 +16,7 @@ import ( | |||
| 16 | 16 | "github.com/bnema/vev/internal/ports" | |
| 17 | 17 | "github.com/bnema/vev/internal/protocol" | |
| 18 | 18 | "github.com/bnema/vev/internal/protocol/wire" | |
| 19 | + | clientusecase "github.com/bnema/vev/internal/usecase/client" | |
| 19 | 20 | commandusecase "github.com/bnema/vev/internal/usecase/command" | |
| 20 | 21 | "github.com/bnema/vev/internal/usecase/daemon" | |
| 21 | 22 | ) | |
| @@ -152,18 +153,20 @@ func (e *commandOutcomeUnknown) Unwrap() error { return e.detail } | |||
| 152 | 153 | func (*commandOutcomeUnknown) Is(target error) bool { return target == errCommandOutcomeUnknown } | |
| 153 | 154 | ||
| 154 | 155 | type cmdDeps struct { | |
| 155 | - | stdout io.Writer | |
| 156 | - | getenv func(string) string | |
| 157 | - | dial func(context.Context, string) (wire.Transport, error) | |
| 158 | - | ensure func(context.Context, string) (wire.Transport, error) | |
| 159 | - | clock ports.Clock | |
| 156 | + | stdout io.Writer | |
| 157 | + | getenv func(string) string | |
| 158 | + | connect func(context.Context) (ports.BrokerService, error) | |
| 159 | + | dial func(context.Context, string) (wire.Transport, error) | |
| 160 | + | ensure func(context.Context, string) (wire.Transport, error) | |
| 161 | + | clock ports.Clock | |
| 160 | 162 | } | |
| 161 | 163 | ||
| 162 | 164 | func runCmd(ctx context.Context, invocation cmdInvocation) error { | |
| 163 | 165 | return runCmdWithDeps(ctx, invocation, cmdDeps{ | |
| 164 | - | stdout: os.Stdout, | |
| 165 | - | getenv: os.Getenv, | |
| 166 | - | dial: realDial, | |
| 166 | + | stdout: os.Stdout, | |
| 167 | + | getenv: os.Getenv, | |
| 168 | + | connect: connectProductionBroker, | |
| 169 | + | dial: realDial, | |
| 167 | 170 | ensure: func(ctx context.Context, dir string) (wire.Transport, error) { | |
| 168 | 171 | return ensureDaemonWithLifecycle(ctx, dir, realDial, realSpawn, defaultBackoff) | |
| 169 | 172 | }, | |
| @@ -200,11 +203,28 @@ func runCmdWithDeps(ctx context.Context, invocation cmdInvocation, deps cmdDeps) | |||
| 200 | 203 | return usagef("too many command arguments") | |
| 201 | 204 | } | |
| 202 | 205 | ||
| 203 | - | dial := deps.dial | |
| 204 | - | if invocation.slug == "remote-catalog" && deps.ensure != nil { | |
| 205 | - | dial = deps.ensure | |
| 206 | + | if deps.connect != nil { | |
| 207 | + | service, err := deps.connect(ctx) | |
| 208 | + | if err != nil { | |
| 209 | + | return &exitCoded{code: 3, err: fmt.Errorf("%w: %w", errDaemonUnreachable, err)} | |
| 210 | + | } | |
| 211 | + | defer func() { _ = service.Close() }() | |
| 212 | + | commandClock := deps.clock | |
| 213 | + | if commandClock == nil { | |
| 214 | + | commandClock = clock.New() | |
| 215 | + | } | |
| 216 | + | operations, err := clientusecase.NewBrokerOperations(service, commandClock) | |
| 217 | + | if err != nil { | |
| 218 | + | return err | |
| 219 | + | } | |
| 220 | + | result, err := operations.Command(ctx, localBrokerOperationRoute(service.Snapshot()), request) | |
| 221 | + | if err != nil { | |
| 222 | + | return err | |
| 223 | + | } | |
| 224 | + | return renderCommandResult(deps.stdout, result) | |
| 206 | 225 | } | |
| 207 | - | transport, err := dial(ctx, ipc.SocketDir()) | |
| 226 | + | ||
| 227 | + | transport, err := deps.dial(ctx, ipc.SocketDir()) | |
| 208 | 228 | if err != nil { | |
| 209 | 229 | return &exitCoded{code: 3, err: fmt.Errorf("%w: %w", errDaemonUnreachable, err)} | |
| 210 | 230 | } | |
| @@ -284,3 +304,32 @@ func runCmdWithDeps(ctx context.Context, invocation cmdInvocation, deps cmdDeps) | |||
| 284 | 304 | } | |
| 285 | 305 | return nil | |
| 286 | 306 | } | |
| 307 | + | ||
| 308 | + | func renderCommandResult(stdout io.Writer, result protocol.CommandResult) error { | |
| 309 | + | if result.Outcome == protocol.CommandOutcomeUnknown { | |
| 310 | + | text := result.Text | |
| 311 | + | if text == "" { | |
| 312 | + | text = "daemon did not report a final outcome" | |
| 313 | + | } | |
| 314 | + | return &exitCoded{code: 3, err: &commandOutcomeUnknown{detail: errors.New(text)}} | |
| 315 | + | } | |
| 316 | + | if result.Outcome != protocol.CommandSucceeded { | |
| 317 | + | if result.Text == "" { | |
| 318 | + | result.Text = "command failed" | |
| 319 | + | } | |
| 320 | + | if result.Code == protocol.ErrInvalidCommandArgs { | |
| 321 | + | return &exitCoded{code: 2, err: errors.New(result.Text)} | |
| 322 | + | } | |
| 323 | + | return errors.New(result.Text) | |
| 324 | + | } | |
| 325 | + | if result.Output != "" { | |
| 326 | + | if _, err := io.WriteString(stdout, result.Output); err != nil { | |
| 327 | + | return err | |
| 328 | + | } | |
| 329 | + | if !strings.HasSuffix(result.Output, "\n") { | |
| 330 | + | _, err := fmt.Fprintln(stdout) | |
| 331 | + | return err | |
| 332 | + | } | |
| 333 | + | } | |
| 334 | + | return nil | |
| 335 | + | } | |
M internal/app/cmd_test.go +10 -11
| @@ -12,6 +12,7 @@ import ( | |||
| 12 | 12 | "time" | |
| 13 | 13 | ||
| 14 | 14 | "github.com/bnema/vev/internal/adapters/sessionwire" | |
| 15 | + | "github.com/bnema/vev/internal/ports" | |
| 15 | 16 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 16 | 17 | "github.com/bnema/vev/internal/protocol" | |
| 17 | 18 | "github.com/bnema/vev/internal/protocol/wire" | |
| @@ -207,23 +208,21 @@ func TestMoveCmdPreservesPositionalArguments(t *testing.T) { | |||
| 207 | 208 | } | |
| 208 | 209 | } | |
| 209 | 210 | ||
| 210 | - | func TestRemoteCatalogCommandEnsuresDaemon(t *testing.T) { | |
| 211 | - | transport := &cmdTestTransport{recv: mustServerEnvelope(protocol.CommandResult{Outcome: protocol.CommandSucceeded})} | |
| 212 | - | ensureCalls := 0 | |
| 211 | + | func TestRemoteCatalogCommandUsesBroker(t *testing.T) { | |
| 212 | + | stream := newSeamBrokerStream(protocol.CommandResult{RequestID: 1, Outcome: protocol.CommandSucceeded}) | |
| 213 | + | service := newSeamBrokerService(stream) | |
| 213 | 214 | err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "remote-catalog", jsonOut: true}, cmdDeps{ | |
| 214 | - | stdout: io.Discard, | |
| 215 | - | getenv: func(string) string { return "" }, | |
| 215 | + | stdout: io.Discard, | |
| 216 | + | getenv: func(string) string { return "" }, | |
| 217 | + | connect: func(context.Context) (ports.BrokerService, error) { return service, nil }, | |
| 216 | 218 | dial: func(context.Context, string) (wire.Transport, error) { | |
| 217 | - | t.Fatal("remote catalog used non-starting daemon dial") | |
| 219 | + | t.Fatal("remote catalog bypassed the broker") | |
| 218 | 220 | return nil, errors.New("unexpected dial") | |
| 219 | 221 | }, | |
| 220 | - | ensure: func(context.Context, string) (wire.Transport, error) { | |
| 221 | - | ensureCalls++ | |
| 222 | - | return transport, nil | |
| 223 | - | }, | |
| 224 | 222 | }) | |
| 225 | 223 | require.NoError(t, err) | |
| 226 | - | require.Equal(t, 1, ensureCalls) | |
| 224 | + | require.Len(t, service.openedRequests(), 1) | |
| 225 | + | require.Equal(t, ports.BrokerStreamControl, service.openedRequests()[0].Purpose) | |
| 227 | 226 | } | |
| 228 | 227 | ||
| 229 | 228 | func TestMoveCmdInvalidArgumentResultExitsTwo(t *testing.T) { | |
M internal/app/connectivity_matrix.go +61 -61
| @@ -61,8 +61,14 @@ type connectivityEntry struct { | |||
| 61 | 61 | Summary string | |
| 62 | 62 | Owner connectivityOwner | |
| 63 | 63 | // DirectDialDebt lists today's direct-daemon dial symbols that the | |
| 64 | - | // cutover removes for broker-only entries. Empty for infra/local rows. | |
| 64 | + | // cutover removes for broker-only entries. Empty for infra/local rows and | |
| 65 | + | // for a broker-only row that has already completed its cutover. | |
| 65 | 66 | DirectDialDebt []string | |
| 67 | + | // Migrated marks a broker-only row whose cutover is complete: it keeps no | |
| 68 | + | // direct dial, no host-store write, and no persist mutation, so its debt | |
| 69 | + | // list is deliberately empty. It exists so a finished row is declared | |
| 70 | + | // explicitly instead of being mistaken for an unreviewed one. | |
| 71 | + | Migrated bool | |
| 66 | 72 | // HostStoreWrite reports a RemoteHostStore constructor/writer today. | |
| 67 | 73 | // Only the broker owns the host store after cutover. | |
| 68 | 74 | HostStoreWrite bool | |
| @@ -77,28 +83,19 @@ type connectivityEntry struct { | |||
| 77 | 83 | var connectivityMatrix = []connectivityEntry{ | |
| 78 | 84 | { | |
| 79 | 85 | Kind: "kindAttach", | |
| 80 | - | Summary: "interactive terminal attach/new/ephemeral, incl. remote attach, detached creation, attach preflight", | |
| 86 | + | Summary: "interactive terminal attach/new/ephemeral, incl. remote attach, detached creation, and the UI-driver/web path", | |
| 81 | 87 | Owner: connectivityBrokerOnly, | |
| 82 | 88 | DirectDialDebt: []string{ | |
| 83 | - | "localDaemonDialer.Dial (run.go)", | |
| 84 | - | "dialOnlyLocalDialer.Dial inventory control (run.go)", | |
| 85 | 89 | "ensureDaemonWithLifecycle detached creation (run.go createDetachedLocalSession)", | |
| 86 | - | "registry.ResolveEndpoint remote carriage (run.go runAttachWithDeps)", | |
| 87 | - | "listSessionsWithDialer attach preflight (attach_preflight.go sessionExists)", | |
| 88 | 90 | }, | |
| 89 | - | Notes: "vev without arguments keeps creating an ephemeral session; creation is expressed as client navigation through the broker.", | |
| 91 | + | Notes: "The ordinary terminal path is already broker-only: runAttach translates the parsed CLI target into client.InitialNavigation/Resolver, connect-or-spawns the per-user broker, and delegates to the shared runBrokerClient (run.go, broker_client.go). The old direct-dialer attach composition and its attach preflight and client host registry are removed; the UI-driver and browser compositions delegate to the shared broker client. The only remaining debt is the nested-session detached creation path, which still dials the local daemon directly and is expressed as broker navigation for ordinary attach.", | |
| 90 | 92 | }, | |
| 91 | 93 | { | |
| 92 | - | Kind: "kindList", | |
| 93 | - | Summary: "session list: local, per-host, and --all", | |
| 94 | - | Owner: connectivityBrokerOnly, | |
| 95 | - | DirectDialDebt: []string{ | |
| 96 | - | "realDial listSessionsWithDialer (run.go)", | |
| 97 | - | "waitForDaemonOrLifecycle list path (run.go)", | |
| 98 | - | "RemoteCatalogClient.List over SSH (remote_hosts.go listAllSessions/listOneRemoteHost)", | |
| 99 | - | }, | |
| 100 | - | PersistMutation: true, | |
| 101 | - | Notes: "Offline persist.LoadReadOnly list path becomes a daemon-owned operation through the broker; observation never attaches.", | |
| 94 | + | Kind: "kindList", | |
| 95 | + | Summary: "session list: local, per-host, and --all", | |
| 96 | + | Owner: connectivityBrokerOnly, | |
| 97 | + | Migrated: true, | |
| 98 | + | Notes: "Cutover complete: runList reaches the per-user broker through the private connectBroker seam (run.go) and reads BrokerOperations.List for the local list or runBrokerSnapshotList for per-host/--all. It keeps no persist read, no daemon dialer, and no direct-dial list path. Remote observation never attaches.", | |
| 102 | 99 | }, | |
| 103 | 100 | { | |
| 104 | 101 | Kind: "kindHost", | |
| @@ -109,15 +106,11 @@ var connectivityMatrix = []connectivityEntry{ | |||
| 109 | 106 | Notes: "Second host-store writer today; broker is the sole writer after cutover. Host list is store-only (no dial).", | |
| 110 | 107 | }, | |
| 111 | 108 | { | |
| 112 | - | Kind: "kindKill", | |
| 113 | - | Summary: "kill one session, kill-all, explicit daemon-stop", | |
| 114 | - | Owner: connectivityBrokerOnly, | |
| 115 | - | DirectDialDebt: []string{ | |
| 116 | - | "waitForDaemonOrLifecycle realDial kill path (run.go)", | |
| 117 | - | "forceStopDaemonFallback (force_stop.go)", | |
| 118 | - | }, | |
| 119 | - | PersistMutation: true, | |
| 120 | - | Notes: "Offline runOfflineNamedKill mutation becomes daemon-owned via broker. Kill-all purges sessions and leaves the daemon running; daemon-stop is the distinct explicit stop. Direct force-stop is offered only when the typed endpoint is unreachable, never after a request may have been delivered.", | |
| 109 | + | Kind: "kindKill", | |
| 110 | + | Summary: "kill one session, kill-all, explicit daemon-stop", | |
| 111 | + | Owner: connectivityBrokerOnly, | |
| 112 | + | Migrated: true, | |
| 113 | + | Notes: "Cutover complete: runKill and requestDaemonStop reach the per-user broker through the private connectBroker seam (run.go) and drive BrokerOperations.Kill/KillAll/StopDaemon. Daemon-stop uses ExistingOnly, so it can never start what it is stopping; kill-all purges sessions and leaves the daemon running. The offline persisted-mutation path and the direct force-stop fallback are removed, so no request is ever bypassed with a direct process signal.", | |
| 121 | 114 | }, | |
| 122 | 115 | { | |
| 123 | 116 | Kind: "kindCmd", | |
| @@ -130,28 +123,32 @@ var connectivityMatrix = []connectivityEntry{ | |||
| 130 | 123 | Notes: "Command tracker semantics unchanged; only the carriage moves behind the broker.", | |
| 131 | 124 | }, | |
| 132 | 125 | { | |
| 133 | - | Kind: "kindUIDriver", | |
| 134 | - | Summary: "headless UI-driver attach", | |
| 135 | - | Owner: connectivityBrokerOnly, | |
| 136 | - | DirectDialDebt: []string{ | |
| 137 | - | "localDaemonDialer launch/local paths (ui_driver.go, run.go runAttachWithDeps)", | |
| 138 | - | "configuredRemoteLaunches dialerFactory (ui_driver.go)", | |
| 139 | - | }, | |
| 140 | - | Notes: "Launch-owned remote dialer and cleanup move behind the broker; UI-driver keeps no parallel carriage.", | |
| 126 | + | Kind: "kindUIDriver", | |
| 127 | + | Summary: "headless UI-driver attach", | |
| 128 | + | Owner: connectivityBrokerOnly, | |
| 129 | + | Migrated: true, | |
| 130 | + | Notes: "Cutover complete: the driver composes the process' production broker connector and the shared terminal initial-navigation translation through runUIDriverClient. It keeps no launch configuration, no dialer, no endpoint, and no daemon ownership.", | |
| 141 | 131 | }, | |
| 142 | 132 | { | |
| 143 | - | Kind: "kindWebDaemon", | |
| 144 | - | Summary: "browser gateway launcher", | |
| 145 | - | Owner: connectivityBrokerOnly, | |
| 146 | - | DirectDialDebt: []string{"gateway session path via runAttachWithDeps (web_daemon.go)"}, | |
| 147 | - | Notes: "Launcher mechanics stay local; gateway session traffic goes through the broker like any client.", | |
| 133 | + | Kind: "kindUIRemoteCleanup", | |
| 134 | + | Summary: "UI fixture cleanup through broker control", | |
| 135 | + | Owner: connectivityBrokerOnly, | |
| 136 | + | Migrated: true, | |
| 137 | + | Notes: "Cleanup uses the broker-owned daemon-stop operation and keeps no direct daemon carriage.", | |
| 148 | 138 | }, | |
| 149 | 139 | { | |
| 150 | - | Kind: "kindWebServe", | |
| 151 | - | Summary: "browser gateway server", | |
| 152 | - | Owner: connectivityBrokerOnly, | |
| 153 | - | DirectDialDebt: []string{"gateway session path via runAttachWithDeps (web_daemon.go)"}, | |
| 154 | - | Notes: "One client runner per WebSocket; no bypass of the broker façade.", | |
| 140 | + | Kind: "kindWebDaemon", | |
| 141 | + | Summary: "browser gateway launcher", | |
| 142 | + | Owner: connectivityBrokerOnly, | |
| 143 | + | Migrated: true, | |
| 144 | + | Notes: "Launcher mechanics stay local. Cutover complete: the gateway server composes the shared runBrokerClient over the process' production broker connector (web_daemon.go runWebTerminalClient), so gateway session traffic goes through the broker like any client. The launcher keeps no dialer, no session carriage, and no daemon ownership.", | |
| 145 | + | }, | |
| 146 | + | { | |
| 147 | + | Kind: "kindWebServe", | |
| 148 | + | Summary: "browser gateway server", | |
| 149 | + | Owner: connectivityBrokerOnly, | |
| 150 | + | Migrated: true, | |
| 151 | + | Notes: "Cutover complete: every authenticated WebSocket owns exactly one runBrokerClient over the shared production broker connector, with the virtual webterm.Terminal as that run's terminal, the no-argument local ephemeral creation as its closed initial navigation, and the standard presentation callbacks. It keeps no direct daemon dialer, no sessionwire carriage, no remote factory, and no launch configuration; disconnect cancels only that supervisor's service and logical stream.", | |
| 155 | 152 | }, | |
| 156 | 153 | { | |
| 157 | 154 | Kind: "kindDaemon", | |
| @@ -183,27 +180,18 @@ var connectivityMatrix = []connectivityEntry{ | |||
| 183 | 180 | Owner: connectivityTransportInfra, | |
| 184 | 181 | Notes: "Blind proxy may forward raw envelopes; exposes no bytes to use cases and owns no sessions.", | |
| 185 | 182 | }, | |
| 186 | - | { | |
| 187 | - | Kind: "kindRemotePreview", | |
| 188 | - | Summary: "_remote-preview SSH-side preview carriage", | |
| 189 | - | Owner: connectivityTransportInfra, | |
| 190 | - | DirectDialDebt: []string{ | |
| 191 | - | "ipc.DialContext direct local dial (remote_preview.go)", | |
| 192 | - | }, | |
| 193 | - | Notes: "Far-side helper only. Client-facing previews flow through broker snapshots after cutover; this helper never becomes a client façade. Direct-dial debt is recorded here rather than as broker debt because the helper itself stays infra.", | |
| 194 | - | }, | |
| 195 | - | { | |
| 196 | - | Kind: "kindUIRemoteCleanup", | |
| 197 | - | Summary: "_ui-cleanup remote-side launch cleanup", | |
| 198 | - | Owner: connectivityTransportInfra, | |
| 199 | - | Notes: "Scoped remote cleanup; never launches observers or brokers recursively.", | |
| 200 | - | }, | |
| 201 | 183 | { | |
| 202 | 184 | Kind: "kindWebRenew", | |
| 203 | 185 | Summary: "browser token renewal", | |
| 204 | 186 | Owner: connectivityLocalOnly, | |
| 205 | 187 | Notes: "Local control only; no daemon or session connectivity.", | |
| 206 | 188 | }, | |
| 189 | + | { | |
| 190 | + | Kind: "kindBrokerReady", | |
| 191 | + | Summary: "hidden dial-only broker readiness probe", | |
| 192 | + | Owner: connectivityLocalOnly, | |
| 193 | + | Notes: "Dials only the selected broker IPC socket, registers, subscribes, and reads snapshots; it never ensures, spawns, opens a logical stream, reconciles, or mutates.", | |
| 194 | + | }, | |
| 207 | 195 | { | |
| 208 | 196 | Kind: "kindHelp", | |
| 209 | 197 | Summary: "usage text", | |
| @@ -222,6 +210,12 @@ var connectivityMatrix = []connectivityEntry{ | |||
| 222 | 210 | Owner: connectivityLocalOnly, | |
| 223 | 211 | Notes: "Foreground sandbox process over its own private root: no production runtime/state, no daemon dial, no RemoteHostStore, and no session persistence. It composes the broker under a temporary offline config and does not become a client façade or an ordinary command.", | |
| 224 | 212 | }, | |
| 213 | + | { | |
| 214 | + | Kind: "kindProductionBrokerServe", | |
| 215 | + | Summary: "hidden production broker server", | |
| 216 | + | Owner: connectivityLocalOnly, | |
| 217 | + | Notes: "Foreground production broker using only productionBrokerLayout and productionBrokerConfigPath; it accepts no offline root.", | |
| 218 | + | }, | |
| 225 | 219 | { | |
| 226 | 220 | Kind: "kindBrokerClient", | |
| 227 | 221 | Summary: "hidden _broker-client autonomous offline client harness", | |
| @@ -234,6 +228,12 @@ var connectivityMatrix = []connectivityEntry{ | |||
| 234 | 228 | Owner: connectivityLocalOnly, | |
| 235 | 229 | Notes: "Process mechanics only, scoped to one operator-supplied offline root: it starts _broker-serve in a new session and exits, like the daemon launcher, and never dials a daemon, owns the host store, or mutates persistence.", | |
| 236 | 230 | }, | |
| 231 | + | { | |
| 232 | + | Kind: "kindProductionBrokerLauncher", | |
| 233 | + | Summary: "hidden detached production broker launcher", | |
| 234 | + | Owner: connectivityLocalOnly, | |
| 235 | + | Notes: "Process mechanics for the production broker only; it accepts no offline root and starts the production serve role in a new session.", | |
| 236 | + | }, | |
| 237 | 237 | { | |
| 238 | 238 | Kind: "kindBrokerStatus", | |
| 239 | 239 | Summary: "hidden _broker-status offline broker probe and connect-or-spawn", | |
| @@ -247,7 +247,7 @@ var connectivityMatrix = []connectivityEntry{ | |||
| 247 | 247 | DirectDialDebt: []string{ | |
| 248 | 248 | "ipc.DialMuxContext local daemonmux carriage (broker_offline_mux.go)", | |
| 249 | 249 | }, | |
| 250 | - | Notes: "Remote-side helper over one operator-supplied offline root: it bridges its own stdio to the single provisioned private Unix daemonmux carriage. It never starts a broker, observer, or ordinary daemon, never dials the production daemon socket, and never fabricates a daemon incarnation. Direct-dial debt is recorded here rather than as broker debt because the helper itself stays transport infra.", | |
| 250 | + | Notes: "Remote-side helper over one operator-supplied offline root: it bridges its own stdio to the single provisioned private Unix daemonmux carriage. It never starts a broker, observer, or recursive helper, never dials the production daemon socket, and never fabricates a daemon incarnation. It starts the daemon behind that carriage only under the propagated --daemon-start if-needed authorization and only when its own provisioned policy permits launching; otherwise it dials only an existing carriage. Direct-dial debt is recorded here rather than as broker debt because the helper itself stays transport infra.", | |
| 251 | 251 | }, | |
| 252 | 252 | { | |
| 253 | 253 | Kind: "kindBrokerMuxQUICBootstrap", | |
| @@ -262,7 +262,7 @@ var connectivityMatrix = []connectivityEntry{ | |||
| 262 | 262 | DirectDialDebt: []string{ | |
| 263 | 263 | "ipc.DialMuxContext local daemonmux carriage (broker_offline_mux.go)", | |
| 264 | 264 | }, | |
| 265 | - | Notes: "Mints one ephemeral authenticated QUIC server, admits exactly one carriage, and bridges it to the single provisioned private Unix daemonmux carriage. It never dials the production daemon socket, never starts a daemon, and never fabricates a daemon incarnation.", | |
| 265 | + | Notes: "Mints one ephemeral authenticated QUIC server, admits exactly one carriage, and bridges it to the single provisioned private Unix daemonmux carriage. It never dials the production daemon socket, never fabricates a daemon incarnation, and starts the daemon behind that carriage only under the propagated --daemon-start if-needed authorization and its own provisioned launch policy.", | |
| 266 | 266 | }, | |
| 267 | 267 | } | |
| 268 | 268 | ||
M internal/app/connectivity_matrix_test.go +97 -16
| @@ -84,6 +84,10 @@ func TestConnectivityMatrixOwnershipRules(t *testing.T) { | |||
| 84 | 84 | switch entry.Owner { | |
| 85 | 85 | case connectivityBrokerOnly: | |
| 86 | 86 | hasDebt := len(entry.DirectDialDebt) > 0 || entry.HostStoreWrite || entry.PersistMutation | |
| 87 | + | if entry.Migrated { | |
| 88 | + | require.False(t, hasDebt, "migrated broker-only %q must declare no remaining migration debt", entry.Kind) | |
| 89 | + | break | |
| 90 | + | } | |
| 87 | 91 | require.True(t, hasDebt, "broker-only %q must declare migration debt (direct dial, host-store write, or persist mutation)", entry.Kind) | |
| 88 | 92 | case connectivityTransportInfra: | |
| 89 | 93 | require.False(t, entry.HostStoreWrite, "infra %q must never own the host store", entry.Kind) | |
| @@ -144,23 +148,48 @@ func TestConnectivityMatrixCurrentDebtPresent(t *testing.T) { | |||
| 144 | 148 | require.Contains(t, body, symbol, "expected debt symbol %q in %q", symbol, file) | |
| 145 | 149 | } | |
| 146 | 150 | ||
| 147 | - | assertContains("type localDaemonDialer struct", "run.go") | |
| 148 | - | assertContains("type dialOnlyLocalDialer struct", "run.go") | |
| 149 | - | assertContains("func (d localDaemonDialer) Dial", "run.go") | |
| 150 | - | assertContains("func (d dialOnlyLocalDialer) Dial", "run.go") | |
| 151 | 151 | assertContains("func ensureDaemonWithLifecycle", "spawn.go") | |
| 152 | - | assertContains("func waitForDaemonOrLifecycle", "spawn.go") | |
| 153 | 152 | assertContains("func realDial", "spawn.go") | |
| 154 | 153 | assertContains("func realSpawn", "spawn.go") | |
| 155 | - | assertContains("func (d remoteHostDeps) hostStore", "remote_hosts.go") | |
| 156 | - | assertContains("func hostAdd", "remote_hosts.go") | |
| 157 | - | assertContains("func hostRm", "remote_hosts.go") | |
| 158 | - | assertContains("func listSessionsWithDialer", "run.go") | |
| 159 | - | assertContains("func sessionExists", "attach_preflight.go") | |
| 160 | - | assertContains("func runOfflineNamedKill", "run.go") | |
| 161 | - | assertContains("persist.LoadReadOnly", "run.go") | |
| 162 | - | assertContains("persist.OpenOrCreate", "run.go") | |
| 163 | - | assertContains("func newClientHostRegistry", "client_hosts.go") | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | // TestConnectivityMatrixListKillStopAreBrokerOnly pins the P7.4f cutover for | |
| 157 | + | // the list/kill/stop rows: each reaches the per-user broker through the private | |
| 158 | + | // connectBroker seam and keeps no removed bypass, so runList and runKill/stop | |
| 159 | + | // can never read durable state or signal a process directly. | |
| 160 | + | func TestConnectivityMatrixListKillStopAreBrokerOnly(t *testing.T) { | |
| 161 | + | t.Parallel() | |
| 162 | + | ||
| 163 | + | sources := loadAppSources(t, false) | |
| 164 | + | runBody, ok := sources["run.go"] | |
| 165 | + | require.True(t, ok, "expected app source file run.go") | |
| 166 | + | require.Contains(t, runBody, "var connectBroker = connectProductionBroker", | |
| 167 | + | "run.go must keep the private connectBroker composition seam") | |
| 168 | + | require.Contains(t, runBody, "func runList", "run.go must keep runList") | |
| 169 | + | require.Contains(t, runBody, "func runKill", "run.go must keep runKill") | |
| 170 | + | require.Contains(t, runBody, "func requestDaemonStop", "run.go must keep requestDaemonStop") | |
| 171 | + | ||
| 172 | + | forbiddenFiles := map[string][]string{ | |
| 173 | + | "run.go": { | |
| 174 | + | "runOfflineNamedKill", "persist.LoadReadOnly", "forceStopDaemonFallback", | |
| 175 | + | "forceStopDaemonFn", "waitForDaemonOrLifecycle", "waitForLifecycleAvailability", | |
| 176 | + | }, | |
| 177 | + | } | |
| 178 | + | for file, forbidden := range forbiddenFiles { | |
| 179 | + | body, ok := sources[file] | |
| 180 | + | require.True(t, ok, "expected app source file %q", file) | |
| 181 | + | for _, symbol := range forbidden { | |
| 182 | + | require.NotContains(t, body, symbol, | |
| 183 | + | "%s must not reintroduce the removed bypass %q", file, symbol) | |
| 184 | + | } | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | // The whole force-stop subsystem is removed, so no production file may | |
| 188 | + | // resurrect an interactive process-signal fallback. | |
| 189 | + | for file, body := range sources { | |
| 190 | + | require.NotContains(t, body, "forceStop", | |
| 191 | + | "the force-stop subsystem must stay removed (%s)", file) | |
| 192 | + | } | |
| 164 | 193 | } | |
| 165 | 194 | ||
| 166 | 195 | // TestConnectivityMatrixSingleHostStoreWriterSet fails when a new | |
| @@ -178,8 +207,60 @@ func TestConnectivityMatrixSingleHostStoreWriterSet(t *testing.T) { | |||
| 178 | 207 | } | |
| 179 | 208 | } | |
| 180 | 209 | sort.Strings(writers) | |
| 181 | - | require.Equal(t, []string{"remote_hosts.go", "run.go"}, writers, | |
| 182 | - | "host-store constructor sites changed; update the matrix debt set in the same change") | |
| 210 | + | require.Empty(t, writers, | |
| 211 | + | "the broker is the sole host-store owner; app must not construct another writer") | |
| 212 | + | } | |
| 213 | + | ||
| 214 | + | // TestConnectivityMatrixReadyProbeIsLocalDialOnly pins the kindBrokerReady row | |
| 215 | + | // and its production source: the hidden readiness probe is local-only, reaches | |
| 216 | + | // no daemon or store, and its production body dials, subscribes, and reads only. | |
| 217 | + | // It must never ensure or spawn a broker, open a logical stream, request a | |
| 218 | + | // reconcile, or mutate membership; each of those would turn a machine probe | |
| 219 | + | // into a client façade that changes the state it reports. | |
| 220 | + | func TestConnectivityMatrixReadyProbeIsLocalDialOnly(t *testing.T) { | |
| 221 | + | t.Parallel() | |
| 222 | + | ||
| 223 | + | owner, ok := connectivityOwnerFor("kindBrokerReady") | |
| 224 | + | require.True(t, ok, "the readiness probe must have a matrix row") | |
| 225 | + | require.Equal(t, connectivityLocalOnly, owner, "the readiness probe is dial-only local infrastructure") | |
| 226 | + | for _, entry := range connectivityMatrix { | |
| 227 | + | if entry.Kind != "kindBrokerReady" { | |
| 228 | + | continue | |
| 229 | + | } | |
| 230 | + | require.Empty(t, entry.DirectDialDebt, "a dial-only probe owes no connectivity migration") | |
| 231 | + | require.False(t, entry.HostStoreWrite, "the readiness probe never owns the host store") | |
| 232 | + | require.False(t, entry.PersistMutation, "the readiness probe never mutates session persistence") | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | sources := loadAppSources(t, false) | |
| 236 | + | body, ok := sources["broker_ready.go"] | |
| 237 | + | require.True(t, ok, "expected app source file broker_ready.go") | |
| 238 | + | ||
| 239 | + | forbidden := []struct { | |
| 240 | + | symbol string | |
| 241 | + | reason string | |
| 242 | + | }{ | |
| 243 | + | {"ensureBrokerReady", "a readiness probe must never ensure a broker"}, | |
| 244 | + | {"ensureDaemonWithLifecycle", "a readiness probe must never start a daemon"}, | |
| 245 | + | {"spawnBrokerLauncher", "a readiness probe must never spawn a broker"}, | |
| 246 | + | {"spawnProductionBrokerLauncher", "a readiness probe must never spawn a broker"}, | |
| 247 | + | {"realDial", "a readiness probe dials the broker endpoint only"}, | |
| 248 | + | {"OpenStream", "a readiness probe opens no logical stream"}, | |
| 249 | + | {"RequestReconcile", "a readiness probe requests no reconcile"}, | |
| 250 | + | {"AddHost", "a readiness probe mutates no membership"}, | |
| 251 | + | {"RemoveHost", "a readiness probe mutates no membership"}, | |
| 252 | + | {"UpdateHostPolicy", "a readiness probe mutates no membership"}, | |
| 253 | + | {"NewFileHostStore", "a readiness probe owns no host store"}, | |
| 254 | + | } | |
| 255 | + | for _, forbidden := range forbidden { | |
| 256 | + | require.NotContains(t, body, forbidden.symbol, "%s (%s)", forbidden.symbol, forbidden.reason) | |
| 257 | + | } | |
| 258 | + | ||
| 259 | + | // Positive pins: the probe is one subscription over one snapshot loop, and | |
| 260 | + | // it classifies terminal failures without retrying them. | |
| 261 | + | for _, required := range []string{"func runBrokerReady", "func observeBrokerReady", "service.Subscribe()", "service.Snapshot()", "terminalReadyReason", "brokerReadyEndpointSecurity"} { | |
| 262 | + | require.Contains(t, body, required, "broker_ready.go must keep %s", required) | |
| 263 | + | } | |
| 183 | 264 | } | |
| 184 | 265 | ||
| 185 | 266 | // commandKindsFromRunGo extracts the kind* const names from run.go so the | |
M internal/app/daemon_integration_test.go +4 -57
| @@ -719,7 +719,9 @@ func TestIntegration_EphemeralNotListedAfterDaemonRestart(t *testing.T) { | |||
| 719 | 719 | awaitText(t, p, sz, "TEMP") | |
| 720 | 720 | require.NoError(t, tr.Close()) | |
| 721 | 721 | ||
| 722 | - | require.NoError(t, runKill(context.Background(), "", false, true)) | |
| 722 | + | // An ephemeral session lives only while its daemon does; the explicit daemon | |
| 723 | + | // stop over the typed control wire ends that daemon. | |
| 724 | + | require.NoError(t, killDaemon(dir)) | |
| 723 | 725 | select { | |
| 724 | 726 | case err := <-served: | |
| 725 | 727 | require.NoError(t, err) | |
| @@ -731,7 +733,7 @@ func TestIntegration_EphemeralNotListedAfterDaemonRestart(t *testing.T) { | |||
| 731 | 733 | sessions := listRemoteSessions(t, dir) | |
| 732 | 734 | require.Empty(t, sessions.Sessions) | |
| 733 | 735 | ||
| 734 | - | require.NoError(t, runKill(context.Background(), "", false, true)) | |
| 736 | + | require.NoError(t, killDaemon(dir)) | |
| 735 | 737 | select { | |
| 736 | 738 | case err := <-served2: | |
| 737 | 739 | require.NoError(t, err) | |
| @@ -1007,61 +1009,6 @@ func TestMultipleClientsOneLifecycleOwner(t *testing.T) { | |||
| 1007 | 1009 | require.Zero(t, spawns.Load()) | |
| 1008 | 1010 | } | |
| 1009 | 1011 | ||
| 1010 | - | func TestListWaitsForLifecycleOwner(t *testing.T) { | |
| 1011 | - | stateRoot, runtimeRoot := t.TempDir(), t.TempDir() | |
| 1012 | - | t.Setenv("XDG_STATE_HOME", stateRoot) | |
| 1013 | - | t.Setenv("XDG_RUNTIME_DIR", runtimeRoot) | |
| 1014 | - | p := newTestPersister(t, filepath.Join(stateRoot, "vev")) | |
| 1015 | - | require.NoError(t, p.Close()) | |
| 1016 | - | owner, err := lifecycle.TryAcquire(ipc.SocketDir()) | |
| 1017 | - | require.NoError(t, err) | |
| 1018 | - | ||
| 1019 | - | done := make(chan error, 1) | |
| 1020 | - | go func() { done <- runList(context.Background(), command{kind: kindList}) }() | |
| 1021 | - | require.Never(t, func() bool { return len(done) != 0 }, 50*time.Millisecond, time.Millisecond) | |
| 1022 | - | require.NoError(t, owner.Release()) | |
| 1023 | - | require.NoError(t, <-done) | |
| 1024 | - | } | |
| 1025 | - | ||
| 1026 | - | func TestOfflineKillWaitsForLifecycleOwner(t *testing.T) { | |
| 1027 | - | stateRoot, runtimeRoot := t.TempDir(), t.TempDir() | |
| 1028 | - | t.Setenv("XDG_STATE_HOME", stateRoot) | |
| 1029 | - | t.Setenv("XDG_RUNTIME_DIR", runtimeRoot) | |
| 1030 | - | p := newTestPersister(t, filepath.Join(stateRoot, "vev")) | |
| 1031 | - | now := time.Now().UnixNano() | |
| 1032 | - | require.NoError(t, p.Save(persist.Record{Name: "named", IncarnationID: domain.IncarnationID{1}, CreatedAt: now, UpdatedAt: now})) | |
| 1033 | - | require.NoError(t, p.Close()) | |
| 1034 | - | owner, err := lifecycle.TryAcquire(ipc.SocketDir()) | |
| 1035 | - | require.NoError(t, err) | |
| 1036 | - | ||
| 1037 | - | done := make(chan error, 1) | |
| 1038 | - | go func() { done <- runKill(context.Background(), "named", false, false) }() | |
| 1039 | - | require.Never(t, func() bool { return len(done) != 0 }, 50*time.Millisecond, time.Millisecond) | |
| 1040 | - | require.NoError(t, owner.Release()) | |
| 1041 | - | require.NoError(t, <-done) | |
| 1042 | - | } | |
| 1043 | - | ||
| 1044 | - | func TestKillDaemonWaitsForOwnershipTransfer(t *testing.T) { | |
| 1045 | - | stateRoot, runtimeRoot := t.TempDir(), t.TempDir() | |
| 1046 | - | t.Setenv("XDG_STATE_HOME", stateRoot) | |
| 1047 | - | t.Setenv("XDG_RUNTIME_DIR", runtimeRoot) | |
| 1048 | - | owner, err := lifecycle.TryAcquire(ipc.SocketDir()) | |
| 1049 | - | require.NoError(t, err) | |
| 1050 | - | _, served := startDaemonInDir(t, ipc.SocketDir()) | |
| 1051 | - | ||
| 1052 | - | done := make(chan error, 1) | |
| 1053 | - | go func() { done <- requestDaemonStop(context.Background()) }() | |
| 1054 | - | select { | |
| 1055 | - | case err := <-served: | |
| 1056 | - | require.NoError(t, err) | |
| 1057 | - | case <-time.After(time.Second): | |
| 1058 | - | t.Fatal("daemon did not stop") | |
| 1059 | - | } | |
| 1060 | - | require.Never(t, func() bool { return len(done) != 0 }, 50*time.Millisecond, time.Millisecond) | |
| 1061 | - | require.NoError(t, owner.Release()) | |
| 1062 | - | require.NoError(t, <-done) | |
| 1063 | - | } | |
| 1064 | - | ||
| 1065 | 1012 | func TestLifecycleOwnershipOutlivesMaintenanceWriter(t *testing.T) { | |
| 1066 | 1013 | runtimeDir := filepath.Join(t.TempDir(), "runtime") | |
| 1067 | 1014 | stateDir := filepath.Join(t.TempDir(), "state") | |
D internal/app/dial_only_test.go +0 -51
| @@ -1,51 +0,0 @@ | |||
| 1 | - | package app | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "context" | |
| 5 | - | "testing" | |
| 6 | - | "time" | |
| 7 | - | ||
| 8 | - | "github.com/stretchr/testify/require" | |
| 9 | - | ) | |
| 10 | - | ||
| 11 | - | // TestDialOnlyLocalDialerNeverStartsDaemon pins the D2 source contract: the | |
| 12 | - | // inventory control dialer performs a bare socket dial with no ensure, spawn, | |
| 13 | - | // or attachment path. Against an isolated directory it must fail fast with a | |
| 14 | - | // dial error instead of implicitly starting a daemon. | |
| 15 | - | func TestDialOnlyLocalDialerNeverStartsDaemon(t *testing.T) { | |
| 16 | - | dir := t.TempDir() | |
| 17 | - | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) | |
| 18 | - | defer cancel() | |
| 19 | - | start := time.Now() | |
| 20 | - | dialer := dialOnlyLocalDialer{dir: dir} | |
| 21 | - | _, err := dialer.Dial(ctx) | |
| 22 | - | if err == nil { | |
| 23 | - | t.Fatal("dial-only control dial against an empty dir must fail") | |
| 24 | - | } | |
| 25 | - | if elapsed := time.Since(start); elapsed > 10*time.Second { | |
| 26 | - | t.Fatalf("dial-only control dial took %v; must not retry or spawn", elapsed) | |
| 27 | - | } | |
| 28 | - | plain := dialOnlyLocalDialer{dir: dir, observer: nil} | |
| 29 | - | if _, err := plain.Dial(context.Background()); err == nil { | |
| 30 | - | t.Fatal("dial-only control dial without observer must also fail fast") | |
| 31 | - | } | |
| 32 | - | } | |
| 33 | - | ||
| 34 | - | // TestHybridMissingLocalSourceNeverStartsDaemon pins the hybrid degradation | |
| 35 | - | // contract: when the dial-only local inventory source is unreachable, the | |
| 36 | - | // composition must surface the dial failure rather than starting a daemon or | |
| 37 | - | // creating a local attachment. The missing source degrades that source only. | |
| 38 | - | func TestHybridMissingLocalSourceNeverStartsDaemon(t *testing.T) { | |
| 39 | - | dir := t.TempDir() | |
| 40 | - | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) | |
| 41 | - | defer cancel() | |
| 42 | - | dialer := dialOnlyLocalDialer{dir: dir} | |
| 43 | - | require.Error(t, func() error { | |
| 44 | - | conn, err := dialer.Dial(ctx) | |
| 45 | - | if err != nil { | |
| 46 | - | return err | |
| 47 | - | } | |
| 48 | - | _ = conn.Close() | |
| 49 | - | return nil | |
| 50 | - | }(), "missing local control source must fail instead of starting a daemon") | |
| 51 | - | } | |
D internal/app/force_stop.go +0 -299
| @@ -1,299 +0,0 @@ | |||
| 1 | - | package app | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "bufio" | |
| 5 | - | "context" | |
| 6 | - | "errors" | |
| 7 | - | "fmt" | |
| 8 | - | "io" | |
| 9 | - | "os" | |
| 10 | - | "path/filepath" | |
| 11 | - | "strings" | |
| 12 | - | "syscall" | |
| 13 | - | "time" | |
| 14 | - | ||
| 15 | - | "github.com/bnema/vev/internal/adapters/ipc" | |
| 16 | - | "github.com/bnema/vev/internal/adapters/lifecycle" | |
| 17 | - | ) | |
| 18 | - | ||
| 19 | - | // Force-stopping is the interactive last resort for a daemon that still owns | |
| 20 | - | // lifecycle.lock but cannot be stopped through the typed kill protocol, for | |
| 21 | - | // example when `go install` replaced the binary while a `/proc/self/exe | |
| 22 | - | // --daemon` process kept running under the name "exe". Ownership is never | |
| 23 | - | // inferred from a process name: the candidate must be a same-user process that | |
| 24 | - | // holds lifecycle.lock open and runs as a vev daemon, and its incarnation is | |
| 25 | - | // re-verified before any escalation so a reused PID is never signalled. | |
| 26 | - | ||
| 27 | - | const ( | |
| 28 | - | // forceStopSIGTERMWait bounds how long SIGTERM has to release lifecycle | |
| 29 | - | // ownership before the fallback escalates exactly once to SIGKILL. | |
| 30 | - | forceStopSIGTERMWait = 5 * time.Second | |
| 31 | - | // forceStopSIGKILLWait bounds how long SIGKILL has to release lifecycle | |
| 32 | - | // ownership. Escalation never repeats. | |
| 33 | - | forceStopSIGKILLWait = 5 * time.Second | |
| 34 | - | // forceStopPollInterval is the ownership-transfer poll cadence. | |
| 35 | - | forceStopPollInterval = 50 * time.Millisecond | |
| 36 | - | ) | |
| 37 | - | ||
| 38 | - | var ( | |
| 39 | - | // errForceStopNotApplicable reports that no force-stop was offered because | |
| 40 | - | // lifecycle ownership was already free or no verified daemon process holds | |
| 41 | - | // it. Callers keep their original diagnosis in that case. | |
| 42 | - | errForceStopNotApplicable = errors.New("no verified daemon process holds lifecycle ownership") | |
| 43 | - | // errForceStopDeclined reports an explicit or default refusal at the prompt. | |
| 44 | - | errForceStopDeclined = errors.New("vev: force stop declined; daemon left running") | |
| 45 | - | // errForceStopTimeout reports that the daemon never released lifecycle | |
| 46 | - | // ownership within the bounded escalation policy. | |
| 47 | - | errForceStopTimeout = errors.New("daemon did not release lifecycle ownership") | |
| 48 | - | ) | |
| 49 | - | ||
| 50 | - | // daemonProcess is a verified, same-user process candidate. | |
| 51 | - | type daemonProcess struct { | |
| 52 | - | PID int | |
| 53 | - | Command string // display only; never used to infer ownership | |
| 54 | - | // Start is an opaque OS-provided process start marker. It distinguishes | |
| 55 | - | // incarnations so a reused PID is never signalled. | |
| 56 | - | Start string | |
| 57 | - | } | |
| 58 | - | ||
| 59 | - | // daemonProcessFinder locates the process holding lifecycle ownership. It must | |
| 60 | - | // prove ownership from the process's open file descriptors, not from its name. | |
| 61 | - | type daemonProcessFinder interface { | |
| 62 | - | // Find returns the same-user process that holds lifecycle.lock open and | |
| 63 | - | // runs as a vev daemon. ok is false when no such process exists. | |
| 64 | - | Find(ctx context.Context, runtimeDir string) (daemonProcess, bool, error) | |
| 65 | - | } | |
| 66 | - | ||
| 67 | - | // hasDaemonArgument reports whether args contain the exact daemon flag. It | |
| 68 | - | // complements, and never replaces, the verified lock-holder proof. | |
| 69 | - | func hasDaemonArgument(args []string) bool { | |
| 70 | - | for _, arg := range args { | |
| 71 | - | if arg == "--daemon" { | |
| 72 | - | return true | |
| 73 | - | } | |
| 74 | - | } | |
| 75 | - | return false | |
| 76 | - | } | |
| 77 | - | ||
| 78 | - | // forceStopHooks carries every side effect so policy tests never signal a real | |
| 79 | - | // process or read the real terminal. | |
| 80 | - | type forceStopHooks struct { | |
| 81 | - | probe lifecycleProbe | |
| 82 | - | finder daemonProcessFinder | |
| 83 | - | signal func(pid int, sig syscall.Signal) error | |
| 84 | - | now func() time.Time | |
| 85 | - | sleep func(ctx context.Context, d time.Duration) error | |
| 86 | - | stdin io.Reader | |
| 87 | - | stdout io.Writer | |
| 88 | - | } | |
| 89 | - | ||
| 90 | - | func defaultForceStopHooks() forceStopHooks { | |
| 91 | - | return forceStopHooks{ | |
| 92 | - | probe: daemonLifecycleProbe, | |
| 93 | - | finder: platformDaemonProcessFinder{}, | |
| 94 | - | signal: syscall.Kill, | |
| 95 | - | now: time.Now, | |
| 96 | - | sleep: forceStopSleep, | |
| 97 | - | stdin: os.Stdin, | |
| 98 | - | stdout: os.Stdout, | |
| 99 | - | } | |
| 100 | - | } | |
| 101 | - | ||
| 102 | - | func forceStopSleep(ctx context.Context, d time.Duration) error { | |
| 103 | - | timer := time.NewTimer(d) | |
| 104 | - | defer timer.Stop() | |
| 105 | - | select { | |
| 106 | - | case <-ctx.Done(): | |
| 107 | - | return ctx.Err() | |
| 108 | - | case <-timer.C: | |
| 109 | - | return nil | |
| 110 | - | } | |
| 111 | - | } | |
| 112 | - | ||
| 113 | - | // forceStopDaemon returns nil once the daemon was stopped through the | |
| 114 | - | // interactive fallback and its stale runtime artifacts were removed. It returns | |
| 115 | - | // errForceStopNotApplicable without any side effect when the fallback does not | |
| 116 | - | // apply, and errForceStopDeclined when the operator refused. | |
| 117 | - | func forceStopDaemon(ctx context.Context, runtimeDir string, hooks forceStopHooks) (retErr error) { | |
| 118 | - | if err := ctx.Err(); err != nil { | |
| 119 | - | return err | |
| 120 | - | } | |
| 121 | - | // Ownership must already be busy: a free lock means there is no daemon to | |
| 122 | - | // force-stop and the caller's normal path applies. | |
| 123 | - | owner, err := hooks.probe.TryAcquire(runtimeDir) | |
| 124 | - | if err == nil { | |
| 125 | - | return errors.Join(errForceStopNotApplicable, owner.Release()) | |
| 126 | - | } | |
| 127 | - | if !errors.Is(err, lifecycle.ErrBusy) { | |
| 128 | - | return fmt.Errorf("vev: probing daemon ownership: %w", err) | |
| 129 | - | } | |
| 130 | - | ||
| 131 | - | candidate, ok, err := hooks.finder.Find(ctx, runtimeDir) | |
| 132 | - | if err != nil { | |
| 133 | - | return fmt.Errorf("vev: identifying daemon process: %w", err) | |
| 134 | - | } | |
| 135 | - | if !ok { | |
| 136 | - | return errForceStopNotApplicable | |
| 137 | - | } | |
| 138 | - | ||
| 139 | - | confirmed, err := hooks.confirm(fmt.Sprintf( | |
| 140 | - | "vev: daemon process %d (%s) still owns %s after the typed kill request failed.\n"+ | |
| 141 | - | "vev: it is unreachable through the typed control endpoint. Send SIGTERM to process %d?", | |
| 142 | - | candidate.PID, candidate.Command, lifecycle.Path(runtimeDir), candidate.PID)) | |
| 143 | - | if err != nil { | |
| 144 | - | return err | |
| 145 | - | } | |
| 146 | - | if !confirmed { | |
| 147 | - | return errForceStopDeclined | |
| 148 | - | } | |
| 149 | - | ||
| 150 | - | // The prompt is interactive and may stay open arbitrarily long, so the | |
| 151 | - | // confirmed incarnation is re-verified before SIGTERM exactly as it is | |
| 152 | - | // before SIGKILL: a PID reused while the operator was answering is never | |
| 153 | - | // signalled. If the verified process vanished or changed, ownership may | |
| 154 | - | // already be free because the daemon exited on its own; then only stale | |
| 155 | - | // runtime artifacts are removed, without signalling anything. | |
| 156 | - | current, found, findErr := hooks.finder.Find(ctx, runtimeDir) | |
| 157 | - | if findErr != nil { | |
| 158 | - | return fmt.Errorf("vev: re-identifying daemon process: %w", findErr) | |
| 159 | - | } | |
| 160 | - | if !found || current.PID != candidate.PID || current.Start != candidate.Start { | |
| 161 | - | owner, acquireErr := hooks.probe.TryAcquire(runtimeDir) | |
| 162 | - | if acquireErr != nil { | |
| 163 | - | if !errors.Is(acquireErr, lifecycle.ErrBusy) { | |
| 164 | - | return fmt.Errorf("vev: probing daemon ownership: %w", acquireErr) | |
| 165 | - | } | |
| 166 | - | return fmt.Errorf("vev: %w (process identity changed before SIGTERM)", errForceStopTimeout) | |
| 167 | - | } | |
| 168 | - | defer func() { retErr = errors.Join(retErr, owner.Release()) }() | |
| 169 | - | if err := removeStaleRuntimeArtifacts(runtimeDir, hooks); err != nil { | |
| 170 | - | return err | |
| 171 | - | } | |
| 172 | - | _, err = fmt.Fprintf(hooks.stdout, "vev: daemon already exited; removed stale runtime artifacts\n") | |
| 173 | - | return err | |
| 174 | - | } | |
| 175 | - | ||
| 176 | - | if err := hooks.signal(candidate.PID, syscall.SIGTERM); err != nil && !errors.Is(err, syscall.ESRCH) { | |
| 177 | - | return fmt.Errorf("vev: sending SIGTERM to process %d: %w", candidate.PID, err) | |
| 178 | - | } | |
| 179 | - | ||
| 180 | - | owner, err = forceStopAwaitOwnership(ctx, runtimeDir, forceStopSIGTERMWait, hooks) | |
| 181 | - | if err != nil { | |
| 182 | - | if !errors.Is(err, errForceStopTimeout) { | |
| 183 | - | return err | |
| 184 | - | } | |
| 185 | - | // Escalate exactly once, and only for the exact incarnation that was | |
| 186 | - | // verified. A PID reused by another process is never signalled. | |
| 187 | - | current, found, findErr := hooks.finder.Find(ctx, runtimeDir) | |
| 188 | - | if findErr != nil { | |
| 189 | - | return fmt.Errorf("vev: re-identifying daemon process: %w", findErr) | |
| 190 | - | } | |
| 191 | - | if !found || current.PID != candidate.PID || current.Start != candidate.Start { | |
| 192 | - | return fmt.Errorf("vev: %w (process identity changed before SIGKILL)", errForceStopTimeout) | |
| 193 | - | } | |
| 194 | - | if err := hooks.signal(candidate.PID, syscall.SIGKILL); err != nil && !errors.Is(err, syscall.ESRCH) { | |
| 195 | - | return fmt.Errorf("vev: sending SIGKILL to process %d: %w", candidate.PID, err) | |
| 196 | - | } | |
| 197 | - | owner, err = forceStopAwaitOwnership(ctx, runtimeDir, forceStopSIGKILLWait, hooks) | |
| 198 | - | if err != nil { | |
| 199 | - | return fmt.Errorf("vev: %w after SIGKILL", err) | |
| 200 | - | } | |
| 201 | - | } | |
| 202 | - | // Holding lifecycle ownership proves no daemon is starting or listening, so | |
| 203 | - | // removing runtime artifacts cannot race a live daemon. | |
| 204 | - | defer func() { retErr = errors.Join(retErr, owner.Release()) }() | |
| 205 | - | if err := removeStaleRuntimeArtifacts(runtimeDir, hooks); err != nil { | |
| 206 | - | return err | |
| 207 | - | } | |
| 208 | - | _, err = fmt.Fprintf(hooks.stdout, "vev: stopped daemon process %d and removed stale runtime artifacts\n", candidate.PID) | |
| 209 | - | return err | |
| 210 | - | } | |
| 211 | - | ||
| 212 | - | // forceStopAwaitOwnership polls until lifecycle ownership is free, then returns | |
| 213 | - | // it so the caller can keep it through cleanup. | |
| 214 | - | func forceStopAwaitOwnership(ctx context.Context, runtimeDir string, wait time.Duration, hooks forceStopHooks) (lifecycleOwnership, error) { | |
| 215 | - | deadline := hooks.now().Add(wait) | |
| 216 | - | for { | |
| 217 | - | if err := ctx.Err(); err != nil { | |
| 218 | - | return nil, err | |
| 219 | - | } | |
| 220 | - | owner, err := hooks.probe.TryAcquire(runtimeDir) | |
| 221 | - | if err == nil { | |
| 222 | - | return owner, nil | |
| 223 | - | } | |
| 224 | - | if !errors.Is(err, lifecycle.ErrBusy) { | |
| 225 | - | return nil, err | |
| 226 | - | } | |
| 227 | - | if !hooks.now().Before(deadline) { | |
| 228 | - | return nil, errForceStopTimeout | |
| 229 | - | } | |
| 230 | - | if err := hooks.sleep(ctx, forceStopPollInterval); err != nil { | |
| 231 | - | return nil, err | |
| 232 | - | } | |
| 233 | - | } | |
| 234 | - | } | |
| 235 | - | ||
| 236 | - | // removeStaleRuntimeArtifacts deletes only the runtime socket and an abandoned | |
| 237 | - | // spawn lock. It never touches durable state: sessions.kv, snapshots, | |
| 238 | - | // configuration, and logs live under the state directory and are not candidates | |
| 239 | - | // here. | |
| 240 | - | func removeStaleRuntimeArtifacts(runtimeDir string, hooks forceStopHooks) error { | |
| 241 | - | var errs []error | |
| 242 | - | if err := os.Remove(ipc.SocketPath(runtimeDir)); err != nil && !errors.Is(err, os.ErrNotExist) { | |
| 243 | - | errs = append(errs, fmt.Errorf("vev: removing stale daemon socket: %w", err)) | |
| 244 | - | } | |
| 245 | - | spawnPath := filepath.Join(runtimeDir, spawnLockName) | |
| 246 | - | info, err := os.Stat(spawnPath) | |
| 247 | - | switch { | |
| 248 | - | case err == nil: | |
| 249 | - | // A fresh spawn lock may belong to a concurrent client that is waiting | |
| 250 | - | // for a daemon; only abandoned locks are removed. | |
| 251 | - | if hooks.now().Sub(info.ModTime()) > staleLockAge { | |
| 252 | - | if err := os.RemoveAll(spawnPath); err != nil { | |
| 253 | - | errs = append(errs, fmt.Errorf("vev: removing stale spawn lock: %w", err)) | |
| 254 | - | } | |
| 255 | - | } | |
| 256 | - | case errors.Is(err, os.ErrNotExist): | |
| 257 | - | default: | |
| 258 | - | errs = append(errs, fmt.Errorf("vev: inspecting spawn lock: %w", err)) | |
| 259 | - | } | |
| 260 | - | return errors.Join(errs...) | |
| 261 | - | } | |
| 262 | - | ||
| 263 | - | // confirm prints a y/N prompt and returns true only for an explicit yes. Empty | |
| 264 | - | // input, EOF, and every other reply default to refusal. | |
| 265 | - | func (h forceStopHooks) confirm(question string) (bool, error) { | |
| 266 | - | if _, err := fmt.Fprintf(h.stdout, "%s [y/N] ", question); err != nil { | |
| 267 | - | return false, fmt.Errorf("vev: writing force-stop prompt: %w", err) | |
| 268 | - | } | |
| 269 | - | line, err := bufio.NewReader(h.stdin).ReadString('\n') | |
| 270 | - | if err != nil && !errors.Is(err, io.EOF) { | |
| 271 | - | return false, fmt.Errorf("vev: reading force-stop confirmation: %w", err) | |
| 272 | - | } | |
| 273 | - | switch strings.ToLower(strings.TrimSpace(line)) { | |
| 274 | - | case "y", "yes": | |
| 275 | - | return true, nil | |
| 276 | - | default: | |
| 277 | - | return false, nil | |
| 278 | - | } | |
| 279 | - | } | |
| 280 | - | ||
| 281 | - | // forceStopDaemonFn is the interactive last-resort hook used by runKill. Tests | |
| 282 | - | // replace it to exercise the fallback wiring without a real daemon process. | |
| 283 | - | var forceStopDaemonFn = func(ctx context.Context) error { | |
| 284 | - | return forceStopDaemon(ctx, ipc.SocketDir(), defaultForceStopHooks()) | |
| 285 | - | } | |
| 286 | - | ||
| 287 | - | // forceStopDaemonFallback offers the interactive force-stop when the daemon | |
| 288 | - | // cannot be reached through the typed protocol. Once a request may have been | |
| 289 | - | // delivered, the caller preserves its explicit or outcome-unknown result rather | |
| 290 | - | // than bypassing the protocol with a direct process signal. When no verified | |
| 291 | - | // daemon process holds lifecycle ownership, the original failure is returned | |
| 292 | - | // unchanged so the caller keeps its own diagnosis. | |
| 293 | - | func forceStopDaemonFallback(ctx context.Context, cause error) error { | |
| 294 | - | err := forceStopDaemonFn(ctx) | |
| 295 | - | if errors.Is(err, errForceStopNotApplicable) { | |
| 296 | - | return cause | |
| 297 | - | } | |
| 298 | - | return err | |
| 299 | - | } | |
D internal/app/force_stop_darwin.go +0 -108
| @@ -1,108 +0,0 @@ | |||
| 1 | - | //go:build darwin | |
| 2 | - | ||
| 3 | - | package app | |
| 4 | - | ||
| 5 | - | import ( | |
| 6 | - | "context" | |
| 7 | - | "errors" | |
| 8 | - | "fmt" | |
| 9 | - | "os" | |
| 10 | - | "os/exec" | |
| 11 | - | "strconv" | |
| 12 | - | "strings" | |
| 13 | - | "time" | |
| 14 | - | ||
| 15 | - | "github.com/bnema/vev/internal/adapters/lifecycle" | |
| 16 | - | ) | |
| 17 | - | ||
| 18 | - | // platformDaemonProcessFinder verifies ownership on macOS with lsof: the | |
| 19 | - | // candidate must be a same-user process running a vev daemon with | |
| 20 | - | // lifecycle.lock open. macOS has no /proc, and lsof ships with the base system; | |
| 21 | - | // when it is unavailable the fallback refuses rather than guessing from a | |
| 22 | - | // process name. An empty (legacy) lock file is irrelevant because ownership is | |
| 23 | - | // proven by the open descriptor, not by file contents. | |
| 24 | - | type platformDaemonProcessFinder struct{} | |
| 25 | - | ||
| 26 | - | // forceStopCommandTimeout bounds the lsof and ps subprocesses so a stuck | |
| 27 | - | // process table cannot hang an interactive kill. | |
| 28 | - | const forceStopCommandTimeout = 5 * time.Second | |
| 29 | - | ||
| 30 | - | func (platformDaemonProcessFinder) Find(ctx context.Context, runtimeDir string) (daemonProcess, bool, error) { | |
| 31 | - | lockPath := lifecycle.Path(runtimeDir) | |
| 32 | - | if _, err := os.Stat(lockPath); err != nil { | |
| 33 | - | if errors.Is(err, os.ErrNotExist) { | |
| 34 | - | return daemonProcess{}, false, nil | |
| 35 | - | } | |
| 36 | - | return daemonProcess{}, false, fmt.Errorf("inspecting lifecycle lock: %w", err) | |
| 37 | - | } | |
| 38 | - | lsof, err := lsofPath() | |
| 39 | - | if err != nil { | |
| 40 | - | return daemonProcess{}, false, err | |
| 41 | - | } | |
| 42 | - | lsofCtx, cancel := context.WithTimeout(ctx, forceStopCommandTimeout) | |
| 43 | - | defer cancel() | |
| 44 | - | out, err := exec.CommandContext(lsofCtx, lsof, "-a", "-u", strconv.Itoa(os.Geteuid()), "-t", "--", lockPath).Output() | |
| 45 | - | if err != nil { | |
| 46 | - | var exitErr *exec.ExitError | |
| 47 | - | if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 { | |
| 48 | - | // lsof exits 1 when no process matches. | |
| 49 | - | return daemonProcess{}, false, nil | |
| 50 | - | } | |
| 51 | - | return daemonProcess{}, false, fmt.Errorf("listing holders of %s: %w", lockPath, err) | |
| 52 | - | } | |
| 53 | - | for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { | |
| 54 | - | pid, convErr := strconv.Atoi(strings.TrimSpace(line)) | |
| 55 | - | if convErr != nil || pid <= 0 { | |
| 56 | - | continue | |
| 57 | - | } | |
| 58 | - | process, ok, infoErr := darwinProcessInfo(ctx, pid) | |
| 59 | - | if infoErr != nil { | |
| 60 | - | return daemonProcess{}, false, infoErr | |
| 61 | - | } | |
| 62 | - | if ok { | |
| 63 | - | return process, true, nil | |
| 64 | - | } | |
| 65 | - | } | |
| 66 | - | return daemonProcess{}, false, nil | |
| 67 | - | } | |
| 68 | - | ||
| 69 | - | func lsofPath() (string, error) { | |
| 70 | - | if path, err := exec.LookPath("lsof"); err == nil { | |
| 71 | - | return path, nil | |
| 72 | - | } | |
| 73 | - | // GUI applications often omit /usr/sbin from PATH. | |
| 74 | - | const systemLsof = "/usr/sbin/lsof" | |
| 75 | - | if _, err := os.Stat(systemLsof); err == nil { | |
| 76 | - | return systemLsof, nil | |
| 77 | - | } | |
| 78 | - | return "", errors.New("vev: locating lsof: not found; cannot verify daemon ownership") | |
| 79 | - | } | |
| 80 | - | ||
| 81 | - | // darwinProcessInfo returns the verified daemon identity for pid, or ok=false | |
| 82 | - | // when the process vanished or is not a vev daemon. | |
| 83 | - | func darwinProcessInfo(ctx context.Context, pid int) (daemonProcess, bool, error) { | |
| 84 | - | cmdCtx, cancel := context.WithTimeout(ctx, forceStopCommandTimeout) | |
| 85 | - | defer cancel() | |
| 86 | - | out, err := exec.CommandContext(cmdCtx, "ps", "-p", strconv.Itoa(pid), "-o", "uid=", "-o", "lstart=", "-o", "command=").Output() | |
| 87 | - | if err != nil { | |
| 88 | - | var exitErr *exec.ExitError | |
| 89 | - | if errors.As(err, &exitErr) { | |
| 90 | - | return daemonProcess{}, false, nil // process vanished between lsof and ps | |
| 91 | - | } | |
| 92 | - | return daemonProcess{}, false, fmt.Errorf("inspecting process %d: %w", pid, err) | |
| 93 | - | } | |
| 94 | - | // uid and the five lstart fields precede the command. | |
| 95 | - | fields := strings.Fields(string(out)) | |
| 96 | - | if len(fields) < 7 { | |
| 97 | - | return daemonProcess{}, false, nil | |
| 98 | - | } | |
| 99 | - | uid, convErr := strconv.Atoi(fields[0]) | |
| 100 | - | if convErr != nil || uid != os.Geteuid() { | |
| 101 | - | return daemonProcess{}, false, nil | |
| 102 | - | } | |
| 103 | - | command := strings.Join(fields[6:], " ") | |
| 104 | - | if !hasDaemonArgument(strings.Fields(command)) { | |
| 105 | - | return daemonProcess{}, false, nil | |
| 106 | - | } | |
| 107 | - | return daemonProcess{PID: pid, Command: command, Start: strings.Join(fields[1:6], " ")}, true, nil | |
| 108 | - | } | |
D internal/app/force_stop_darwin_test.go +0 -90
| @@ -1,90 +0,0 @@ | |||
| 1 | - | //go:build darwin | |
| 2 | - | ||
| 3 | - | package app | |
| 4 | - | ||
| 5 | - | import ( | |
| 6 | - | "context" | |
| 7 | - | "os" | |
| 8 | - | "os/exec" | |
| 9 | - | "path/filepath" | |
| 10 | - | "testing" | |
| 11 | - | "time" | |
| 12 | - | ||
| 13 | - | "github.com/bnema/vev/internal/adapters/lifecycle" | |
| 14 | - | "github.com/stretchr/testify/require" | |
| 15 | - | ) | |
| 16 | - | ||
| 17 | - | // TestDarwinForceStopFinderVerifiesDaemonHoldingEmptyLegacyLock proves | |
| 18 | - | // ownership through lsof: the helper runs as `--daemon`, holds a legacy (empty) | |
| 19 | - | // lifecycle.lock, and is identified without trusting the process name. | |
| 20 | - | func TestDarwinForceStopFinderVerifiesDaemonHoldingEmptyLegacyLock(t *testing.T) { | |
| 21 | - | if _, err := lsofPath(); err != nil { | |
| 22 | - | t.Skip("lsof unavailable; the fallback refuses rather than guessing") | |
| 23 | - | } | |
| 24 | - | runtimeDir := filepath.Join(t.TempDir(), "runtime") | |
| 25 | - | require.NoError(t, os.MkdirAll(runtimeDir, 0o700)) | |
| 26 | - | childFile := filepath.Join(t.TempDir(), "daemon.pid") | |
| 27 | - | t.Setenv(spawnTestChildFileEnv, childFile) | |
| 28 | - | t.Setenv(spawnTestLockDirEnv, runtimeDir) | |
| 29 | - | ||
| 30 | - | cmd := exec.Command(os.Args[0], "--daemon") | |
| 31 | - | cmd.Env = os.Environ() | |
| 32 | - | require.NoError(t, cmd.Start()) | |
| 33 | - | t.Cleanup(func() { | |
| 34 | - | _ = cmd.Process.Kill() | |
| 35 | - | _, _ = cmd.Process.Wait() | |
| 36 | - | }) | |
| 37 | - | daemonPID := waitForProcessRecord(t, childFile) | |
| 38 | - | ||
| 39 | - | finder := platformDaemonProcessFinder{} | |
| 40 | - | var process daemonProcess | |
| 41 | - | var ok bool | |
| 42 | - | require.Eventually(t, func() bool { | |
| 43 | - | var err error | |
| 44 | - | process, ok, err = finder.Find(context.Background(), runtimeDir) | |
| 45 | - | require.NoError(t, err) | |
| 46 | - | return ok | |
| 47 | - | }, 10*time.Second, 50*time.Millisecond, "finder did not identify the lock holder") | |
| 48 | - | ||
| 49 | - | require.Equal(t, daemonPID, process.PID) | |
| 50 | - | require.Contains(t, process.Command, "--daemon") | |
| 51 | - | require.NotEmpty(t, process.Start) | |
| 52 | - | ||
| 53 | - | lockInfo, err := os.Stat(lifecycle.Path(runtimeDir)) | |
| 54 | - | require.NoError(t, err) | |
| 55 | - | require.EqualValues(t, 0, lockInfo.Size(), "the legacy lifecycle lock stays empty") | |
| 56 | - | } | |
| 57 | - | ||
| 58 | - | // TestDarwinForceStopFinderIgnoresNonDaemonLockHolder proves a same-user process | |
| 59 | - | // that merely holds lifecycle.lock is not a candidate: this test binary owns the | |
| 60 | - | // lock but was not started as a vev daemon. | |
| 61 | - | func TestDarwinForceStopFinderIgnoresNonDaemonLockHolder(t *testing.T) { | |
| 62 | - | if _, err := lsofPath(); err != nil { | |
| 63 | - | t.Skip("lsof unavailable; the fallback refuses rather than guessing") | |
| 64 | - | } | |
| 65 | - | runtimeDir := filepath.Join(t.TempDir(), "runtime") | |
| 66 | - | owner, err := lifecycle.TryAcquire(runtimeDir) | |
| 67 | - | require.NoError(t, err) | |
| 68 | - | defer func() { require.NoError(t, owner.Release()) }() | |
| 69 | - | ||
| 70 | - | _, ok, err := platformDaemonProcessFinder{}.Find(context.Background(), runtimeDir) | |
| 71 | - | ||
| 72 | - | require.NoError(t, err) | |
| 73 | - | require.False(t, ok, "ownership must not be inferred from a process name alone") | |
| 74 | - | } | |
| 75 | - | ||
| 76 | - | func TestDarwinForceStopFinderIgnoresUnheldLockFile(t *testing.T) { | |
| 77 | - | runtimeDir := filepath.Join(t.TempDir(), "runtime") | |
| 78 | - | require.NoError(t, os.MkdirAll(runtimeDir, 0o700)) | |
| 79 | - | require.NoError(t, os.WriteFile(lifecycle.Path(runtimeDir), nil, 0o600)) | |
| 80 | - | ||
| 81 | - | _, ok, err := platformDaemonProcessFinder{}.Find(context.Background(), runtimeDir) | |
| 82 | - | require.NoError(t, err) | |
| 83 | - | require.False(t, ok) | |
| 84 | - | } | |
| 85 | - | ||
| 86 | - | func TestDarwinForceStopFinderIgnoresMissingLockFile(t *testing.T) { | |
| 87 | - | _, ok, err := platformDaemonProcessFinder{}.Find(context.Background(), filepath.Join(t.TempDir(), "runtime")) | |
| 88 | - | require.NoError(t, err) | |
| 89 | - | require.False(t, ok) | |
| 90 | - | } | |
D internal/app/force_stop_linux.go +0 -134
| @@ -1,134 +0,0 @@ | |||
| 1 | - | //go:build linux | |
| 2 | - | ||
| 3 | - | package app | |
| 4 | - | ||
| 5 | - | import ( | |
| 6 | - | "context" | |
| 7 | - | "errors" | |
| 8 | - | "fmt" | |
| 9 | - | "os" | |
| 10 | - | "path/filepath" | |
| 11 | - | "strconv" | |
| 12 | - | "strings" | |
| 13 | - | "syscall" | |
| 14 | - | ||
| 15 | - | "github.com/bnema/vev/internal/adapters/lifecycle" | |
| 16 | - | ) | |
| 17 | - | ||
| 18 | - | // platformDaemonProcessFinder verifies ownership through /proc: the candidate | |
| 19 | - | // must be a same-user process whose open file descriptor table references | |
| 20 | - | // lifecycle.lock. An empty (legacy) lock file is irrelevant because ownership | |
| 21 | - | // is proven by the held descriptor, not by file contents. | |
| 22 | - | type platformDaemonProcessFinder struct{} | |
| 23 | - | ||
| 24 | - | func (platformDaemonProcessFinder) Find(ctx context.Context, runtimeDir string) (daemonProcess, bool, error) { | |
| 25 | - | lockInfo, err := os.Stat(lifecycle.Path(runtimeDir)) | |
| 26 | - | if err != nil { | |
| 27 | - | if errors.Is(err, os.ErrNotExist) { | |
| 28 | - | return daemonProcess{}, false, nil | |
| 29 | - | } | |
| 30 | - | return daemonProcess{}, false, fmt.Errorf("inspecting lifecycle lock: %w", err) | |
| 31 | - | } | |
| 32 | - | entries, err := os.ReadDir("/proc") | |
| 33 | - | if err != nil { | |
| 34 | - | return daemonProcess{}, false, fmt.Errorf("scanning process table: %w", err) | |
| 35 | - | } | |
| 36 | - | euid := os.Geteuid() | |
| 37 | - | for _, entry := range entries { | |
| 38 | - | if err := ctx.Err(); err != nil { | |
| 39 | - | return daemonProcess{}, false, err | |
| 40 | - | } | |
| 41 | - | pid, convErr := strconv.Atoi(entry.Name()) | |
| 42 | - | if convErr != nil || pid <= 0 { | |
| 43 | - | continue | |
| 44 | - | } | |
| 45 | - | uid, err := procUID(pid) | |
| 46 | - | if err != nil || uid != euid { | |
| 47 | - | continue | |
| 48 | - | } | |
| 49 | - | holds, err := procHoldsFile(pid, lockInfo) | |
| 50 | - | if err != nil || !holds { | |
| 51 | - | continue | |
| 52 | - | } | |
| 53 | - | args, err := procArgs(pid) | |
| 54 | - | if err != nil || !hasDaemonArgument(args) { | |
| 55 | - | continue | |
| 56 | - | } | |
| 57 | - | start, err := procStartMarker(pid) | |
| 58 | - | if err != nil { | |
| 59 | - | continue | |
| 60 | - | } | |
| 61 | - | return daemonProcess{PID: pid, Command: strings.Join(args, " "), Start: start}, true, nil | |
| 62 | - | } | |
| 63 | - | return daemonProcess{}, false, nil | |
| 64 | - | } | |
| 65 | - | ||
| 66 | - | func procDir(pid int) string { | |
| 67 | - | return filepath.Join("/proc", strconv.Itoa(pid)) | |
| 68 | - | } | |
| 69 | - | ||
| 70 | - | // procUID reports the owning user of a process directory. Under hidepid it may | |
| 71 | - | // fail, in which case the candidate is skipped. | |
| 72 | - | func procUID(pid int) (int, error) { | |
| 73 | - | info, err := os.Stat(procDir(pid)) | |
| 74 | - | if err != nil { | |
| 75 | - | return -1, err | |
| 76 | - | } | |
| 77 | - | stat, ok := info.Sys().(*syscall.Stat_t) | |
| 78 | - | if !ok { | |
| 79 | - | return -1, errors.New("unexpected stat type for process directory") | |
| 80 | - | } | |
| 81 | - | return int(stat.Uid), nil | |
| 82 | - | } | |
| 83 | - | ||
| 84 | - | // procHoldsFile reports whether pid has an open descriptor referencing the same | |
| 85 | - | // file as lockInfo, which is the ownership proof for lifecycle.lock. | |
| 86 | - | func procHoldsFile(pid int, lockInfo os.FileInfo) (bool, error) { | |
| 87 | - | fds, err := os.ReadDir(filepath.Join(procDir(pid), "fd")) | |
| 88 | - | if err != nil { | |
| 89 | - | return false, err | |
| 90 | - | } | |
| 91 | - | for _, fd := range fds { | |
| 92 | - | info, err := os.Stat(filepath.Join(procDir(pid), "fd", fd.Name())) | |
| 93 | - | if err != nil { | |
| 94 | - | continue | |
| 95 | - | } | |
| 96 | - | if os.SameFile(info, lockInfo) { | |
| 97 | - | return true, nil | |
| 98 | - | } | |
| 99 | - | } | |
| 100 | - | return false, nil | |
| 101 | - | } | |
| 102 | - | ||
| 103 | - | func procArgs(pid int) ([]string, error) { | |
| 104 | - | raw, err := os.ReadFile(filepath.Join(procDir(pid), "cmdline")) | |
| 105 | - | if err != nil { | |
| 106 | - | return nil, err | |
| 107 | - | } | |
| 108 | - | args := strings.Split(string(raw), "\x00") | |
| 109 | - | if len(args) > 0 && args[len(args)-1] == "" { | |
| 110 | - | args = args[:len(args)-1] | |
| 111 | - | } | |
| 112 | - | return args, nil | |
| 113 | - | } | |
| 114 | - | ||
| 115 | - | // procStartMarker returns the kernel start time of pid. It changes when a PID is | |
| 116 | - | // reused by another process, whatever the new process is called. | |
| 117 | - | func procStartMarker(pid int) (string, error) { | |
| 118 | - | raw, err := os.ReadFile(filepath.Join(procDir(pid), "stat")) | |
| 119 | - | if err != nil { | |
| 120 | - | return "", err | |
| 121 | - | } | |
| 122 | - | end := strings.LastIndexByte(string(raw), ')') | |
| 123 | - | if end < 0 { | |
| 124 | - | return "", errors.New("malformed process stat") | |
| 125 | - | } | |
| 126 | - | fields := strings.Fields(string(raw[end+1:])) | |
| 127 | - | // After the command name, field 22 of /proc/PID/stat (starttime) is at | |
| 128 | - | // index 22-3 = 19 because the first listed field is the state. | |
| 129 | - | const startTimeIndex = 19 | |
| 130 | - | if len(fields) <= startTimeIndex { | |
| 131 | - | return "", errors.New("malformed process stat") | |
| 132 | - | } | |
| 133 | - | return fields[startTimeIndex], nil | |
| 134 | - | } | |
D internal/app/force_stop_linux_test.go +0 -103
| @@ -1,103 +0,0 @@ | |||
| 1 | - | //go:build linux | |
| 2 | - | ||
| 3 | - | package app | |
| 4 | - | ||
| 5 | - | import ( | |
| 6 | - | "context" | |
| 7 | - | "os" | |
| 8 | - | "os/exec" | |
| 9 | - | "path/filepath" | |
| 10 | - | "testing" | |
| 11 | - | "time" | |
| 12 | - | ||
| 13 | - | "github.com/bnema/vev/internal/adapters/lifecycle" | |
| 14 | - | "github.com/stretchr/testify/require" | |
| 15 | - | ) | |
| 16 | - | ||
| 17 | - | // TestLinuxForceStopFinderVerifiesDaemonHoldingEmptyLegacyLock proves ownership | |
| 18 | - | // through a real descriptor: the helper runs as `--daemon`, holds a legacy | |
| 19 | - | // (empty) lifecycle.lock, and is identified without trusting the process name. | |
| 20 | - | func TestLinuxForceStopFinderVerifiesDaemonHoldingEmptyLegacyLock(t *testing.T) { | |
| 21 | - | runtimeDir := filepath.Join(t.TempDir(), "runtime") | |
| 22 | - | require.NoError(t, os.MkdirAll(runtimeDir, 0o700)) | |
| 23 | - | childFile := filepath.Join(t.TempDir(), "daemon.pid") | |
| 24 | - | t.Setenv(spawnTestChildFileEnv, childFile) | |
| 25 | - | t.Setenv(spawnTestLockDirEnv, runtimeDir) | |
| 26 | - | ||
| 27 | - | cmd := exec.Command(os.Args[0], "--daemon") | |
| 28 | - | cmd.Env = os.Environ() | |
| 29 | - | require.NoError(t, cmd.Start()) | |
| 30 | - | t.Cleanup(func() { | |
| 31 | - | _ = cmd.Process.Kill() | |
| 32 | - | _, _ = cmd.Process.Wait() | |
| 33 | - | }) | |
| 34 | - | daemonPID := waitForProcessRecord(t, childFile) | |
| 35 | - | ||
| 36 | - | finder := platformDaemonProcessFinder{} | |
| 37 | - | var process daemonProcess | |
| 38 | - | var ok bool | |
| 39 | - | require.Eventually(t, func() bool { | |
| 40 | - | var err error | |
| 41 | - | process, ok, err = finder.Find(context.Background(), runtimeDir) | |
| 42 | - | require.NoError(t, err) | |
| 43 | - | return ok | |
| 44 | - | }, 5*time.Second, 10*time.Millisecond, "finder did not identify the lock holder") | |
| 45 | - | ||
| 46 | - | require.Equal(t, daemonPID, process.PID) | |
| 47 | - | require.Contains(t, process.Command, "--daemon") | |
| 48 | - | require.NotEmpty(t, process.Start) | |
| 49 | - | ||
| 50 | - | lockInfo, err := os.Stat(lifecycle.Path(runtimeDir)) | |
| 51 | - | require.NoError(t, err) | |
| 52 | - | require.EqualValues(t, 0, lockInfo.Size(), "the legacy lifecycle lock stays empty") | |
| 53 | - | } | |
| 54 | - | ||
| 55 | - | // TestLinuxForceStopFinderIgnoresNonDaemonLockHolder proves a same-user process | |
| 56 | - | // that merely holds lifecycle.lock is not a candidate: this test binary owns the | |
| 57 | - | // lock but was not started as a vev daemon. | |
| 58 | - | func TestLinuxForceStopFinderIgnoresNonDaemonLockHolder(t *testing.T) { | |
| 59 | - | runtimeDir := filepath.Join(t.TempDir(), "runtime") | |
| 60 | - | owner, err := lifecycle.TryAcquire(runtimeDir) | |
| 61 | - | require.NoError(t, err) | |
| 62 | - | defer func() { require.NoError(t, owner.Release()) }() | |
| 63 | - | ||
| 64 | - | _, ok, err := platformDaemonProcessFinder{}.Find(context.Background(), runtimeDir) | |
| 65 | - | ||
| 66 | - | require.NoError(t, err) | |
| 67 | - | require.False(t, ok, "ownership must not be inferred from a process name alone") | |
| 68 | - | } | |
| 69 | - | ||
| 70 | - | func TestLinuxForceStopFinderIgnoresUnheldLockFile(t *testing.T) { | |
| 71 | - | runtimeDir := filepath.Join(t.TempDir(), "runtime") | |
| 72 | - | require.NoError(t, os.MkdirAll(runtimeDir, 0o700)) | |
| 73 | - | require.NoError(t, os.WriteFile(lifecycle.Path(runtimeDir), nil, 0o600)) | |
| 74 | - | ||
| 75 | - | _, ok, err := platformDaemonProcessFinder{}.Find(context.Background(), runtimeDir) | |
| 76 | - | require.NoError(t, err) | |
| 77 | - | require.False(t, ok) | |
| 78 | - | } | |
| 79 | - | ||
| 80 | - | func TestLinuxForceStopFinderIgnoresMissingLockFile(t *testing.T) { | |
| 81 | - | _, ok, err := platformDaemonProcessFinder{}.Find(context.Background(), filepath.Join(t.TempDir(), "runtime")) | |
| 82 | - | require.NoError(t, err) | |
| 83 | - | require.False(t, ok) | |
| 84 | - | } | |
| 85 | - | ||
| 86 | - | // TestLinuxProcessStartMarkerIsStable pins the PID-reuse marker: the kernel | |
| 87 | - | // start time of our own process is stable across reads and distinguishes | |
| 88 | - | // incarnations. | |
| 89 | - | func TestLinuxProcessStartMarkerIsStable(t *testing.T) { | |
| 90 | - | first, err := procStartMarker(os.Getpid()) | |
| 91 | - | require.NoError(t, err) | |
| 92 | - | require.NotEmpty(t, first) | |
| 93 | - | ||
| 94 | - | second, err := procStartMarker(os.Getpid()) | |
| 95 | - | require.NoError(t, err) | |
| 96 | - | require.Equal(t, first, second) | |
| 97 | - | ||
| 98 | - | // Sanity: a different live process has a different marker or at least a | |
| 99 | - | // parseable one. | |
| 100 | - | other, err := procStartMarker(1) | |
| 101 | - | require.NoError(t, err) | |
| 102 | - | require.NotEmpty(t, other) | |
| 103 | - | } | |
D internal/app/force_stop_test.go +0 -456
| @@ -1,456 +0,0 @@ | |||
| 1 | - | package app | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "bytes" | |
| 5 | - | "context" | |
| 6 | - | "errors" | |
| 7 | - | "io" | |
| 8 | - | "os" | |
| 9 | - | "path/filepath" | |
| 10 | - | "strings" | |
| 11 | - | "syscall" | |
| 12 | - | "testing" | |
| 13 | - | "time" | |
| 14 | - | ||
| 15 | - | "github.com/bnema/vev/internal/adapters/ipc" | |
| 16 | - | "github.com/bnema/vev/internal/adapters/lifecycle" | |
| 17 | - | "github.com/stretchr/testify/require" | |
| 18 | - | ) | |
| 19 | - | ||
| 20 | - | type funcLifecycleProbe struct { | |
| 21 | - | tryAcquire func(string) (lifecycleOwnership, error) | |
| 22 | - | } | |
| 23 | - | ||
| 24 | - | func (p funcLifecycleProbe) TryAcquire(dir string) (lifecycleOwnership, error) { | |
| 25 | - | return p.tryAcquire(dir) | |
| 26 | - | } | |
| 27 | - | ||
| 28 | - | type fakeDaemonFinder struct { | |
| 29 | - | find func(ctx context.Context, runtimeDir string) (daemonProcess, bool, error) | |
| 30 | - | } | |
| 31 | - | ||
| 32 | - | func (f fakeDaemonFinder) Find(ctx context.Context, runtimeDir string) (daemonProcess, bool, error) { | |
| 33 | - | if f.find == nil { | |
| 34 | - | return daemonProcess{}, false, nil | |
| 35 | - | } | |
| 36 | - | return f.find(ctx, runtimeDir) | |
| 37 | - | } | |
| 38 | - | ||
| 39 | - | func freeLifecycleOwnership() lifecycleOwnership { | |
| 40 | - | return fakeLifecycleOwnership{release: func() error { return nil }} | |
| 41 | - | } | |
| 42 | - | ||
| 43 | - | // forceStopFixture drives forceStopDaemon without touching real processes, the | |
| 44 | - | // real terminal, or the wall clock. | |
| 45 | - | type forceStopFixture struct { | |
| 46 | - | hooks forceStopHooks | |
| 47 | - | signals []syscall.Signal | |
| 48 | - | output bytes.Buffer | |
| 49 | - | now time.Time | |
| 50 | - | pid int | |
| 51 | - | } | |
| 52 | - | ||
| 53 | - | func newForceStopFixture(t *testing.T, input string) *forceStopFixture { | |
| 54 | - | t.Helper() | |
| 55 | - | f := &forceStopFixture{now: time.Now(), pid: 4242} | |
| 56 | - | f.hooks = forceStopHooks{ | |
| 57 | - | probe: funcLifecycleProbe{tryAcquire: func(string) (lifecycleOwnership, error) { | |
| 58 | - | return nil, lifecycle.ErrBusy | |
| 59 | - | }}, | |
| 60 | - | finder: fakeDaemonFinder{find: func(context.Context, string) (daemonProcess, bool, error) { | |
| 61 | - | return daemonProcess{PID: f.pid, Command: "/proc/self/exe --daemon", Start: "start-1"}, true, nil | |
| 62 | - | }}, | |
| 63 | - | signal: func(_ int, sig syscall.Signal) error { | |
| 64 | - | f.signals = append(f.signals, sig) | |
| 65 | - | return nil | |
| 66 | - | }, | |
| 67 | - | now: func() time.Time { return f.now }, | |
| 68 | - | sleep: func(_ context.Context, d time.Duration) error { f.now = f.now.Add(d); return nil }, | |
| 69 | - | stdin: strings.NewReader(input), | |
| 70 | - | stdout: &f.output, | |
| 71 | - | } | |
| 72 | - | return f | |
| 73 | - | } | |
| 74 | - | ||
| 75 | - | // freeAfterSignals reports busy ownership until signals have been delivered. | |
| 76 | - | func (f *forceStopFixture) freeAfterSignals(count int) { | |
| 77 | - | f.hooks.probe = funcLifecycleProbe{tryAcquire: func(string) (lifecycleOwnership, error) { | |
| 78 | - | if len(f.signals) >= count { | |
| 79 | - | return freeLifecycleOwnership(), nil | |
| 80 | - | } | |
| 81 | - | return nil, lifecycle.ErrBusy | |
| 82 | - | }} | |
| 83 | - | } | |
| 84 | - | ||
| 85 | - | // forceStopRuntime builds a runtime directory with the artifacts a force-stop | |
| 86 | - | // may remove, plus durable state it must never touch. | |
| 87 | - | func forceStopRuntime(t *testing.T) (runtimeDir, stateDir string) { | |
| 88 | - | t.Helper() | |
| 89 | - | root := t.TempDir() | |
| 90 | - | runtimeDir = filepath.Join(root, "runtime") | |
| 91 | - | stateDir = filepath.Join(root, "state") | |
| 92 | - | require.NoError(t, os.MkdirAll(runtimeDir, 0o700)) | |
| 93 | - | require.NoError(t, os.MkdirAll(stateDir, 0o700)) | |
| 94 | - | require.NoError(t, os.WriteFile(ipc.SocketPath(runtimeDir), []byte("stale"), 0o600)) | |
| 95 | - | spawnLock := filepath.Join(runtimeDir, spawnLockName) | |
| 96 | - | require.NoError(t, os.Mkdir(spawnLock, 0o700)) | |
| 97 | - | old := time.Now().Add(-time.Hour) | |
| 98 | - | require.NoError(t, os.Chtimes(spawnLock, old, old)) | |
| 99 | - | require.NoError(t, os.WriteFile(filepath.Join(stateDir, "sessions.kv"), []byte("durable"), 0o600)) | |
| 100 | - | require.NoError(t, os.WriteFile(filepath.Join(stateDir, "config.toml"), []byte("config"), 0o600)) | |
| 101 | - | require.NoError(t, os.WriteFile(filepath.Join(stateDir, "vev.log"), []byte("log"), 0o600)) | |
| 102 | - | require.NoError(t, os.Mkdir(filepath.Join(stateDir, "snapshots"), 0o700)) | |
| 103 | - | return runtimeDir, stateDir | |
| 104 | - | } | |
| 105 | - | ||
| 106 | - | func assertDurableStateIntact(t *testing.T, stateDir string) { | |
| 107 | - | t.Helper() | |
| 108 | - | for name, want := range map[string]string{ | |
| 109 | - | "sessions.kv": "durable", | |
| 110 | - | "config.toml": "config", | |
| 111 | - | "vev.log": "log", | |
| 112 | - | } { | |
| 113 | - | got, err := os.ReadFile(filepath.Join(stateDir, name)) | |
| 114 | - | require.NoError(t, err, name) | |
| 115 | - | require.Equal(t, want, string(got), name) | |
| 116 | - | } | |
| 117 | - | info, err := os.Stat(filepath.Join(stateDir, "snapshots")) | |
| 118 | - | require.NoError(t, err) | |
| 119 | - | require.True(t, info.IsDir()) | |
| 120 | - | } | |
| 121 | - | ||
| 122 | - | func TestForceStopNotApplicableWhenOwnershipIsFree(t *testing.T) { | |
| 123 | - | f := newForceStopFixture(t, "y\n") | |
| 124 | - | f.hooks.probe = funcLifecycleProbe{tryAcquire: func(string) (lifecycleOwnership, error) { | |
| 125 | - | return freeLifecycleOwnership(), nil | |
| 126 | - | }} | |
| 127 | - | ||
| 128 | - | err := forceStopDaemon(context.Background(), t.TempDir(), f.hooks) | |
| 129 | - | ||
| 130 | - | require.ErrorIs(t, err, errForceStopNotApplicable) | |
| 131 | - | require.Empty(t, f.signals) | |
| 132 | - | require.NotContains(t, f.output.String(), "[y/N]", "no prompt may be offered without a busy lifecycle") | |
| 133 | - | } | |
| 134 | - | ||
| 135 | - | func TestForceStopNotApplicableWithoutVerifiedProcess(t *testing.T) { | |
| 136 | - | f := newForceStopFixture(t, "y\n") | |
| 137 | - | f.hooks.finder = fakeDaemonFinder{find: func(context.Context, string) (daemonProcess, bool, error) { | |
| 138 | - | return daemonProcess{}, false, nil | |
| 139 | - | }} | |
| 140 | - | ||
| 141 | - | err := forceStopDaemon(context.Background(), t.TempDir(), f.hooks) | |
| 142 | - | ||
| 143 | - | require.ErrorIs(t, err, errForceStopNotApplicable) | |
| 144 | - | require.Empty(t, f.signals) | |
| 145 | - | require.NotContains(t, f.output.String(), "[y/N]") | |
| 146 | - | } | |
| 147 | - | ||
| 148 | - | func TestForceStopPromptRequiresExplicitYes(t *testing.T) { | |
| 149 | - | tests := []struct { | |
| 150 | - | name string | |
| 151 | - | input string | |
| 152 | - | wantStopped bool | |
| 153 | - | }{ | |
| 154 | - | {name: "empty input defaults to refusal", input: ""}, | |
| 155 | - | {name: "blank line defaults to refusal", input: "\n"}, | |
| 156 | - | {name: "n refuses", input: "n\n"}, | |
| 157 | - | {name: "no refuses", input: "no\n"}, | |
| 158 | - | {name: "yeah refuses", input: "yeah\n"}, | |
| 159 | - | {name: "numeric one refuses", input: "1\n"}, | |
| 160 | - | {name: "y accepts", input: "y\n", wantStopped: true}, | |
| 161 | - | {name: "yes accepts", input: "yes\n", wantStopped: true}, | |
| 162 | - | {name: "y without newline accepts", input: "y", wantStopped: true}, | |
| 163 | - | {name: "uppercase Y accepts", input: "Y\n", wantStopped: true}, | |
| 164 | - | {name: "mixed case Yes accepts", input: "Yes\n", wantStopped: true}, | |
| 165 | - | {name: "padded y accepts", input: " y \n", wantStopped: true}, | |
| 166 | - | } | |
| 167 | - | for _, tt := range tests { | |
| 168 | - | t.Run(tt.name, func(t *testing.T) { | |
| 169 | - | f := newForceStopFixture(t, tt.input) | |
| 170 | - | f.freeAfterSignals(1) | |
| 171 | - | runtimeDir, _ := forceStopRuntime(t) | |
| 172 | - | ||
| 173 | - | err := forceStopDaemon(context.Background(), runtimeDir, f.hooks) | |
| 174 | - | ||
| 175 | - | if tt.wantStopped { | |
| 176 | - | require.NoError(t, err) | |
| 177 | - | require.Equal(t, []syscall.Signal{syscall.SIGTERM}, f.signals) | |
| 178 | - | return | |
| 179 | - | } | |
| 180 | - | require.ErrorIs(t, err, errForceStopDeclined) | |
| 181 | - | require.Empty(t, f.signals) | |
| 182 | - | }) | |
| 183 | - | } | |
| 184 | - | } | |
| 185 | - | ||
| 186 | - | func TestForceStopSendsSIGTERMAndRemovesStaleRuntimeArtifacts(t *testing.T) { | |
| 187 | - | f := newForceStopFixture(t, "y\n") | |
| 188 | - | f.freeAfterSignals(1) | |
| 189 | - | runtimeDir, stateDir := forceStopRuntime(t) | |
| 190 | - | ||
| 191 | - | err := forceStopDaemon(context.Background(), runtimeDir, f.hooks) | |
| 192 | - | ||
| 193 | - | require.NoError(t, err) | |
| 194 | - | require.Equal(t, []syscall.Signal{syscall.SIGTERM}, f.signals) | |
| 195 | - | require.Contains(t, f.output.String(), "4242") | |
| 196 | - | require.Contains(t, f.output.String(), "/proc/self/exe --daemon") | |
| 197 | - | require.Contains(t, f.output.String(), lifecycle.Path(runtimeDir)) | |
| 198 | - | require.Contains(t, f.output.String(), "stopped daemon process 4242") | |
| 199 | - | require.NoFileExists(t, ipc.SocketPath(runtimeDir)) | |
| 200 | - | require.NoDirExists(t, filepath.Join(runtimeDir, spawnLockName)) | |
| 201 | - | assertDurableStateIntact(t, stateDir) | |
| 202 | - | } | |
| 203 | - | ||
| 204 | - | func TestForceStopKeepsFreshSpawnLock(t *testing.T) { | |
| 205 | - | f := newForceStopFixture(t, "y\n") | |
| 206 | - | f.freeAfterSignals(1) | |
| 207 | - | runtimeDir, _ := forceStopRuntime(t) | |
| 208 | - | fresh := filepath.Join(runtimeDir, spawnLockName) | |
| 209 | - | require.NoError(t, os.Chtimes(fresh, f.now, f.now)) | |
| 210 | - | ||
| 211 | - | require.NoError(t, forceStopDaemon(context.Background(), runtimeDir, f.hooks)) | |
| 212 | - | ||
| 213 | - | require.NoFileExists(t, ipc.SocketPath(runtimeDir)) | |
| 214 | - | require.DirExists(t, fresh, "a concurrent spawner's fresh lock must be preserved") | |
| 215 | - | } | |
| 216 | - | ||
| 217 | - | func TestForceStopEscalatesToSIGKILLOnceForTheSameProcess(t *testing.T) { | |
| 218 | - | f := newForceStopFixture(t, "yes\n") | |
| 219 | - | f.freeAfterSignals(2) | |
| 220 | - | runtimeDir, stateDir := forceStopRuntime(t) | |
| 221 | - | start := f.now | |
| 222 | - | ||
| 223 | - | require.NoError(t, forceStopDaemon(context.Background(), runtimeDir, f.hooks)) | |
| 224 | - | ||
| 225 | - | require.Equal(t, []syscall.Signal{syscall.SIGTERM, syscall.SIGKILL}, f.signals) | |
| 226 | - | require.GreaterOrEqual(t, f.now.Sub(start), forceStopSIGTERMWait, | |
| 227 | - | "SIGKILL must only follow the bounded SIGTERM wait") | |
| 228 | - | require.NoFileExists(t, ipc.SocketPath(runtimeDir)) | |
| 229 | - | require.NoDirExists(t, filepath.Join(runtimeDir, spawnLockName)) | |
| 230 | - | assertDurableStateIntact(t, stateDir) | |
| 231 | - | } | |
| 232 | - | ||
| 233 | - | func TestForceStopRefusesSIGKILLWhenProcessIdentityChanged(t *testing.T) { | |
| 234 | - | f := newForceStopFixture(t, "y\n") | |
| 235 | - | runtimeDir, stateDir := forceStopRuntime(t) | |
| 236 | - | calls := 0 | |
| 237 | - | f.hooks.finder = fakeDaemonFinder{find: func(context.Context, string) (daemonProcess, bool, error) { | |
| 238 | - | calls++ | |
| 239 | - | // The confirmed incarnation stays valid through the SIGTERM re-check and | |
| 240 | - | // disappears only before the SIGKILL escalation. | |
| 241 | - | if calls <= 2 { | |
| 242 | - | return daemonProcess{PID: f.pid, Command: "vev --daemon", Start: "start-1"}, true, nil | |
| 243 | - | } | |
| 244 | - | // The verified incarnation is gone: the PID now belongs to another process. | |
| 245 | - | return daemonProcess{PID: f.pid, Command: "unrelated", Start: "start-2"}, true, nil | |
| 246 | - | }} | |
| 247 | - | ||
| 248 | - | err := forceStopDaemon(context.Background(), runtimeDir, f.hooks) | |
| 249 | - | ||
| 250 | - | require.ErrorIs(t, err, errForceStopTimeout) | |
| 251 | - | require.Equal(t, []syscall.Signal{syscall.SIGTERM}, f.signals, "a reused PID must never receive SIGKILL") | |
| 252 | - | require.FileExists(t, ipc.SocketPath(runtimeDir), "artifacts stay until ownership is released") | |
| 253 | - | assertDurableStateIntact(t, stateDir) | |
| 254 | - | } | |
| 255 | - | ||
| 256 | - | // promptChangeReader flips the verified incarnation as soon as the operator's | |
| 257 | - | // answer is consumed, so the identity check that follows confirmation sees a | |
| 258 | - | // PID that was reused while the prompt was open. | |
| 259 | - | type promptChangeReader struct { | |
| 260 | - | reader io.Reader | |
| 261 | - | onRead func() | |
| 262 | - | } | |
| 263 | - | ||
| 264 | - | func (r promptChangeReader) Read(p []byte) (int, error) { | |
| 265 | - | n, err := r.reader.Read(p) | |
| 266 | - | if n > 0 { | |
| 267 | - | r.onRead() | |
| 268 | - | } | |
| 269 | - | return n, err | |
| 270 | - | } | |
| 271 | - | ||
| 272 | - | func TestForceStopReverifiesIdentityAfterConfirmation(t *testing.T) { | |
| 273 | - | tests := []struct { | |
| 274 | - | name string | |
| 275 | - | change func(f *forceStopFixture) (daemonProcess, bool, error) | |
| 276 | - | }{ | |
| 277 | - | { | |
| 278 | - | name: "pid reused by an unrelated process", | |
| 279 | - | change: func(f *forceStopFixture) (daemonProcess, bool, error) { | |
| 280 | - | return daemonProcess{PID: f.pid, Command: "unrelated", Start: "start-2"}, true, nil | |
| 281 | - | }, | |
| 282 | - | }, | |
| 283 | - | { | |
| 284 | - | name: "verified process vanished", | |
| 285 | - | change: func(*forceStopFixture) (daemonProcess, bool, error) { | |
| 286 | - | return daemonProcess{}, false, nil | |
| 287 | - | }, | |
| 288 | - | }, | |
| 289 | - | } | |
| 290 | - | for _, tt := range tests { | |
| 291 | - | t.Run(tt.name, func(t *testing.T) { | |
| 292 | - | f := newForceStopFixture(t, "y\n") | |
| 293 | - | runtimeDir, stateDir := forceStopRuntime(t) | |
| 294 | - | reverified := false | |
| 295 | - | f.hooks.finder = fakeDaemonFinder{find: func(context.Context, string) (daemonProcess, bool, error) { | |
| 296 | - | if reverified { | |
| 297 | - | return tt.change(f) | |
| 298 | - | } | |
| 299 | - | return daemonProcess{PID: f.pid, Command: "/proc/self/exe --daemon", Start: "start-1"}, true, nil | |
| 300 | - | }} | |
| 301 | - | f.hooks.stdin = promptChangeReader{ | |
| 302 | - | reader: strings.NewReader("y\n"), | |
| 303 | - | onRead: func() { reverified = true }, | |
| 304 | - | } | |
| 305 | - | ||
| 306 | - | err := forceStopDaemon(context.Background(), runtimeDir, f.hooks) | |
| 307 | - | ||
| 308 | - | require.ErrorIs(t, err, errForceStopTimeout) | |
| 309 | - | require.Empty(t, f.signals, "the confirmed PID must never receive a signal once its identity changed") | |
| 310 | - | require.FileExists(t, ipc.SocketPath(runtimeDir), "artifacts stay until ownership is released") | |
| 311 | - | require.DirExists(t, filepath.Join(runtimeDir, spawnLockName)) | |
| 312 | - | assertDurableStateIntact(t, stateDir) | |
| 313 | - | }) | |
| 314 | - | } | |
| 315 | - | } | |
| 316 | - | ||
| 317 | - | // TestForceStopCleansArtifactsWhenProcessVanishesAndOwnershipFreed covers the | |
| 318 | - | // daemon exiting while the operator answers the prompt: the verified process | |
| 319 | - | // disappears and lifecycle ownership is free, so only stale runtime artifacts | |
| 320 | - | // are removed and no signal is ever sent. | |
| 321 | - | func TestForceStopCleansArtifactsWhenProcessVanishesAndOwnershipFreed(t *testing.T) { | |
| 322 | - | f := newForceStopFixture(t, "y\n") | |
| 323 | - | runtimeDir, stateDir := forceStopRuntime(t) | |
| 324 | - | reverified := false | |
| 325 | - | f.hooks.finder = fakeDaemonFinder{find: func(context.Context, string) (daemonProcess, bool, error) { | |
| 326 | - | if reverified { | |
| 327 | - | return daemonProcess{}, false, nil | |
| 328 | - | } | |
| 329 | - | return daemonProcess{PID: f.pid, Command: "/proc/self/exe --daemon", Start: "start-1"}, true, nil | |
| 330 | - | }} | |
| 331 | - | f.hooks.probe = funcLifecycleProbe{tryAcquire: func(string) (lifecycleOwnership, error) { | |
| 332 | - | if reverified { | |
| 333 | - | return freeLifecycleOwnership(), nil | |
| 334 | - | } | |
| 335 | - | return nil, lifecycle.ErrBusy | |
| 336 | - | }} | |
| 337 | - | f.hooks.stdin = promptChangeReader{ | |
| 338 | - | reader: strings.NewReader("y\n"), | |
| 339 | - | onRead: func() { reverified = true }, | |
| 340 | - | } | |
| 341 | - | ||
| 342 | - | err := forceStopDaemon(context.Background(), runtimeDir, f.hooks) | |
| 343 | - | ||
| 344 | - | require.NoError(t, err) | |
| 345 | - | require.Empty(t, f.signals, "an already-exited daemon must never be signalled") | |
| 346 | - | require.NoFileExists(t, ipc.SocketPath(runtimeDir)) | |
| 347 | - | require.NoDirExists(t, filepath.Join(runtimeDir, spawnLockName)) | |
| 348 | - | assertDurableStateIntact(t, stateDir) | |
| 349 | - | } | |
| 350 | - | ||
| 351 | - | // TestForceStopFailsWhenProcessChangesButOwnershipStaysBusy keeps the | |
| 352 | - | // conservative path: a reused PID with ownership still held is never signalled | |
| 353 | - | // and its runtime artifacts are left in place. | |
| 354 | - | func TestForceStopFailsWhenProcessChangesButOwnershipStaysBusy(t *testing.T) { | |
| 355 | - | f := newForceStopFixture(t, "y\n") | |
| 356 | - | runtimeDir, stateDir := forceStopRuntime(t) | |
| 357 | - | reverified := false | |
| 358 | - | f.hooks.finder = fakeDaemonFinder{find: func(context.Context, string) (daemonProcess, bool, error) { | |
| 359 | - | if reverified { | |
| 360 | - | return daemonProcess{PID: f.pid, Command: "unrelated", Start: "start-2"}, true, nil | |
| 361 | - | } | |
| 362 | - | return daemonProcess{PID: f.pid, Command: "/proc/self/exe --daemon", Start: "start-1"}, true, nil | |
| 363 | - | }} | |
| 364 | - | f.hooks.stdin = promptChangeReader{ | |
| 365 | - | reader: strings.NewReader("y\n"), | |
| 366 | - | onRead: func() { reverified = true }, | |
| 367 | - | } | |
| 368 | - | ||
| 369 | - | err := forceStopDaemon(context.Background(), runtimeDir, f.hooks) | |
| 370 | - | ||
| 371 | - | require.ErrorIs(t, err, errForceStopTimeout) | |
| 372 | - | require.Empty(t, f.signals) | |
| 373 | - | require.FileExists(t, ipc.SocketPath(runtimeDir), "artifacts stay while another process holds ownership") | |
| 374 | - | assertDurableStateIntact(t, stateDir) | |
| 375 | - | } | |
| 376 | - | ||
| 377 | - | func TestForceStopIsBoundedWhenOwnershipNeverTransfers(t *testing.T) { | |
| 378 | - | f := newForceStopFixture(t, "y\n") | |
| 379 | - | runtimeDir, _ := forceStopRuntime(t) | |
| 380 | - | start := f.now | |
| 381 | - | ||
| 382 | - | err := forceStopDaemon(context.Background(), runtimeDir, f.hooks) | |
| 383 | - | ||
| 384 | - | require.ErrorIs(t, err, errForceStopTimeout) | |
| 385 | - | require.Equal(t, []syscall.Signal{syscall.SIGTERM, syscall.SIGKILL}, f.signals, "escalation never repeats") | |
| 386 | - | require.GreaterOrEqual(t, f.now.Sub(start), forceStopSIGTERMWait+forceStopSIGKILLWait) | |
| 387 | - | require.FileExists(t, ipc.SocketPath(runtimeDir)) | |
| 388 | - | } | |
| 389 | - | ||
| 390 | - | func TestForceStopDeclinedLeavesRuntimeArtifacts(t *testing.T) { | |
| 391 | - | f := newForceStopFixture(t, "") | |
| 392 | - | runtimeDir, stateDir := forceStopRuntime(t) | |
| 393 | - | ||
| 394 | - | err := forceStopDaemon(context.Background(), runtimeDir, f.hooks) | |
| 395 | - | ||
| 396 | - | require.ErrorIs(t, err, errForceStopDeclined) | |
| 397 | - | require.FileExists(t, ipc.SocketPath(runtimeDir)) | |
| 398 | - | require.DirExists(t, filepath.Join(runtimeDir, spawnLockName)) | |
| 399 | - | assertDurableStateIntact(t, stateDir) | |
| 400 | - | } | |
| 401 | - | ||
| 402 | - | func TestForceStopFallbackPreservesCauseWhenNotApplicable(t *testing.T) { | |
| 403 | - | original := forceStopDaemonFn | |
| 404 | - | t.Cleanup(func() { forceStopDaemonFn = original }) | |
| 405 | - | cause := errors.New("typed kill failed") | |
| 406 | - | forceStopDaemonFn = func(context.Context) error { return errForceStopNotApplicable } | |
| 407 | - | ||
| 408 | - | require.ErrorIs(t, forceStopDaemonFallback(context.Background(), cause), cause) | |
| 409 | - | ||
| 410 | - | forceStopDaemonFn = func(context.Context) error { return nil } | |
| 411 | - | require.NoError(t, forceStopDaemonFallback(context.Background(), cause)) | |
| 412 | - | ||
| 413 | - | forceStopDaemonFn = func(context.Context) error { return errForceStopDeclined } | |
| 414 | - | require.ErrorIs(t, forceStopDaemonFallback(context.Background(), cause), errForceStopDeclined) | |
| 415 | - | } | |
| 416 | - | ||
| 417 | - | // TestRunKillOffersForceStopOnlyForDaemonScope drives the real runKill path with | |
| 418 | - | // an unreachable daemon whose lifecycle ownership stays busy. | |
| 419 | - | func TestRunKillOffersForceStopOnlyForDaemonScope(t *testing.T) { | |
| 420 | - | originalBackoff := defaultBackoff | |
| 421 | - | originalFallback := forceStopDaemonFn | |
| 422 | - | t.Cleanup(func() { | |
| 423 | - | defaultBackoff = originalBackoff | |
| 424 | - | forceStopDaemonFn = originalFallback | |
| 425 | - | }) | |
| 426 | - | defaultBackoff = backoffConfig{initial: time.Millisecond, max: time.Millisecond, total: 5 * time.Millisecond} | |
| 427 | - | ||
| 428 | - | t.Setenv("XDG_RUNTIME_DIR", t.TempDir()) | |
| 429 | - | t.Setenv("XDG_STATE_HOME", t.TempDir()) | |
| 430 | - | owner, err := lifecycle.TryAcquire(ipc.SocketDir()) | |
| 431 | - | require.NoError(t, err) | |
| 432 | - | defer func() { require.NoError(t, owner.Release()) }() | |
| 433 | - | ||
| 434 | - | t.Run("daemon scope offers the fallback", func(t *testing.T) { | |
| 435 | - | called := 0 | |
| 436 | - | forceStopDaemonFn = func(context.Context) error { called++; return nil } | |
| 437 | - | require.NoError(t, runKill(context.Background(), "", false, true)) | |
| 438 | - | require.Equal(t, 1, called) | |
| 439 | - | }) | |
| 440 | - | ||
| 441 | - | t.Run("daemon scope keeps the typed failure when no candidate exists", func(t *testing.T) { | |
| 442 | - | called := 0 | |
| 443 | - | forceStopDaemonFn = func(context.Context) error { called++; return errForceStopNotApplicable } | |
| 444 | - | err := runKill(context.Background(), "", false, true) | |
| 445 | - | require.Equal(t, 1, called) | |
| 446 | - | require.ErrorIs(t, err, ErrDaemonUnreachable) | |
| 447 | - | }) | |
| 448 | - | ||
| 449 | - | t.Run("all scope never offers the fallback", func(t *testing.T) { | |
| 450 | - | called := 0 | |
| 451 | - | forceStopDaemonFn = func(context.Context) error { called++; return nil } | |
| 452 | - | err := runKill(context.Background(), "", true, false) | |
| 453 | - | require.Zero(t, called) | |
| 454 | - | require.ErrorIs(t, err, ErrDaemonUnreachable) | |
| 455 | - | }) | |
| 456 | - | } | |
D internal/app/kill_result_test.go +0 -429
| @@ -1,429 +0,0 @@ | |||
| 1 | - | package app | |
| 2 | - | ||
| 3 | - | // KillResult caller contract (explicit result slice). runKill and | |
| 4 | - | // requestDaemonStop send one Kill with a unique nonzero RequestID and consume | |
| 5 | - | // exactly one matching KillResult; a lost, uncorrelated, or wrong-typed reply is | |
| 6 | - | // outcome-unknown and is never replayed, while a caller canceled before the | |
| 7 | - | // send is a definite not-sent outcome. These tests drive the two helpers those | |
| 8 | - | // callers share with a controllable in-memory client connection, so every case | |
| 9 | - | // is bounded and uses no sleeps. | |
| 10 | - | ||
| 11 | - | import ( | |
| 12 | - | "context" | |
| 13 | - | "errors" | |
| 14 | - | "io" | |
| 15 | - | "sync" | |
| 16 | - | "sync/atomic" | |
| 17 | - | "testing" | |
| 18 | - | "time" | |
| 19 | - | ||
| 20 | - | "github.com/stretchr/testify/require" | |
| 21 | - | ||
| 22 | - | "github.com/bnema/vev/internal/adapters/ipc" | |
| 23 | - | "github.com/bnema/vev/internal/adapters/sessionwire" | |
| 24 | - | "github.com/bnema/vev/internal/ports" | |
| 25 | - | "github.com/bnema/vev/internal/protocol" | |
| 26 | - | ) | |
| 27 | - | ||
| 28 | - | // fakeKillConnection is a scripted ports.ClientConnection. Sends are recorded | |
| 29 | - | // and counted; ReceiveServer serves queued replies in order, then either a | |
| 30 | - | // reply derived from the last sent Kill (replyForKill), a terminal error, or a | |
| 31 | - | // block until Close. | |
| 32 | - | type fakeKillConnection struct { | |
| 33 | - | mu sync.Mutex | |
| 34 | - | sends []protocol.ClientMessage | |
| 35 | - | recv []protocol.ServerMessage | |
| 36 | - | replyForKill func(protocol.Kill) protocol.ServerMessage | |
| 37 | - | sendErr error | |
| 38 | - | recvErr error | |
| 39 | - | // block: once no reply is available, ReceiveServer parks until Close. | |
| 40 | - | block bool | |
| 41 | - | closed chan struct{} | |
| 42 | - | once sync.Once | |
| 43 | - | // recvEntered reports that a blocked ReceiveServer was actually entered. | |
| 44 | - | recvEntered chan struct{} | |
| 45 | - | enterOnce sync.Once | |
| 46 | - | } | |
| 47 | - | ||
| 48 | - | func newFakeKillConnection() *fakeKillConnection { | |
| 49 | - | return &fakeKillConnection{closed: make(chan struct{}), recvEntered: make(chan struct{})} | |
| 50 | - | } | |
| 51 | - | ||
| 52 | - | func (c *fakeKillConnection) SendClient(message protocol.ClientMessage) error { | |
| 53 | - | c.mu.Lock() | |
| 54 | - | defer c.mu.Unlock() | |
| 55 | - | if c.sendErr != nil { | |
| 56 | - | return c.sendErr | |
| 57 | - | } | |
| 58 | - | c.sends = append(c.sends, message) | |
| 59 | - | return nil | |
| 60 | - | } | |
| 61 | - | ||
| 62 | - | func (c *fakeKillConnection) ReceiveServer() (protocol.ServerMessage, error) { | |
| 63 | - | c.mu.Lock() | |
| 64 | - | if len(c.recv) > 0 { | |
| 65 | - | next := c.recv[0] | |
| 66 | - | c.recv = c.recv[1:] | |
| 67 | - | c.mu.Unlock() | |
| 68 | - | return next, nil | |
| 69 | - | } | |
| 70 | - | // replyForKill derives a matching result from the exact request that was | |
| 71 | - | // sent, so a test never has to predict the fresh random RequestID. | |
| 72 | - | if c.replyForKill != nil && len(c.sends) > 0 { | |
| 73 | - | if kill, ok := c.sends[len(c.sends)-1].(protocol.Kill); ok { | |
| 74 | - | reply := c.replyForKill(kill) | |
| 75 | - | c.mu.Unlock() | |
| 76 | - | return reply, nil | |
| 77 | - | } | |
| 78 | - | } | |
| 79 | - | block, recvErr := c.block, c.recvErr | |
| 80 | - | c.mu.Unlock() | |
| 81 | - | if !block { | |
| 82 | - | return nil, recvErr | |
| 83 | - | } | |
| 84 | - | c.enterOnce.Do(func() { close(c.recvEntered) }) | |
| 85 | - | <-c.closed | |
| 86 | - | return nil, io.ErrClosedPipe | |
| 87 | - | } | |
| 88 | - | ||
| 89 | - | func (c *fakeKillConnection) Capabilities() protocol.ConnectionCapabilities { | |
| 90 | - | return protocol.ConnectionCapabilities{} | |
| 91 | - | } | |
| 92 | - | ||
| 93 | - | func (*fakeKillConnection) LinkState() ports.LinkState { return ports.LinkState(0) } | |
| 94 | - | func (*fakeKillConnection) LinkEvents() <-chan ports.LinkEvent { return nil } | |
| 95 | - | ||
| 96 | - | func (c *fakeKillConnection) Close() error { | |
| 97 | - | c.once.Do(func() { close(c.closed) }) | |
| 98 | - | return nil | |
| 99 | - | } | |
| 100 | - | ||
| 101 | - | func (c *fakeKillConnection) sentCount() int { | |
| 102 | - | c.mu.Lock() | |
| 103 | - | defer c.mu.Unlock() | |
| 104 | - | return len(c.sends) | |
| 105 | - | } | |
| 106 | - | ||
| 107 | - | func (c *fakeKillConnection) sentKill(t *testing.T) protocol.Kill { | |
| 108 | - | t.Helper() | |
| 109 | - | c.mu.Lock() | |
| 110 | - | defer c.mu.Unlock() | |
| 111 | - | require.Len(t, c.sends, 1, "exactly one Kill must be sent (no replay)") | |
| 112 | - | kill, ok := c.sends[0].(protocol.Kill) | |
| 113 | - | require.True(t, ok, "sent message %T is not a Kill", c.sends[0]) | |
| 114 | - | return kill | |
| 115 | - | } | |
| 116 | - | ||
| 117 | - | // sendAndAwait drives the shared send/receive pair once, as runKill and | |
| 118 | - | // requestDaemonStop do. | |
| 119 | - | func sendAndAwait(ctx context.Context, conn *fakeKillConnection, request protocol.Kill) error { | |
| 120 | - | requestID, err := sendKillRequest(ctx, conn, request) | |
| 121 | - | if err != nil { | |
| 122 | - | return err | |
| 123 | - | } | |
| 124 | - | return receiveKillResult(ctx, conn, requestID) | |
| 125 | - | } | |
| 126 | - | ||
| 127 | - | // matchingResult mirrors one Kill's RequestID into a successful result. | |
| 128 | - | func matchingResult(outcome protocol.KillOutcome, code uint16, text string) func(protocol.Kill) protocol.ServerMessage { | |
| 129 | - | return func(kill protocol.Kill) protocol.ServerMessage { | |
| 130 | - | return protocol.KillResult{RequestID: kill.RequestID, Outcome: outcome, Code: code, Text: text} | |
| 131 | - | } | |
| 132 | - | } | |
| 133 | - | ||
| 134 | - | func TestKillResultMatchingSuccessConsumesOneCorrelatedResult(t *testing.T) { | |
| 135 | - | conn := newFakeKillConnection() | |
| 136 | - | conn.replyForKill = matchingResult(protocol.KillSucceeded, 0, "") | |
| 137 | - | ||
| 138 | - | require.NoError(t, sendAndAwait(context.Background(), conn, protocol.Kill{Name: "work", Scope: protocol.KillSession})) | |
| 139 | - | ||
| 140 | - | kill := conn.sentKill(t) | |
| 141 | - | require.NotZero(t, kill.RequestID, "a Kill must carry a nonzero RequestID") | |
| 142 | - | require.Equal(t, "work", kill.Name) | |
| 143 | - | require.Equal(t, protocol.KillSession, kill.Scope) | |
| 144 | - | require.Equal(t, 1, conn.sentCount()) | |
| 145 | - | } | |
| 146 | - | ||
| 147 | - | func TestKillResultFailedDefiniteIsNotUnknown(t *testing.T) { | |
| 148 | - | tests := []struct { | |
| 149 | - | name string | |
| 150 | - | text string | |
| 151 | - | }{ | |
| 152 | - | {name: "no such session", text: "no such session: work"}, | |
| 153 | - | {name: "participants changed", text: "session changed during the operation; try again"}, | |
| 154 | - | } | |
| 155 | - | for _, tt := range tests { | |
| 156 | - | t.Run(tt.name, func(t *testing.T) { | |
| 157 | - | conn := newFakeKillConnection() | |
| 158 | - | conn.replyForKill = matchingResult(protocol.KillFailed, protocol.ErrInternal, tt.text) | |
| 159 | - | ||
| 160 | - | err := sendAndAwait(context.Background(), conn, protocol.Kill{Name: "work", Scope: protocol.KillSession}) | |
| 161 | - | require.Error(t, err) | |
| 162 | - | require.ErrorContains(t, err, tt.text) | |
| 163 | - | require.NotErrorIs(t, err, errKillOutcomeUnknown, "a definite failure must not be reported as unknown") | |
| 164 | - | require.Equal(t, 1, conn.sentCount(), "a definite failure must not be replayed") | |
| 165 | - | }) | |
| 166 | - | } | |
| 167 | - | } | |
| 168 | - | ||
| 169 | - | func TestKillResultUnknownOutcomeStaysUnknown(t *testing.T) { | |
| 170 | - | conn := newFakeKillConnection() | |
| 171 | - | conn.replyForKill = matchingResult(protocol.KillOutcomeUnknown, protocol.ErrServerShutdown, "daemon is shutting down") | |
| 172 | - | ||
| 173 | - | err := sendAndAwait(context.Background(), conn, protocol.Kill{Scope: protocol.KillAll}) | |
| 174 | - | require.ErrorIs(t, err, errKillOutcomeUnknown) | |
| 175 | - | require.Equal(t, 1, conn.sentCount(), "an unknown outcome must never be replayed") | |
| 176 | - | } | |
| 177 | - | ||
| 178 | - | func TestKillResultWrongTypeOrIDIsOutcomeUnknown(t *testing.T) { | |
| 179 | - | tests := []struct { | |
| 180 | - | name string | |
| 181 | - | reply protocol.ServerMessage | |
| 182 | - | }{ | |
| 183 | - | {name: "wrong type", reply: protocol.Pong{}}, | |
| 184 | - | {name: "wrong type error", reply: protocol.ErrorMsg{Code: protocol.ErrInternal, Text: "boom"}}, | |
| 185 | - | {name: "wrong id", reply: protocol.KillResult{RequestID: 99, Outcome: protocol.KillSucceeded}}, | |
| 186 | - | {name: "zero id", reply: protocol.KillResult{RequestID: 0, Outcome: protocol.KillSucceeded}}, | |
| 187 | - | } | |
| 188 | - | for _, tt := range tests { | |
| 189 | - | t.Run(tt.name, func(t *testing.T) { | |
| 190 | - | conn := newFakeKillConnection() | |
| 191 | - | conn.recv = []protocol.ServerMessage{tt.reply} | |
| 192 | - | ||
| 193 | - | err := sendAndAwait(context.Background(), conn, protocol.Kill{Scope: protocol.KillSession}) | |
| 194 | - | require.ErrorIs(t, err, errKillOutcomeUnknown) | |
| 195 | - | require.Equal(t, 1, conn.sentCount(), "an uncorrelated reply must not trigger a replay") | |
| 196 | - | }) | |
| 197 | - | } | |
| 198 | - | } | |
| 199 | - | ||
| 200 | - | func TestKillResultClosedBeforeReplyIsOutcomeUnknown(t *testing.T) { | |
| 201 | - | conn := newFakeKillConnection() | |
| 202 | - | conn.recvErr = io.EOF | |
| 203 | - | ||
| 204 | - | err := sendAndAwait(context.Background(), conn, protocol.Kill{Scope: protocol.KillSession}) | |
| 205 | - | require.ErrorIs(t, err, errKillOutcomeUnknown) | |
| 206 | - | require.Equal(t, 1, conn.sentCount(), "a close before the reply must never be read as success") | |
| 207 | - | } | |
| 208 | - | ||
| 209 | - | // TestKillResultCancelAfterSendIsOutcomeUnknownWithoutReplay proves a caller | |
| 210 | - | // canceled while awaiting the result closes the exact connection, reports an | |
| 211 | - | // unknown outcome (never EOF-as-success), and never resends the request. | |
| 212 | - | func TestKillResultCancelAfterSendIsOutcomeUnknownWithoutReplay(t *testing.T) { | |
| 213 | - | conn := newFakeKillConnection() | |
| 214 | - | conn.block = true | |
| 215 | - | ||
| 216 | - | ctx, cancel := context.WithCancel(context.Background()) | |
| 217 | - | defer cancel() | |
| 218 | - | ||
| 219 | - | done := make(chan error, 1) | |
| 220 | - | go func() { done <- sendAndAwait(ctx, conn, protocol.Kill{Name: "work", Scope: protocol.KillSession}) }() | |
| 221 | - | ||
| 222 | - | // Wait until the receive is actually parked, then cancel. No sleep: the | |
| 223 | - | // fake signals entry deterministically. | |
| 224 | - | <-conn.recvEntered | |
| 225 | - | cancel() | |
| 226 | - | ||
| 227 | - | require.ErrorIs(t, <-done, errKillOutcomeUnknown) | |
| 228 | - | require.Equal(t, 1, conn.sentCount(), "a canceled kill must never be replayed") | |
| 229 | - | ||
| 230 | - | select { | |
| 231 | - | case <-conn.closed: | |
| 232 | - | default: | |
| 233 | - | t.Fatal("cancellation after send must close the exact connection") | |
| 234 | - | } | |
| 235 | - | } | |
| 236 | - | ||
| 237 | - | // TestKillResultTimeoutAfterSendIsOutcomeUnknownWithoutReplay proves a caller | |
| 238 | - | // whose result deadline expires while awaiting the reply closes the exact | |
| 239 | - | // connection, reports an unknown outcome, and never resends the request. | |
| 240 | - | func TestKillResultTimeoutAfterSendIsOutcomeUnknownWithoutReplay(t *testing.T) { | |
| 241 | - | conn := newFakeKillConnection() | |
| 242 | - | conn.block = true | |
| 243 | - | ||
| 244 | - | ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) | |
| 245 | - | defer cancel() | |
| 246 | - | ||
| 247 | - | done := make(chan error, 1) | |
| 248 | - | go func() { done <- sendAndAwait(ctx, conn, protocol.Kill{Name: "work", Scope: protocol.KillSession}) }() | |
| 249 | - | ||
| 250 | - | <-conn.recvEntered | |
| 251 | - | require.ErrorIs(t, <-done, errKillOutcomeUnknown) | |
| 252 | - | require.Equal(t, 1, conn.sentCount(), "a timed-out kill must never be replayed") | |
| 253 | - | ||
| 254 | - | select { | |
| 255 | - | case <-conn.closed: | |
| 256 | - | default: | |
| 257 | - | t.Fatal("a result timeout must close the exact connection") | |
| 258 | - | } | |
| 259 | - | } | |
| 260 | - | ||
| 261 | - | // TestKillResultCancelBeforeSendIsDefiniteNotSent proves a caller canceled | |
| 262 | - | // before the request is sent reports a definite not-sent outcome and puts no | |
| 263 | - | // bytes on the wire. | |
| 264 | - | func TestKillResultCancelBeforeSendIsDefiniteNotSent(t *testing.T) { | |
| 265 | - | conn := newFakeKillConnection() | |
| 266 | - | ||
| 267 | - | ctx, cancel := context.WithCancel(context.Background()) | |
| 268 | - | cancel() | |
| 269 | - | ||
| 270 | - | err := sendAndAwait(ctx, conn, protocol.Kill{Name: "work", Scope: protocol.KillSession}) | |
| 271 | - | require.ErrorIs(t, err, errKillNotSent) | |
| 272 | - | require.NotErrorIs(t, err, errKillOutcomeUnknown, "a not-sent request has a definite outcome") | |
| 273 | - | require.Zero(t, conn.sentCount(), "a canceled-before-send kill must not be sent") | |
| 274 | - | } | |
| 275 | - | ||
| 276 | - | func TestKillRequestSendFailureIsReported(t *testing.T) { | |
| 277 | - | conn := newFakeKillConnection() | |
| 278 | - | conn.sendErr = errors.New("write failed") | |
| 279 | - | ||
| 280 | - | err := sendAndAwait(context.Background(), conn, protocol.Kill{Name: "work", Scope: protocol.KillSession}) | |
| 281 | - | require.ErrorContains(t, err, "write failed") | |
| 282 | - | require.Zero(t, conn.sentCount()) | |
| 283 | - | } | |
| 284 | - | ||
| 285 | - | // TestKillRequestIDsAreUniqueAndNonzero proves each request allocates a fresh, | |
| 286 | - | // nonzero correlation ID so a reply for one request can never match another. | |
| 287 | - | func TestKillRequestIDsAreUniqueAndNonzero(t *testing.T) { | |
| 288 | - | seen := make(map[uint64]struct{}) | |
| 289 | - | for range 64 { | |
| 290 | - | conn := newFakeKillConnection() | |
| 291 | - | conn.replyForKill = matchingResult(protocol.KillSucceeded, 0, "") | |
| 292 | - | require.NoError(t, sendAndAwait(context.Background(), conn, protocol.Kill{Scope: protocol.KillAll})) | |
| 293 | - | id := conn.sentKill(t).RequestID | |
| 294 | - | require.NotZero(t, id) | |
| 295 | - | _, duplicate := seen[id] | |
| 296 | - | require.False(t, duplicate, "duplicate RequestID %d", id) | |
| 297 | - | seen[id] = struct{}{} | |
| 298 | - | } | |
| 299 | - | } | |
| 300 | - | ||
| 301 | - | // countingLifecycleProbe records every lifecycle-acquire attempt and returns | |
| 302 | - | // the configured owner or error, so a test can prove both whether the transfer | |
| 303 | - | // wait was entered and how it resolved. | |
| 304 | - | type countingLifecycleProbe struct { | |
| 305 | - | attempts atomic.Int64 | |
| 306 | - | owner lifecycleOwnership | |
| 307 | - | err error | |
| 308 | - | } | |
| 309 | - | ||
| 310 | - | func (p *countingLifecycleProbe) TryAcquire(string) (lifecycleOwnership, error) { | |
| 311 | - | p.attempts.Add(1) | |
| 312 | - | return p.owner, p.err | |
| 313 | - | } | |
| 314 | - | ||
| 315 | - | // startServingOneKill binds ipc.SocketDir() under a temp runtime root, points | |
| 316 | - | // the lifecycle probe at a counting fake, and serves exactly one control | |
| 317 | - | // connection in the background. reply==nil closes the connection without | |
| 318 | - | // answering, which models a lost reply; otherwise the reply is correlated to the | |
| 319 | - | // exact request read from the wire. The returned channel closes once the | |
| 320 | - | // scripted server has finished. | |
| 321 | - | func startServingOneKill(t *testing.T, probe *countingLifecycleProbe, reply func(protocol.Kill) protocol.ServerMessage) <-chan struct{} { | |
| 322 | - | t.Helper() | |
| 323 | - | t.Setenv("XDG_RUNTIME_DIR", t.TempDir()) | |
| 324 | - | t.Setenv("XDG_STATE_HOME", t.TempDir()) | |
| 325 | - | ||
| 326 | - | // Bound the transfer wait so a regression that wrongly waits fails fast | |
| 327 | - | // instead of burning the production retry budget. | |
| 328 | - | originalBackoff := defaultBackoff | |
| 329 | - | t.Cleanup(func() { defaultBackoff = originalBackoff }) | |
| 330 | - | defaultBackoff = backoffConfig{initial: time.Millisecond, max: time.Millisecond, total: 5 * time.Millisecond} | |
| 331 | - | ||
| 332 | - | originalProbe := daemonLifecycleProbe | |
| 333 | - | t.Cleanup(func() { daemonLifecycleProbe = originalProbe }) | |
| 334 | - | daemonLifecycleProbe = probe | |
| 335 | - | ||
| 336 | - | listener, err := ipc.Listen(ipc.SocketDir()) | |
| 337 | - | require.NoError(t, err) | |
| 338 | - | t.Cleanup(func() { _ = listener.Close() }) | |
| 339 | - | ||
| 340 | - | done := make(chan struct{}) | |
| 341 | - | go func() { | |
| 342 | - | defer close(done) | |
| 343 | - | raw, err := listener.Accept() | |
| 344 | - | if err != nil { | |
| 345 | - | return | |
| 346 | - | } | |
| 347 | - | defer func() { _ = raw.Close() }() | |
| 348 | - | connection := sessionwire.NewServerConnection(raw) | |
| 349 | - | message, err := connection.ReceiveClient() | |
| 350 | - | if err != nil { | |
| 351 | - | return | |
| 352 | - | } | |
| 353 | - | kill, ok := message.(protocol.Kill) | |
| 354 | - | if !ok || reply == nil { | |
| 355 | - | return | |
| 356 | - | } | |
| 357 | - | _ = connection.SendServer(reply(kill)) | |
| 358 | - | }() | |
| 359 | - | return done | |
| 360 | - | } | |
| 361 | - | ||
| 362 | - | func awaitServingOneKill(t *testing.T, done <-chan struct{}) { | |
| 363 | - | t.Helper() | |
| 364 | - | select { | |
| 365 | - | case <-done: | |
| 366 | - | case <-time.After(2 * time.Second): | |
| 367 | - | t.Fatal("the scripted control server did not finish") | |
| 368 | - | } | |
| 369 | - | } | |
| 370 | - | ||
| 371 | - | // TestDaemonStopLostReplyConfirmedByLifecycleTransfer proves a daemon-stop whose | |
| 372 | - | // explicit result is lost is still reported as success once the caller directly | |
| 373 | - | // acquires lifecycle ownership: the authoritative transfer confirms the accepted | |
| 374 | - | // shutdown completed. | |
| 375 | - | func TestDaemonStopLostReplyConfirmedByLifecycleTransfer(t *testing.T) { | |
| 376 | - | probe := &countingLifecycleProbe{owner: fakeLifecycleOwnership{release: func() error { return nil }}} | |
| 377 | - | done := startServingOneKill(t, probe, nil) | |
| 378 | - | ||
| 379 | - | require.NoError(t, requestDaemonStop(context.Background())) | |
| 380 | - | awaitServingOneKill(t, done) | |
| 381 | - | require.EqualValues(t, 1, probe.attempts.Load(), "a lost reply must be resolved by exactly one ownership probe") | |
| 382 | - | } | |
| 383 | - | ||
| 384 | - | // TestDaemonStopLostReplyAndTransferFailurePreservesUnknownOutcome proves a | |
| 385 | - | // daemon-stop whose result is lost AND whose lifecycle confirmation fails keeps | |
| 386 | - | // errors.Is(err, errKillOutcomeUnknown) and carries both diagnostics, so the | |
| 387 | - | // caller never reads a lost reply as a definite success or a definite failure. | |
| 388 | - | func TestDaemonStopLostReplyAndTransferFailurePreservesUnknownOutcome(t *testing.T) { | |
| 389 | - | probe := &countingLifecycleProbe{err: errors.New("lifecycle still busy")} | |
| 390 | - | done := startServingOneKill(t, probe, nil) | |
| 391 | - | ||
| 392 | - | err := requestDaemonStop(context.Background()) | |
| 393 | - | require.ErrorIs(t, err, errKillOutcomeUnknown) | |
| 394 | - | require.ErrorContains(t, err, "awaiting explicit result", "the lost-reply diagnostic must survive") | |
| 395 | - | require.ErrorContains(t, err, "lifecycle confirmation also failed") | |
| 396 | - | awaitServingOneKill(t, done) | |
| 397 | - | } | |
| 398 | - | ||
| 399 | - | // TestDaemonStopDefiniteFailureReturnsWithoutLifecycleWait proves a definite | |
| 400 | - | // KillFailed daemon-stop reply is returned immediately and never enters the | |
| 401 | - | // lifecycle-transfer wait, so a rejected stop cannot be retried as if the | |
| 402 | - | // outcome were still unknown. | |
| 403 | - | func TestDaemonStopDefiniteFailureReturnsWithoutLifecycleWait(t *testing.T) { | |
| 404 | - | probe := &countingLifecycleProbe{err: errors.New("lifecycle must not be probed")} | |
| 405 | - | done := startServingOneKill(t, probe, func(kill protocol.Kill) protocol.ServerMessage { | |
| 406 | - | return protocol.KillResult{RequestID: kill.RequestID, Outcome: protocol.KillFailed, Code: protocol.ErrInternal, Text: "daemon refused stop"} | |
| 407 | - | }) | |
| 408 | - | ||
| 409 | - | err := requestDaemonStop(context.Background()) | |
| 410 | - | require.Error(t, err) | |
| 411 | - | require.ErrorContains(t, err, "daemon refused stop") | |
| 412 | - | require.NotErrorIs(t, err, errKillOutcomeUnknown, "a definite failure must not be reported as unknown") | |
| 413 | - | require.Zero(t, probe.attempts.Load(), "a definite failure must return without waiting for ownership transfer") | |
| 414 | - | awaitServingOneKill(t, done) | |
| 415 | - | } | |
| 416 | - | ||
| 417 | - | // TestDaemonStopUnknownResultKeepsUnknownWhenOwnershipReleaseFails pins the | |
| 418 | - | // ownership-release branch: even when the probe grants ownership, a release | |
| 419 | - | // failure keeps the lost-reply diagnostic instead of discarding it. | |
| 420 | - | func TestDaemonStopUnknownResultKeepsUnknownWhenOwnershipReleaseFails(t *testing.T) { | |
| 421 | - | probe := &countingLifecycleProbe{owner: fakeLifecycleOwnership{release: func() error { return errors.New("unlock failed") }}} | |
| 422 | - | done := startServingOneKill(t, probe, nil) | |
| 423 | - | ||
| 424 | - | err := requestDaemonStop(context.Background()) | |
| 425 | - | require.ErrorIs(t, err, errKillOutcomeUnknown) | |
| 426 | - | require.ErrorContains(t, err, "confirmed ownership release failed") | |
| 427 | - | require.ErrorContains(t, err, "unlock failed") | |
| 428 | - | awaitServingOneKill(t, done) | |
| 429 | - | } | |
D internal/app/launch_allowlist.go +0 -158
| @@ -1,158 +0,0 @@ | |||
| 1 | - | package app | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "context" | |
| 5 | - | "errors" | |
| 6 | - | "os" | |
| 7 | - | "strings" | |
| 8 | - | ||
| 9 | - | "github.com/bnema/vev/internal/domain" | |
| 10 | - | "github.com/bnema/vev/internal/ports" | |
| 11 | - | "github.com/bnema/vev/internal/protocol" | |
| 12 | - | "github.com/bnema/vev/internal/protocol/catalogue" | |
| 13 | - | ) | |
| 14 | - | ||
| 15 | - | func remoteLaunchAllowlistFromEnv() (map[string]struct{}, bool, error) { | |
| 16 | - | raw, configured := os.LookupEnv(launchAllowedRemoteEndpointsEnv) | |
| 17 | - | if !configured { | |
| 18 | - | return nil, false, nil | |
| 19 | - | } | |
| 20 | - | allowed := make(map[string]struct{}) | |
| 21 | - | for _, target := range strings.Split(raw, "\n") { | |
| 22 | - | if target == "" { | |
| 23 | - | continue | |
| 24 | - | } | |
| 25 | - | if err := domain.ValidateRemoteHostTarget(target); err != nil { | |
| 26 | - | return nil, true, errors.New("vev: invalid configured remote endpoint allowlist") | |
| 27 | - | } | |
| 28 | - | allowed[target] = struct{}{} | |
| 29 | - | } | |
| 30 | - | return allowed, true, nil | |
| 31 | - | } | |
| 32 | - | ||
| 33 | - | func allowlistedRemoteEndpoint(allowed map[string]struct{}, target string) bool { | |
| 34 | - | _, ok := allowed[target] | |
| 35 | - | return ok | |
| 36 | - | } | |
| 37 | - | ||
| 38 | - | type allowlistedRemoteHostStore struct { | |
| 39 | - | delegate ports.RemoteHostStore | |
| 40 | - | allowed map[string]struct{} | |
| 41 | - | } | |
| 42 | - | ||
| 43 | - | func (s allowlistedRemoteHostStore) Hosts() (pinned, learned []domain.RemoteRegistration, err error) { | |
| 44 | - | pinned, learned, err = s.delegate.Hosts() | |
| 45 | - | return filterRemoteRegistrations(pinned, s.allowed), filterRemoteRegistrations(learned, s.allowed), err | |
| 46 | - | } | |
| 47 | - | ||
| 48 | - | func (s allowlistedRemoteHostStore) AddPinned(target string) error { | |
| 49 | - | if !allowlistedRemoteEndpoint(s.allowed, target) { | |
| 50 | - | return endpointNotConfiguredError() | |
| 51 | - | } | |
| 52 | - | return s.delegate.AddPinned(target) | |
| 53 | - | } | |
| 54 | - | ||
| 55 | - | func (s allowlistedRemoteHostStore) RemovePinned(target string) error { | |
| 56 | - | if !allowlistedRemoteEndpoint(s.allowed, target) { | |
| 57 | - | return endpointNotConfiguredError() | |
| 58 | - | } | |
| 59 | - | return s.delegate.RemovePinned(target) | |
| 60 | - | } | |
| 61 | - | ||
| 62 | - | func (s allowlistedRemoteHostStore) Remember(target string) error { | |
| 63 | - | if !allowlistedRemoteEndpoint(s.allowed, target) { | |
| 64 | - | return endpointNotConfiguredError() | |
| 65 | - | } | |
| 66 | - | return s.delegate.Remember(target) | |
| 67 | - | } | |
| 68 | - | ||
| 69 | - | func (s allowlistedRemoteHostStore) Forget(target string) error { | |
| 70 | - | if !allowlistedRemoteEndpoint(s.allowed, target) { | |
| 71 | - | return endpointNotConfiguredError() | |
| 72 | - | } | |
| 73 | - | return s.delegate.Forget(target) | |
| 74 | - | } | |
| 75 | - | ||
| 76 | - | func (s allowlistedRemoteHostStore) Remove(target string) (bool, error) { | |
| 77 | - | if !allowlistedRemoteEndpoint(s.allowed, target) { | |
| 78 | - | return false, endpointNotConfiguredError() | |
| 79 | - | } | |
| 80 | - | return s.delegate.Remove(target) | |
| 81 | - | } | |
| 82 | - | ||
| 83 | - | type allowlistedRemoteCatalogClient struct { | |
| 84 | - | delegate ports.RemoteCatalogClient | |
| 85 | - | allowed map[string]struct{} | |
| 86 | - | } | |
| 87 | - | ||
| 88 | - | func (c allowlistedRemoteCatalogClient) List(ctx context.Context, target string) (catalogue.RemoteCatalog, error) { | |
| 89 | - | if !allowlistedRemoteEndpoint(c.allowed, target) { | |
| 90 | - | return catalogue.RemoteCatalog{}, endpointNotConfiguredError() | |
| 91 | - | } | |
| 92 | - | return c.delegate.List(ctx, target) | |
| 93 | - | } | |
| 94 | - | ||
| 95 | - | type allowlistedRemotePreviewClient struct { | |
| 96 | - | delegate ports.RemotePreviewClient | |
| 97 | - | allowed map[string]struct{} | |
| 98 | - | } | |
| 99 | - | ||
| 100 | - | func (c allowlistedRemotePreviewClient) Preview(ctx context.Context, target domain.RemoteSessionTarget, width, height uint16) (protocol.RemotePreview, error) { | |
| 101 | - | if !allowlistedRemoteEndpoint(c.allowed, target.Endpoint) { | |
| 102 | - | return protocol.RemotePreview{}, endpointNotConfiguredError() | |
| 103 | - | } | |
| 104 | - | return c.delegate.Preview(ctx, target, width, height) | |
| 105 | - | } | |
| 106 | - | ||
| 107 | - | type allowlistedRemoteCatalogCache struct { | |
| 108 | - | delegate ports.RemoteCatalogCache | |
| 109 | - | allowed map[string]struct{} | |
| 110 | - | } | |
| 111 | - | ||
| 112 | - | func (c allowlistedRemoteCatalogCache) Load() ([]catalogue.RemoteCatalogCacheEntry, error) { | |
| 113 | - | entries, err := c.delegate.Load() | |
| 114 | - | if err != nil { | |
| 115 | - | return nil, err | |
| 116 | - | } | |
| 117 | - | filtered := make([]catalogue.RemoteCatalogCacheEntry, 0, len(entries)) | |
| 118 | - | for _, entry := range entries { | |
| 119 | - | if allowlistedRemoteEndpoint(c.allowed, entry.Host) { | |
| 120 | - | filtered = append(filtered, entry) | |
| 121 | - | } | |
| 122 | - | } | |
| 123 | - | return filtered, nil | |
| 124 | - | } | |
| 125 | - | ||
| 126 | - | func (c allowlistedRemoteCatalogCache) Store(entries []catalogue.RemoteCatalogCacheEntry) error { | |
| 127 | - | filtered := make([]catalogue.RemoteCatalogCacheEntry, 0, len(entries)) | |
| 128 | - | for _, entry := range entries { | |
| 129 | - | if allowlistedRemoteEndpoint(c.allowed, entry.Host) { | |
| 130 | - | filtered = append(filtered, entry) | |
| 131 | - | } | |
| 132 | - | } | |
| 133 | - | return c.delegate.Store(filtered) | |
| 134 | - | } | |
| 135 | - | ||
| 136 | - | func filterRemoteTargets(targets []string, allowed map[string]struct{}) []string { | |
| 137 | - | filtered := make([]string, 0, len(targets)) | |
| 138 | - | for _, target := range targets { | |
| 139 | - | if allowlistedRemoteEndpoint(allowed, target) { | |
| 140 | - | filtered = append(filtered, target) | |
| 141 | - | } | |
| 142 | - | } | |
| 143 | - | return filtered | |
| 144 | - | } | |
| 145 | - | ||
| 146 | - | func filterRemoteRegistrations(registrations []domain.RemoteRegistration, allowed map[string]struct{}) []domain.RemoteRegistration { | |
| 147 | - | filtered := make([]domain.RemoteRegistration, 0, len(registrations)) | |
| 148 | - | for _, record := range registrations { | |
| 149 | - | if allowlistedRemoteEndpoint(allowed, record.Endpoint) { | |
| 150 | - | filtered = append(filtered, record) | |
| 151 | - | } | |
| 152 | - | } | |
| 153 | - | return filtered | |
| 154 | - | } | |
| 155 | - | ||
| 156 | - | func endpointNotConfiguredError() error { | |
| 157 | - | return &ports.UIError{Code: ports.UIErrEndpointNotConfigured} | |
| 158 | - | } | |
D internal/app/missing_session_preflight_test.go +0 -446
| @@ -1,446 +0,0 @@ | |||
| 1 | - | package app | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "context" | |
| 5 | - | "errors" | |
| 6 | - | "io" | |
| 7 | - | "os" | |
| 8 | - | "strings" | |
| 9 | - | "syscall" | |
| 10 | - | "testing" | |
| 11 | - | "time" | |
| 12 | - | ||
| 13 | - | "github.com/stretchr/testify/mock" | |
| 14 | - | "github.com/stretchr/testify/require" | |
| 15 | - | ||
| 16 | - | "github.com/bnema/vev/internal/adapters/term" | |
| 17 | - | "github.com/bnema/vev/internal/ports" | |
| 18 | - | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 19 | - | "github.com/bnema/vev/internal/protocol" | |
| 20 | - | "github.com/bnema/vev/internal/protocol/wire" | |
| 21 | - | wiremocks "github.com/bnema/vev/internal/protocol/wire/mocks" | |
| 22 | - | "github.com/bnema/vev/internal/usecase/client" | |
| 23 | - | "github.com/bnema/vev/pkg/rawterm" | |
| 24 | - | ) | |
| 25 | - | ||
| 26 | - | // sessionListDialer serves a canned session listing over generated mocks. The | |
| 27 | - | // typed client connection runs the preamble lazily, so the transport answers | |
| 28 | - | // the preamble request before the listing exchange. | |
| 29 | - | func sessionListDialer(t *testing.T, sessions []protocol.SessionInfo) func() wire.Dialer { | |
| 30 | - | t.Helper() | |
| 31 | - | transport := wiremocks.NewMockTransport(t) | |
| 32 | - | transport.EXPECT().Send(mock.Anything).Return(nil).Twice() | |
| 33 | - | transport.EXPECT().Recv().Return(mustPreambleResponse(), nil).Once() | |
| 34 | - | transport.EXPECT().Recv().Return(mustServerEnvelope(protocol.Sessions{Sessions: sessions}), nil).Once() | |
| 35 | - | transport.EXPECT().Close().Return(nil) | |
| 36 | - | dialer := wiremocks.NewMockDialer(t) | |
| 37 | - | dialer.EXPECT().Dial(mock.Anything).Return(transport, nil).Once() | |
| 38 | - | return func() wire.Dialer { return dialer } | |
| 39 | - | } | |
| 40 | - | ||
| 41 | - | // failingSessionListDialer fails the listing dial so the preflight falls | |
| 42 | - | // back to a plain attach. | |
| 43 | - | func failingSessionListDialer(t *testing.T, dialErr error) func() wire.Dialer { | |
| 44 | - | t.Helper() | |
| 45 | - | dialer := wiremocks.NewMockDialer(t) | |
| 46 | - | dialer.EXPECT().Dial(mock.Anything).Return(nil, dialErr).Maybe() | |
| 47 | - | return func() wire.Dialer { return dialer } | |
| 48 | - | } | |
| 49 | - | ||
| 50 | - | // terminalStub serves canned prompt I/O through a generated terminal mock. | |
| 51 | - | func terminalStub(t *testing.T, in io.Reader, out io.Writer) func() ports.Terminal { | |
| 52 | - | t.Helper() | |
| 53 | - | terminal := portsmocks.NewMockTerminal(t) | |
| 54 | - | terminal.EXPECT().In().Return(in).Maybe() | |
| 55 | - | terminal.EXPECT().Out().Return(out).Maybe() | |
| 56 | - | terminal.EXPECT().Flush().Return(nil).Maybe() | |
| 57 | - | return func() ports.Terminal { return terminal } | |
| 58 | - | } | |
| 59 | - | ||
| 60 | - | // interactiveProbe injects the console decision, standing in for the | |
| 61 | - | // production probe so the prompt matrix runs without owning a real TTY. | |
| 62 | - | func interactiveProbe(interactive bool) func(ports.Terminal) bool { | |
| 63 | - | return func(ports.Terminal) bool { return interactive } | |
| 64 | - | } | |
| 65 | - | ||
| 66 | - | func TestRunAttachWithDepsMissingSessionCreatePrompt(t *testing.T) { | |
| 67 | - | tests := []struct { | |
| 68 | - | name string | |
| 69 | - | sessions []protocol.SessionInfo | |
| 70 | - | answer string | |
| 71 | - | notInteractive bool | |
| 72 | - | wantIntent uint8 | |
| 73 | - | wantPromptPart string | |
| 74 | - | wantNoPrompt bool | |
| 75 | - | }{ | |
| 76 | - | {name: "missing confirm creates", sessions: nil, answer: "y\n", wantIntent: protocol.IntentNew, wantPromptPart: `vev: session "scratch" doesn't exist, want to create and attach to it? [y/N]`}, | |
| 77 | - | {name: "missing decline attaches", sessions: nil, answer: "n\n", wantIntent: protocol.IntentAttach, wantPromptPart: "scratch"}, | |
| 78 | - | {name: "missing empty answer attaches", sessions: nil, answer: "\n", wantIntent: protocol.IntentAttach}, | |
| 79 | - | {name: "missing unknown answer attaches", sessions: nil, answer: "later\n", wantIntent: protocol.IntentAttach, wantPromptPart: "[y/N]"}, | |
| 80 | - | {name: "present attaches without prompt", sessions: []protocol.SessionInfo{{Name: "scratch"}}, answer: "y\n", wantIntent: protocol.IntentAttach, wantNoPrompt: true}, | |
| 81 | - | {name: "non-interactive console attaches without prompt", sessions: nil, answer: "y\n", notInteractive: true, wantIntent: protocol.IntentAttach, wantNoPrompt: true}, | |
| 82 | - | } | |
| 83 | - | for _, tt := range tests { | |
| 84 | - | t.Run(tt.name, func(t *testing.T) { | |
| 85 | - | var intents []uint8 | |
| 86 | - | var promptOut strings.Builder | |
| 87 | - | err := runAttachWithDeps(context.Background(), protocol.IntentAttach, "scratch", "", "", nil, runAttachDeps{ | |
| 88 | - | localDialer: sessionListDialer(t, tt.sessions), | |
| 89 | - | terminal: terminalStub(t, strings.NewReader(tt.answer), &promptOut), | |
| 90 | - | interactiveConsole: interactiveProbe(!tt.notInteractive), | |
| 91 | - | runClient: func(_ context.Context, _ client.Dependencies, request client.AttachRequest) error { | |
| 92 | - | intents = append(intents, request.Intent) | |
| 93 | - | return nil | |
| 94 | - | }, | |
| 95 | - | }) | |
| 96 | - | require.NoError(t, err) | |
| 97 | - | require.Equal(t, []uint8{tt.wantIntent}, intents, "preflight must select the attach intent before any client attempt") | |
| 98 | - | if tt.wantPromptPart != "" { | |
| 99 | - | require.Contains(t, promptOut.String(), tt.wantPromptPart) | |
| 100 | - | } | |
| 101 | - | if tt.wantNoPrompt { | |
| 102 | - | require.Empty(t, promptOut.String()) | |
| 103 | - | } | |
| 104 | - | }) | |
| 105 | - | } | |
| 106 | - | } | |
| 107 | - | ||
| 108 | - | // TestTerminalIsInteractive covers the production probe on both sides of the | |
| 109 | - | // console check: a real terminal file and the streams that are not consoles. | |
| 110 | - | func TestTerminalIsInteractive(t *testing.T) { | |
| 111 | - | require.False(t, terminalIsInteractive(nil), "a missing terminal cannot prompt") | |
| 112 | - | pty := openPtySlave(t) | |
| 113 | - | require.True(t, terminalIsInteractive(term.NewWithFiles(pty, pty)), "a real terminal file is a console") | |
| 114 | - | ||
| 115 | - | console, writer, err := os.Pipe() | |
| 116 | - | require.NoError(t, err) | |
| 117 | - | t.Cleanup(func() { | |
| 118 | - | _ = console.Close() | |
| 119 | - | _ = writer.Close() | |
| 120 | - | }) | |
| 121 | - | ||
| 122 | - | for _, tt := range []struct { | |
| 123 | - | name string | |
| 124 | - | in io.Reader | |
| 125 | - | }{ | |
| 126 | - | {name: "detached reader", in: strings.NewReader("y\n")}, | |
| 127 | - | {name: "non-terminal file", in: console}, | |
| 128 | - | } { | |
| 129 | - | t.Run(tt.name, func(t *testing.T) { | |
| 130 | - | require.False(t, terminalIsInteractive(terminalStub(t, tt.in, io.Discard)())) | |
| 131 | - | }) | |
| 132 | - | } | |
| 133 | - | } | |
| 134 | - | ||
| 135 | - | // openPtySlave returns the slave side of a real PTY pair: the only way to | |
| 136 | - | // hand the probe an *os.File the kernel reports as a terminal. | |
| 137 | - | func openPtySlave(t *testing.T) *os.File { | |
| 138 | - | t.Helper() | |
| 139 | - | master, err := os.OpenFile("/dev/ptmx", os.O_RDWR|syscall.O_NOCTTY|syscall.O_CLOEXEC, 0) | |
| 140 | - | if err != nil { | |
| 141 | - | t.Skipf("open /dev/ptmx: %v", err) | |
| 142 | - | } | |
| 143 | - | t.Cleanup(func() { _ = master.Close() }) | |
| 144 | - | slave, err := rawterm.PreparePty(int(master.Fd())) | |
| 145 | - | require.NoError(t, err, "prepare pty") | |
| 146 | - | t.Cleanup(func() { _ = slave.Close() }) | |
| 147 | - | return slave | |
| 148 | - | } | |
| 149 | - | ||
| 150 | - | // TestRunAttachWithDepsMissingSessionPromptFlushesQuestion drives the real | |
| 151 | - | // terminal adapter, whose output is buffered: the preflight has to flush the | |
| 152 | - | // question before it blocks on the answer, or the user waits in front of a | |
| 153 | - | // blank console and the question only shows up after the daemon replies. The | |
| 154 | - | // observed wrapper used by `--ui-observe` embeds the port, so it has to keep | |
| 155 | - | // flushing end to end. | |
| 156 | - | func TestRunAttachWithDepsMissingSessionPromptFlushesQuestion(t *testing.T) { | |
| 157 | - | for _, tt := range []struct { | |
| 158 | - | name string | |
| 159 | - | wrap func(ports.Terminal) ports.Terminal | |
| 160 | - | }{ | |
| 161 | - | {name: "direct terminal", wrap: func(terminal ports.Terminal) ports.Terminal { return terminal }}, | |
| 162 | - | {name: "observed terminal", wrap: func(terminal ports.Terminal) ports.Terminal { | |
| 163 | - | return observedTerminal{Terminal: terminal} | |
| 164 | - | }}, | |
| 165 | - | } { | |
| 166 | - | t.Run(tt.name, func(t *testing.T) { | |
| 167 | - | consoleIn, answers, err := os.Pipe() | |
| 168 | - | require.NoError(t, err) | |
| 169 | - | questions, consoleOut, err := os.Pipe() | |
| 170 | - | require.NoError(t, err) | |
| 171 | - | t.Cleanup(func() { | |
| 172 | - | _ = consoleIn.Close() | |
| 173 | - | _ = answers.Close() | |
| 174 | - | _ = questions.Close() | |
| 175 | - | _ = consoleOut.Close() | |
| 176 | - | }) | |
| 177 | - | ||
| 178 | - | terminal := tt.wrap(term.NewWithFiles(consoleIn, consoleOut)) | |
| 179 | - | var intents []uint8 | |
| 180 | - | done := make(chan error, 1) | |
| 181 | - | go func() { | |
| 182 | - | done <- runAttachWithDeps(context.Background(), protocol.IntentAttach, "scratch", "", "", nil, runAttachDeps{ | |
| 183 | - | localDialer: sessionListDialer(t, nil), | |
| 184 | - | terminal: func() ports.Terminal { return terminal }, | |
| 185 | - | interactiveConsole: interactiveProbe(true), | |
| 186 | - | runClient: func(_ context.Context, _ client.Dependencies, request client.AttachRequest) error { | |
| 187 | - | intents = append(intents, request.Intent) | |
| 188 | - | return nil | |
| 189 | - | }, | |
| 190 | - | }) | |
| 191 | - | }() | |
| 192 | - | ||
| 193 | - | question := readPrompt(t, questions) | |
| 194 | - | require.Contains(t, question, "want to create and attach to it? [y/N] ", "the question must reach the console before the preflight reads an answer") | |
| 195 | - | ||
| 196 | - | _, err = answers.WriteString("y\n") | |
| 197 | - | require.NoError(t, err) | |
| 198 | - | select { | |
| 199 | - | case err := <-done: | |
| 200 | - | require.NoError(t, err) | |
| 201 | - | case <-time.After(5 * time.Second): | |
| 202 | - | t.Fatal("the preflight did not release after the answer") | |
| 203 | - | } | |
| 204 | - | require.Equal(t, []uint8{protocol.IntentNew}, intents) | |
| 205 | - | }) | |
| 206 | - | } | |
| 207 | - | } | |
| 208 | - | ||
| 209 | - | // readPrompt returns the console output that ends the create question, | |
| 210 | - | // failing the test instead of hanging when nothing arrives. | |
| 211 | - | func readPrompt(t *testing.T, console *os.File) string { | |
| 212 | - | t.Helper() | |
| 213 | - | require.NoError(t, console.SetReadDeadline(time.Now().Add(5*time.Second))) | |
| 214 | - | var output []byte | |
| 215 | - | frame := make([]byte, 64) | |
| 216 | - | for { | |
| 217 | - | read, err := console.Read(frame) | |
| 218 | - | output = append(output, frame[:read]...) | |
| 219 | - | if strings.Contains(string(output), "[y/N] ") { | |
| 220 | - | return string(output) | |
| 221 | - | } | |
| 222 | - | if err != nil { | |
| 223 | - | t.Fatalf("preflight prompt never reached the console (read %q: %v)", output, err) | |
| 224 | - | } | |
| 225 | - | } | |
| 226 | - | } | |
| 227 | - | ||
| 228 | - | // TestRunAttachWithDepsMissingSessionPromptFailureAttaches covers a console | |
| 229 | - | // that cannot show the question: the preflight must keep the plain attach | |
| 230 | - | // path instead of consuming an answer nobody was asked for. The input blocks | |
| 231 | - | // forever, so a prompt that reads it would only return on context expiry. | |
| 232 | - | func TestRunAttachWithDepsMissingSessionPromptFailureAttaches(t *testing.T) { | |
| 233 | - | closedOut, err := os.CreateTemp(t.TempDir(), "vev-console") | |
| 234 | - | require.NoError(t, err) | |
| 235 | - | require.NoError(t, closedOut.Close()) | |
| 236 | - | ||
| 237 | - | for _, tt := range []struct { | |
| 238 | - | name string | |
| 239 | - | out io.Writer | |
| 240 | - | flushErr error | |
| 241 | - | }{ | |
| 242 | - | {name: "write fails", out: closedOut}, | |
| 243 | - | {name: "flush fails", out: io.Discard, flushErr: errors.New("console gone")}, | |
| 244 | - | } { | |
| 245 | - | t.Run(tt.name, func(t *testing.T) { | |
| 246 | - | consoleIn, consoleOut, err := os.Pipe() | |
| 247 | - | require.NoError(t, err) | |
| 248 | - | t.Cleanup(func() { | |
| 249 | - | _ = consoleIn.Close() | |
| 250 | - | _ = consoleOut.Close() | |
| 251 | - | }) | |
| 252 | - | terminal := portsmocks.NewMockTerminal(t) | |
| 253 | - | terminal.EXPECT().In().Return(consoleIn).Maybe() | |
| 254 | - | terminal.EXPECT().Out().Return(tt.out).Maybe() | |
| 255 | - | terminal.EXPECT().Flush().Return(tt.flushErr).Maybe() | |
| 256 | - | ||
| 257 | - | ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) | |
| 258 | - | defer cancel() | |
| 259 | - | var intents []uint8 | |
| 260 | - | err = runAttachWithDeps(ctx, protocol.IntentAttach, "scratch", "", "", nil, runAttachDeps{ | |
| 261 | - | localDialer: sessionListDialer(t, nil), | |
| 262 | - | terminal: func() ports.Terminal { return terminal }, | |
| 263 | - | interactiveConsole: interactiveProbe(true), | |
| 264 | - | runClient: func(_ context.Context, _ client.Dependencies, request client.AttachRequest) error { | |
| 265 | - | intents = append(intents, request.Intent) | |
| 266 | - | return nil | |
| 267 | - | }, | |
| 268 | - | }) | |
| 269 | - | require.NoError(t, err, "an unusable console must not fail the attach") | |
| 270 | - | require.Equal(t, []uint8{protocol.IntentAttach}, intents) | |
| 271 | - | }) | |
| 272 | - | } | |
| 273 | - | } | |
| 274 | - | ||
| 275 | - | func TestRunAttachWithDepsMissingSessionPreflightSkipsNonAttach(t *testing.T) { | |
| 276 | - | for _, tt := range []struct { | |
| 277 | - | name string | |
| 278 | - | intent uint8 | |
| 279 | - | remote string | |
| 280 | - | }{ | |
| 281 | - | {name: "create", intent: protocol.IntentNew}, | |
| 282 | - | {name: "remote", intent: protocol.IntentAttach, remote: "remote.example"}, | |
| 283 | - | {name: "ephemeral", intent: protocol.IntentEphemeral}, | |
| 284 | - | } { | |
| 285 | - | t.Run(tt.name, func(t *testing.T) { | |
| 286 | - | var intents []uint8 | |
| 287 | - | var promptOut strings.Builder | |
| 288 | - | session := "" | |
| 289 | - | if tt.intent != protocol.IntentEphemeral { | |
| 290 | - | session = "scratch" | |
| 291 | - | } | |
| 292 | - | err := runAttachWithDeps(context.Background(), tt.intent, session, tt.remote, "", nil, runAttachDeps{ | |
| 293 | - | terminal: terminalStub(t, strings.NewReader("y\n"), &promptOut), | |
| 294 | - | interactiveConsole: interactiveProbe(true), | |
| 295 | - | runClient: func(_ context.Context, deps client.Dependencies, request client.AttachRequest) error { | |
| 296 | - | intents = append(intents, request.Intent) | |
| 297 | - | if tt.remote != "" { | |
| 298 | - | require.True(t, deps.Remote) | |
| 299 | - | } | |
| 300 | - | return nil | |
| 301 | - | }, | |
| 302 | - | }) | |
| 303 | - | require.NoError(t, err) | |
| 304 | - | require.Equal(t, []uint8{tt.intent}, intents) | |
| 305 | - | require.Empty(t, promptOut.String(), "preflight must not run outside direct local attach") | |
| 306 | - | }) | |
| 307 | - | } | |
| 308 | - | } | |
| 309 | - | ||
| 310 | - | func TestRunAttachWithDepsMissingSessionPreflightUnavailableAttaches(t *testing.T) { | |
| 311 | - | for _, tt := range []struct { | |
| 312 | - | name string | |
| 313 | - | dialErr error | |
| 314 | - | cancel bool | |
| 315 | - | }{ | |
| 316 | - | {name: "dial fails", dialErr: errors.New("socket unreachable")}, | |
| 317 | - | {name: "context cancelled", dialErr: context.Canceled, cancel: true}, | |
| 318 | - | } { | |
| 319 | - | t.Run(tt.name, func(t *testing.T) { | |
| 320 | - | stubLifecycleUnavailable(t) | |
| 321 | - | ctx := context.Background() | |
| 322 | - | if tt.cancel { | |
| 323 | - | var cancel context.CancelFunc | |
| 324 | - | ctx, cancel = context.WithCancel(ctx) | |
| 325 | - | cancel() | |
| 326 | - | } | |
| 327 | - | var promptOut strings.Builder | |
| 328 | - | var intents []uint8 | |
| 329 | - | err := runAttachWithDeps(ctx, protocol.IntentAttach, "scratch", "", "", nil, runAttachDeps{ | |
| 330 | - | localDialer: failingSessionListDialer(t, tt.dialErr), | |
| 331 | - | terminal: terminalStub(t, strings.NewReader("y\n"), &promptOut), | |
| 332 | - | interactiveConsole: interactiveProbe(true), | |
| 333 | - | runClient: func(_ context.Context, _ client.Dependencies, request client.AttachRequest) error { | |
| 334 | - | intents = append(intents, request.Intent) | |
| 335 | - | return nil | |
| 336 | - | }, | |
| 337 | - | }) | |
| 338 | - | require.NoError(t, err) | |
| 339 | - | require.Equal(t, []uint8{protocol.IntentAttach}, intents) | |
| 340 | - | require.Empty(t, promptOut.String(), "an undeterminable preflight must not prompt; the daemon rejection decides") | |
| 341 | - | }) | |
| 342 | - | } | |
| 343 | - | } | |
| 344 | - | ||
| 345 | - | // stubLifecycleUnavailable fails lifecycle acquisition so the preflight | |
| 346 | - | // cannot fall back to the on-disk catalogue, independent of the machine's | |
| 347 | - | // real daemon state. | |
| 348 | - | func stubLifecycleUnavailable(t *testing.T) { | |
| 349 | - | t.Helper() | |
| 350 | - | originalProbe := daemonLifecycleProbe | |
| 351 | - | t.Cleanup(func() { daemonLifecycleProbe = originalProbe }) | |
| 352 | - | daemonLifecycleProbe = fakeLifecycleProbe{err: errors.New("lifecycle unavailable")} | |
| 353 | - | } | |
| 354 | - | ||
| 355 | - | // stalledListTransport parks the list exchange inside one operation until | |
| 356 | - | // closed, letting tests exercise the preflight bound mid-flight. entered | |
| 357 | - | // fires when the exchange reaches the parked operation. | |
| 358 | - | func stalledListTransport(t *testing.T, blockOnSend bool) (*wiremocks.MockTransport, chan struct{}, chan struct{}) { | |
| 359 | - | t.Helper() | |
| 360 | - | entered := make(chan struct{}) | |
| 361 | - | closed := make(chan struct{}) | |
| 362 | - | transport := wiremocks.NewMockTransport(t) | |
| 363 | - | block := func() { | |
| 364 | - | close(entered) | |
| 365 | - | <-closed | |
| 366 | - | } | |
| 367 | - | if blockOnSend { | |
| 368 | - | transport.EXPECT().Send(mock.Anything).Run(func(wire.Envelope) { block() }).Return(errors.New("transport closed")).Once() | |
| 369 | - | } else { | |
| 370 | - | transport.EXPECT().Send(mock.Anything).Return(nil).Once() | |
| 371 | - | transport.EXPECT().Recv().Run(func() { block() }).Return(wire.Envelope{}, errors.New("transport closed")).Once() | |
| 372 | - | } | |
| 373 | - | transport.EXPECT().Close().Run(func() { | |
| 374 | - | select { | |
| 375 | - | case <-closed: | |
| 376 | - | default: | |
| 377 | - | close(closed) | |
| 378 | - | } | |
| 379 | - | }).Return(nil) | |
| 380 | - | return transport, entered, closed | |
| 381 | - | } | |
| 382 | - | ||
| 383 | - | func TestListSessionsTimesOutOnSilentDaemon(t *testing.T) { | |
| 384 | - | oldTimeout := preflightListTimeout | |
| 385 | - | preflightListTimeout = 50 * time.Millisecond | |
| 386 | - | defer func() { preflightListTimeout = oldTimeout }() | |
| 387 | - | ||
| 388 | - | transport, _, closed := stalledListTransport(t, false) | |
| 389 | - | started := time.Now() | |
| 390 | - | _, err := listSessionsWithDialer(context.Background(), func(context.Context) (wire.Transport, error) { | |
| 391 | - | return transport, nil | |
| 392 | - | }) | |
| 393 | - | elapsed := time.Since(started) | |
| 394 | - | require.Error(t, err) | |
| 395 | - | require.Less(t, elapsed, 5*time.Second, "a silent daemon must not stall the preflight past its bound") | |
| 396 | - | select { | |
| 397 | - | case <-closed: | |
| 398 | - | default: | |
| 399 | - | t.Fatal("preflight timeout must close the exchange transport") | |
| 400 | - | } | |
| 401 | - | } | |
| 402 | - | ||
| 403 | - | func TestListSessionsHonorsCancellation(t *testing.T) { | |
| 404 | - | for _, tt := range []struct { | |
| 405 | - | name string | |
| 406 | - | blockOnSend bool | |
| 407 | - | }{ | |
| 408 | - | {name: "blocked send", blockOnSend: true}, | |
| 409 | - | {name: "blocked recv", blockOnSend: false}, | |
| 410 | - | } { | |
| 411 | - | t.Run(tt.name, func(t *testing.T) { | |
| 412 | - | transport, entered, closed := stalledListTransport(t, tt.blockOnSend) | |
| 413 | - | ctx, cancel := context.WithCancel(context.Background()) | |
| 414 | - | defer cancel() | |
| 415 | - | done := make(chan error, 1) | |
| 416 | - | go func() { | |
| 417 | - | _, err := listSessionsWithDialer(ctx, func(context.Context) (wire.Transport, error) { | |
| 418 | - | return transport, nil | |
| 419 | - | }) | |
| 420 | - | done <- err | |
| 421 | - | }() | |
| 422 | - | // Wait until the exchange is blocked inside the transport | |
| 423 | - | // before cancelling, so the test exercises in-flight | |
| 424 | - | // cancellation rather than the earlier dial-path check. | |
| 425 | - | select { | |
| 426 | - | case <-entered: | |
| 427 | - | case <-time.After(5 * time.Second): | |
| 428 | - | t.Fatal("exchange did not reach the blocked transport") | |
| 429 | - | } | |
| 430 | - | started := time.Now() | |
| 431 | - | cancel() | |
| 432 | - | select { | |
| 433 | - | case err := <-done: | |
| 434 | - | require.ErrorIs(t, err, context.Canceled) | |
| 435 | - | require.Less(t, time.Since(started), preflightListTimeout, "in-flight cancellation must beat the list bound") | |
| 436 | - | case <-time.After(5 * time.Second): | |
| 437 | - | t.Fatal("preflight did not release on context cancellation") | |
| 438 | - | } | |
| 439 | - | select { | |
| 440 | - | case <-closed: | |
| 441 | - | default: | |
| 442 | - | t.Fatal("cancellation must close the exchange transport") | |
| 443 | - | } | |
| 444 | - | }) | |
| 445 | - | } | |
| 446 | - | } | |
D internal/app/offline_kill_retry_test.go +0 -81
| @@ -1,81 +0,0 @@ | |||
| 1 | - | package app | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "context" | |
| 5 | - | "errors" | |
| 6 | - | "os" | |
| 7 | - | "path/filepath" | |
| 8 | - | "testing" | |
| 9 | - | "time" | |
| 10 | - | ||
| 11 | - | "github.com/stretchr/testify/require" | |
| 12 | - | ||
| 13 | - | "github.com/bnema/vev/internal/domain" | |
| 14 | - | "github.com/bnema/vev/internal/persist" | |
| 15 | - | ) | |
| 16 | - | ||
| 17 | - | func TestRunOfflineNamedKillUsesOpenCatalogueSeam(t *testing.T) { | |
| 18 | - | stateRoot := t.TempDir() | |
| 19 | - | t.Setenv("XDG_STATE_HOME", stateRoot) | |
| 20 | - | stateDir := filepath.Join(stateRoot, "vev") | |
| 21 | - | ||
| 22 | - | p := newTestPersister(t, stateDir) | |
| 23 | - | now := time.Now().UnixNano() | |
| 24 | - | require.NoError(t, p.Save(persist.Record{Name: "named", IncarnationID: domain.IncarnationID{1}, Cwd: t.TempDir(), CreatedAt: now, UpdatedAt: now})) | |
| 25 | - | require.NoError(t, p.Close()) | |
| 26 | - | ||
| 27 | - | original := openCatalogue | |
| 28 | - | t.Cleanup(func() { openCatalogue = original }) | |
| 29 | - | wantErr := errors.New("open seam called") | |
| 30 | - | called := false | |
| 31 | - | openCatalogue = func(gotStateDir string) (persist.OpenResult, error) { | |
| 32 | - | called = true | |
| 33 | - | require.Equal(t, stateDir, gotStateDir) | |
| 34 | - | return persist.OpenResult{}, wantErr | |
| 35 | - | } | |
| 36 | - | ||
| 37 | - | err := runOfflineNamedKill(context.Background(), "named") | |
| 38 | - | require.ErrorIs(t, err, wantErr) | |
| 39 | - | require.True(t, called) | |
| 40 | - | } | |
| 41 | - | ||
| 42 | - | func TestRunKillOfflineUsesIncarnationDeletionProtocol(t *testing.T) { | |
| 43 | - | tests := []struct { | |
| 44 | - | name string | |
| 45 | - | crashHook func(*testing.T, string) | |
| 46 | - | }{ | |
| 47 | - | {name: "normal"}, | |
| 48 | - | { | |
| 49 | - | name: "stray tmp is ignored", | |
| 50 | - | crashHook: func(t *testing.T, stateDir string) { | |
| 51 | - | t.Helper() | |
| 52 | - | storePath := persist.StorePath(stateDir) | |
| 53 | - | require.NoError(t, os.WriteFile(storePath+".tmp", []byte("partial rewrite"), 0o600)) | |
| 54 | - | }, | |
| 55 | - | }, | |
| 56 | - | } | |
| 57 | - | ||
| 58 | - | for _, tt := range tests { | |
| 59 | - | t.Run(tt.name, func(t *testing.T) { | |
| 60 | - | stateRoot, runtimeRoot := t.TempDir(), t.TempDir() | |
| 61 | - | t.Setenv("XDG_STATE_HOME", stateRoot) | |
| 62 | - | t.Setenv("XDG_RUNTIME_DIR", runtimeRoot) | |
| 63 | - | stateDir := filepath.Join(stateRoot, "vev") | |
| 64 | - | ||
| 65 | - | id := domain.IncarnationID{1} | |
| 66 | - | p := newTestPersister(t, stateDir) | |
| 67 | - | now := time.Now().UnixNano() | |
| 68 | - | require.NoError(t, p.Save(persist.Record{Name: "named", IncarnationID: id, Cwd: t.TempDir(), CreatedAt: now, UpdatedAt: now})) | |
| 69 | - | require.NoError(t, p.Close()) | |
| 70 | - | source := filepath.Join(stateDir, "snapshots", "sessions", id.String()) | |
| 71 | - | require.NoError(t, os.MkdirAll(source, 0o700)) | |
| 72 | - | require.NoError(t, os.WriteFile(filepath.Join(source, "sentinel"), []byte("durable"), 0o600)) | |
| 73 | - | if tt.crashHook != nil { | |
| 74 | - | tt.crashHook(t, stateDir) | |
| 75 | - | } | |
| 76 | - | ||
| 77 | - | require.NoError(t, runKill(context.Background(), "named", false, false)) | |
| 78 | - | require.NoDirExists(t, source) | |
| 79 | - | }) | |
| 80 | - | } | |
| 81 | - | } | |
D internal/app/remote_cache_config_test.go +0 -51
| @@ -1,51 +0,0 @@ | |||
| 1 | - | package app | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "context" | |
| 5 | - | "os" | |
| 6 | - | "path/filepath" | |
| 7 | - | "testing" | |
| 8 | - | "time" | |
| 9 | - | ||
| 10 | - | remoteadapter "github.com/bnema/vev/internal/adapters/remote" | |
| 11 | - | "github.com/bnema/vev/internal/domain" | |
| 12 | - | "github.com/bnema/vev/internal/protocol" | |
| 13 | - | "github.com/bnema/vev/internal/usecase/client" | |
| 14 | - | "github.com/stretchr/testify/mock" | |
| 15 | - | "github.com/stretchr/testify/require" | |
| 16 | - | ) | |
| 17 | - | ||
| 18 | - | func TestRunAttachThreadsAttachmentCacheConfig(t *testing.T) { | |
| 19 | - | for _, remote := range []string{"", "remote.example"} { | |
| 20 | - | for _, tt := range []struct { | |
| 21 | - | name, text string | |
| 22 | - | want domain.AttachmentCacheConfig | |
| 23 | - | }{ | |
| 24 | - | {"default", "", domain.DefaultAttachmentCacheConfig()}, | |
| 25 | - | {"configured", "remote.attachment-cache-capacity = 3\nremote.attachment-cache-idle-timeout = 42s", domain.AttachmentCacheConfig{Enabled: true, Capacity: 3, IdleTimeout: 42 * time.Second}}, | |
| 26 | - | {"disabled", "remote.attachment-cache = off", domain.AttachmentCacheConfig{Enabled: false, Capacity: 8}}, | |
| 27 | - | {"warning with nil logger", "remote.attachment-cache-capacity = 0", domain.DefaultAttachmentCacheConfig()}, | |
| 28 | - | } { | |
| 29 | - | t.Run(remote+"/"+tt.name, func(t *testing.T) { | |
| 30 | - | path := filepath.Join(t.TempDir(), "config") | |
| 31 | - | require.NoError(t, os.WriteFile(path, []byte(tt.text), 0600)) | |
| 32 | - | factory := newRemoteDialerFactoryMock(t) | |
| 33 | - | if remote != "" { | |
| 34 | - | factory.EXPECT().DialerForRemote(remote, "", remoteadapter.TransportQUIC, mock.Anything).Return(namedDialer{name: "remote"}, nil).Once() | |
| 35 | - | } | |
| 36 | - | calls := 0 | |
| 37 | - | err := runAttachWithDeps(context.Background(), protocol.IntentNew, "work", remote, "", nil, runAttachDeps{ | |
| 38 | - | configPath: func() string { return path }, | |
| 39 | - | remoteDialerFactory: factory.DialerForRemote, | |
| 40 | - | runClient: func(_ context.Context, deps client.Dependencies, _ client.AttachRequest) error { | |
| 41 | - | calls++ | |
| 42 | - | require.Equal(t, tt.want, deps.AttachmentCache) | |
| 43 | - | return nil | |
| 44 | - | }, | |
| 45 | - | }) | |
| 46 | - | require.NoError(t, err) | |
| 47 | - | require.Equal(t, 1, calls) | |
| 48 | - | }) | |
| 49 | - | } | |
| 50 | - | } | |
| 51 | - | } | |
D internal/app/remote_dialer_factory_test.go +0 -78
| @@ -1,78 +0,0 @@ | |||
| 1 | - | package app | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "fmt" | |
| 5 | - | "log/slog" | |
| 6 | - | "sync" | |
| 7 | - | "testing" | |
| 8 | - | ||
| 9 | - | remoteadapter "github.com/bnema/vev/internal/adapters/remote" | |
| 10 | - | "github.com/bnema/vev/internal/protocol/wire" | |
| 11 | - | ) | |
| 12 | - | ||
| 13 | - | type remoteDialerFactoryMock struct { | |
| 14 | - | mu sync.Mutex | |
| 15 | - | calls []*remoteDialerFactoryCall | |
| 16 | - | } | |
| 17 | - | ||
| 18 | - | type remoteDialerFactoryExpecter struct{ mock *remoteDialerFactoryMock } | |
| 19 | - | ||
| 20 | - | type remoteDialerFactoryCall struct { | |
| 21 | - | target, session string | |
| 22 | - | mode remoteadapter.TransportMode | |
| 23 | - | dialer wire.Dialer | |
| 24 | - | err error | |
| 25 | - | remaining int | |
| 26 | - | } | |
| 27 | - | ||
| 28 | - | func newRemoteDialerFactoryMock(t *testing.T) *remoteDialerFactoryMock { | |
| 29 | - | t.Helper() | |
| 30 | - | m := &remoteDialerFactoryMock{} | |
| 31 | - | t.Cleanup(func() { | |
| 32 | - | m.mu.Lock() | |
| 33 | - | defer m.mu.Unlock() | |
| 34 | - | for _, call := range m.calls { | |
| 35 | - | if call.remaining != 0 { | |
| 36 | - | t.Errorf("unmet DialerForRemote(%q, %q, %q) calls: %d", call.target, call.session, call.mode, call.remaining) | |
| 37 | - | } | |
| 38 | - | } | |
| 39 | - | }) | |
| 40 | - | return m | |
| 41 | - | } | |
| 42 | - | ||
| 43 | - | func (m *remoteDialerFactoryMock) EXPECT() *remoteDialerFactoryExpecter { | |
| 44 | - | return &remoteDialerFactoryExpecter{mock: m} | |
| 45 | - | } | |
| 46 | - | ||
| 47 | - | func (e *remoteDialerFactoryExpecter) DialerForRemote(target, session string, mode remoteadapter.TransportMode, _ any) *remoteDialerFactoryCall { | |
| 48 | - | call := &remoteDialerFactoryCall{target: target, session: session, mode: mode, remaining: 1} | |
| 49 | - | e.mock.mu.Lock() | |
| 50 | - | e.mock.calls = append(e.mock.calls, call) | |
| 51 | - | e.mock.mu.Unlock() | |
| 52 | - | return call | |
| 53 | - | } | |
| 54 | - | ||
| 55 | - | func (c *remoteDialerFactoryCall) Return(dialer wire.Dialer, err error) *remoteDialerFactoryCall { | |
| 56 | - | c.dialer, c.err = dialer, err | |
| 57 | - | return c | |
| 58 | - | } | |
| 59 | - | ||
| 60 | - | func (c *remoteDialerFactoryCall) Once() *remoteDialerFactoryCall { return c.Times(1) } | |
| 61 | - | ||
| 62 | - | func (c *remoteDialerFactoryCall) Times(n int) *remoteDialerFactoryCall { | |
| 63 | - | c.remaining = n | |
| 64 | - | return c | |
| 65 | - | } | |
| 66 | - | ||
| 67 | - | func (m *remoteDialerFactoryMock) DialerForRemote(target, session string, mode remoteadapter.TransportMode, _ *slog.Logger) (wire.Dialer, error) { | |
| 68 | - | m.mu.Lock() | |
| 69 | - | defer m.mu.Unlock() | |
| 70 | - | for _, call := range m.calls { | |
| 71 | - | if call.remaining == 0 || call.target != target || call.session != session || call.mode != mode { | |
| 72 | - | continue | |
| 73 | - | } | |
| 74 | - | call.remaining-- | |
| 75 | - | return call.dialer, call.err | |
| 76 | - | } | |
| 77 | - | return nil, fmt.Errorf("unexpected DialerForRemote(%q, %q, %q)", target, session, mode) | |
| 78 | - | } | |
D internal/app/remote_final_acceptance_test.go +0 -178
| @@ -1,178 +0,0 @@ | |||
| 1 | - | //go:build linux | |
| 2 | - | ||
| 3 | - | package app | |
| 4 | - | ||
| 5 | - | import ( | |
| 6 | - | "bytes" | |
| 7 | - | "context" | |
| 8 | - | "io" | |
| 9 | - | "log/slog" | |
| 10 | - | "net" | |
| 11 | - | "sync" | |
| 12 | - | "testing" | |
| 13 | - | ||
| 14 | - | remoteadapter "github.com/bnema/vev/internal/adapters/remote" | |
| 15 | - | "github.com/bnema/vev/internal/adapters/sessionwire" | |
| 16 | - | "github.com/bnema/vev/internal/adapters/sshstdio" | |
| 17 | - | "github.com/bnema/vev/internal/domain" | |
| 18 | - | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 19 | - | "github.com/bnema/vev/internal/protocol" | |
| 20 | - | "github.com/bnema/vev/internal/protocol/wire" | |
| 21 | - | "github.com/bnema/vev/internal/usecase/client" | |
| 22 | - | "github.com/stretchr/testify/require" | |
| 23 | - | ) | |
| 24 | - | ||
| 25 | - | type acceptanceRemoteFactory struct { | |
| 26 | - | mu sync.Mutex | |
| 27 | - | calls []string | |
| 28 | - | handoff map[string]protocol.AttachTarget | |
| 29 | - | outputs chan string | |
| 30 | - | } | |
| 31 | - | ||
| 32 | - | func (f *acceptanceRemoteFactory) DialerForRemote(target string, _ string, _ remoteadapter.TransportMode, _ *slog.Logger) (wire.Dialer, error) { | |
| 33 | - | f.mu.Lock() | |
| 34 | - | f.calls = append(f.calls, target) | |
| 35 | - | targetHandoff := f.handoff[target] | |
| 36 | - | f.mu.Unlock() | |
| 37 | - | return acceptanceRemoteDialer{target: target, handoff: targetHandoff, outputs: f.outputs}, nil | |
| 38 | - | } | |
| 39 | - | ||
| 40 | - | type acceptanceRemoteDialer struct { | |
| 41 | - | target string | |
| 42 | - | handoff protocol.AttachTarget | |
| 43 | - | outputs chan<- string | |
| 44 | - | } | |
| 45 | - | ||
| 46 | - | func (d acceptanceRemoteDialer) Dial(context.Context) (wire.Transport, error) { | |
| 47 | - | clientConn, serverConn := net.Pipe() | |
| 48 | - | clientTransport := sshstdio.NewTransport(clientConn, clientConn, clientConn.Close) | |
| 49 | - | serverTransport := sshstdio.NewTransport(serverConn, serverConn, serverConn.Close) | |
| 50 | - | go serveAcceptanceRemote(serverTransport, d.target, d.handoff, d.outputs) | |
| 51 | - | return clientTransport, nil | |
| 52 | - | } | |
| 53 | - | ||
| 54 | - | func serveAcceptanceRemote(raw wire.Transport, target string, handoff protocol.AttachTarget, outputs chan<- string) { | |
| 55 | - | tr := sessionwire.NewServerConnection(raw) | |
| 56 | - | defer func() { _ = tr.Close() }() | |
| 57 | - | welcomed := false | |
| 58 | - | for { | |
| 59 | - | message, err := tr.ReceiveClient() | |
| 60 | - | if err != nil { | |
| 61 | - | return | |
| 62 | - | } | |
| 63 | - | if !welcomed { | |
| 64 | - | if _, ok := message.(protocol.Hello); !ok { | |
| 65 | - | continue | |
| 66 | - | } | |
| 67 | - | if err := tr.SendServer(protocol.Welcome{SessionID: target}); err != nil { | |
| 68 | - | return | |
| 69 | - | } | |
| 70 | - | welcomed = true | |
| 71 | - | continue | |
| 72 | - | } | |
| 73 | - | if handoff.Endpoint != "" { | |
| 74 | - | _ = tr.SendServer(handoff) | |
| 75 | - | for { | |
| 76 | - | if _, err := tr.ReceiveClient(); err != nil { | |
| 77 | - | return | |
| 78 | - | } | |
| 79 | - | } | |
| 80 | - | } | |
| 81 | - | data := []byte("remote:" + target) | |
| 82 | - | if outputs != nil { | |
| 83 | - | outputs <- string(data) | |
| 84 | - | } | |
| 85 | - | if err := tr.SendServer(protocol.Output{ | |
| 86 | - | Epoch: 1, New: 1, Full: true, Size: domain.Size{Cols: 80, Rows: 24}, Data: data, | |
| 87 | - | Context: &protocol.ViewContext{ | |
| 88 | - | Publication: 1, | |
| 89 | - | Route: protocol.CommittedRouteIdentity{Target: protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "remote-fixture"}}, | |
| 90 | - | TabID: "tab-1", FocusedPaneID: "pane-1", | |
| 91 | - | }, | |
| 92 | - | }); err != nil { | |
| 93 | - | return | |
| 94 | - | } | |
| 95 | - | _ = tr.SendServer(protocol.Detached{Reason: protocol.ReasonDetach}) | |
| 96 | - | for { | |
| 97 | - | if _, err := tr.ReceiveClient(); err != nil { | |
| 98 | - | return | |
| 99 | - | } | |
| 100 | - | } | |
| 101 | - | } | |
| 102 | - | } | |
| 103 | - | ||
| 104 | - | type acceptanceRemoteTerminal struct { | |
| 105 | - | in io.Reader | |
| 106 | - | out bytes.Buffer | |
| 107 | - | } | |
| 108 | - | ||
| 109 | - | func (t *acceptanceRemoteTerminal) EnterRaw() (func() error, error) { | |
| 110 | - | return func() error { return nil }, nil | |
| 111 | - | } | |
| 112 | - | func (*acceptanceRemoteTerminal) Geometry() (domain.Geometry, error) { | |
| 113 | - | return domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}, nil | |
| 114 | - | } | |
| 115 | - | func (*acceptanceRemoteTerminal) ResizeEvents() <-chan domain.Geometry { return nil } | |
| 116 | - | func (t *acceptanceRemoteTerminal) In() io.Reader { return t.in } | |
| 117 | - | func (t *acceptanceRemoteTerminal) Out() io.Writer { return &t.out } | |
| 118 | - | func (*acceptanceRemoteTerminal) Flush() error { return nil } | |
| 119 | - | ||
| 120 | - | func TestAcceptanceRemoteDirectAndPickerUseOnlyRemoteTransports(t *testing.T) { | |
| 121 | - | outputs := make(chan string, 4) | |
| 122 | - | factory := &acceptanceRemoteFactory{outputs: outputs} | |
| 123 | - | hostStore := portsmocks.NewMockRemoteHostStore(t) | |
| 124 | - | localCalls := 0 | |
| 125 | - | terminals := make([]*acceptanceRemoteTerminal, 0, 3) | |
| 126 | - | deps := runAttachDeps{ | |
| 127 | - | hostStore: hostStore, | |
| 128 | - | localDialer: func() wire.Dialer { | |
| 129 | - | localCalls++ | |
| 130 | - | return acceptanceRemoteDialer{} | |
| 131 | - | }, | |
| 132 | - | remoteDialerFactory: factory.DialerForRemote, | |
| 133 | - | selectedRemoteTransport: string(remoteadapter.TransportStdio), | |
| 134 | - | runClient: func(ctx context.Context, deps client.Dependencies, request client.AttachRequest) error { | |
| 135 | - | term := &acceptanceRemoteTerminal{in: bytes.NewReader(nil)} | |
| 136 | - | terminals = append(terminals, term) | |
| 137 | - | deps.Terminal = term | |
| 138 | - | deps.Logger = slog.New(slog.DiscardHandler) | |
| 139 | - | deps.Remote = true | |
| 140 | - | result := client.NewRunner(deps).Run(ctx, request) | |
| 141 | - | if request.SessionName == "work" { | |
| 142 | - | require.NoError(t, result, "the client handoff completes before the test injects the composition-root handoff") | |
| 143 | - | return &client.AttachTargetError{Target: protocol.AttachTarget{Endpoint: "picked.example", Session: "picked", Intent: protocol.IntentAttach}} | |
| 144 | - | } | |
| 145 | - | return result | |
| 146 | - | }, | |
| 147 | - | } | |
| 148 | - | ||
| 149 | - | probeRaw, err := (acceptanceRemoteDialer{ | |
| 150 | - | target: "picker-wire.example", | |
| 151 | - | handoff: protocol.AttachTarget{Endpoint: "picked.example", Session: "picked", Intent: protocol.IntentAttach}, | |
| 152 | - | }).Dial(context.Background()) | |
| 153 | - | require.NoError(t, err) | |
| 154 | - | wireTransport := sessionwire.NewClientConnection(probeRaw) | |
| 155 | - | require.NoError(t, wireTransport.SendClient(protocol.Hello{ | |
| 156 | - | Version: protocol.Version, Intent: protocol.IntentAttach, Name: "work", Size: domain.Size{Cols: 80, Rows: 24}, | |
| 157 | - | })) | |
| 158 | - | welcome, err := wireTransport.ReceiveServer() | |
| 159 | - | require.NoError(t, err) | |
| 160 | - | require.IsType(t, protocol.Welcome{}, welcome) | |
| 161 | - | require.NoError(t, wireTransport.SendClient(protocol.Theme{})) | |
| 162 | - | attachTargetMessage, err := wireTransport.ReceiveServer() | |
| 163 | - | require.NoError(t, err) | |
| 164 | - | target, ok := attachTargetMessage.(protocol.AttachTarget) | |
| 165 | - | require.True(t, ok, "expected attach target, got %T", attachTargetMessage) | |
| 166 | - | require.Equal(t, "picked.example", target.Endpoint) | |
| 167 | - | require.NoError(t, wireTransport.Close()) | |
| 168 | - | ||
| 169 | - | require.NoError(t, runAttachWithDeps(context.Background(), protocol.IntentAttach, "direct", "direct.example", "", nil, deps)) | |
| 170 | - | require.NoError(t, runAttachWithDeps(context.Background(), protocol.IntentAttach, "work", "picker.example", "", nil, deps)) | |
| 171 | - | factory.mu.Lock() | |
| 172 | - | calls := append([]string(nil), factory.calls...) | |
| 173 | - | factory.mu.Unlock() | |
| 174 | - | require.Equal(t, []string{"direct.example", "picker.example", "picked.example"}, calls) | |
| 175 | - | require.Zero(t, localCalls, "remote direct and picker connections must not create a local shadow") | |
| 176 | - | require.Len(t, terminals, 3) | |
| 177 | - | require.Equal(t, []string{"remote:direct.example", "remote:picker.example", "remote:picked.example"}, []string{<-outputs, <-outputs, <-outputs}) | |
| 178 | - | } | |
M internal/app/remote_hosts.go +73 -181
| @@ -2,17 +2,13 @@ package app | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | - | "errors" | |
| 6 | 5 | "fmt" | |
| 7 | 6 | "io" | |
| 8 | - | "log/slog" | |
| 9 | 7 | "os" | |
| 10 | 8 | "sort" | |
| 11 | 9 | "text/tabwriter" | |
| 12 | 10 | ||
| 13 | - | remoteadapter "github.com/bnema/vev/internal/adapters/remote" | |
| 14 | 11 | "github.com/bnema/vev/internal/domain" | |
| 15 | - | "github.com/bnema/vev/internal/platform" | |
| 16 | 12 | "github.com/bnema/vev/internal/ports" | |
| 17 | 13 | "github.com/bnema/vev/internal/protocol" | |
| 18 | 14 | "github.com/bnema/vev/internal/protocol/catalogue" | |
| @@ -24,36 +20,18 @@ const ( | |||
| 24 | 20 | hostActionList = "list" | |
| 25 | 21 | ) | |
| 26 | 22 | ||
| 27 | - | // remoteHostDeps holds injectable seams for host management and remote listing. | |
| 28 | 23 | type remoteHostDeps struct { | |
| 29 | - | stateDir func() string | |
| 30 | - | store ports.RemoteHostStore | |
| 31 | - | catalog ports.RemoteCatalogClient | |
| 32 | - | localList func(context.Context) ([]protocol.SessionInfo, error) | |
| 33 | - | stdout io.Writer | |
| 24 | + | connect func(context.Context) (ports.BrokerService, error) | |
| 25 | + | stdout io.Writer | |
| 34 | 26 | } | |
| 35 | 27 | ||
| 36 | 28 | func defaultRemoteHostDeps() remoteHostDeps { | |
| 37 | - | return remoteHostDeps{ | |
| 38 | - | stateDir: platform.StateDir, | |
| 39 | - | catalog: remoteadapter.NewCatalogClient(), | |
| 40 | - | localList: listLocalSessions, | |
| 41 | - | stdout: os.Stdout, | |
| 42 | - | } | |
| 29 | + | return remoteHostDeps{connect: connectProductionBroker, stdout: os.Stdout} | |
| 43 | 30 | } | |
| 44 | 31 | ||
| 45 | 32 | func (d remoteHostDeps) withDefaults() remoteHostDeps { | |
| 46 | - | if d.stateDir == nil { | |
| 47 | - | d.stateDir = platform.StateDir | |
| 48 | - | } | |
| 49 | - | if d.store == nil { | |
| 50 | - | d.store = remoteadapter.NewFileHostStore(remoteadapter.HostStorePath(d.stateDir())) | |
| 51 | - | } | |
| 52 | - | if d.catalog == nil { | |
| 53 | - | d.catalog = remoteadapter.NewCatalogClient() | |
| 54 | - | } | |
| 55 | - | if d.localList == nil { | |
| 56 | - | d.localList = listLocalSessions | |
| 33 | + | if d.connect == nil { | |
| 34 | + | d.connect = connectProductionBroker | |
| 57 | 35 | } | |
| 58 | 36 | if d.stdout == nil { | |
| 59 | 37 | d.stdout = os.Stdout | |
| @@ -61,200 +39,114 @@ func (d remoteHostDeps) withDefaults() remoteHostDeps { | |||
| 61 | 39 | return d | |
| 62 | 40 | } | |
| 63 | 41 | ||
| 64 | - | func (d remoteHostDeps) hostStore() ports.RemoteHostStore { | |
| 65 | - | return d.store | |
| 66 | - | } | |
| 67 | - | ||
| 68 | 42 | func runHostCommand(ctx context.Context, cmd command, deps remoteHostDeps) error { | |
| 69 | - | _ = ctx | |
| 70 | 43 | deps = deps.withDefaults() | |
| 71 | - | ||
| 44 | + | service, err := deps.connect(ctx) | |
| 45 | + | if err != nil { | |
| 46 | + | return err | |
| 47 | + | } | |
| 48 | + | defer func() { _ = service.Close() }() | |
| 72 | 49 | switch cmd.hostAction { | |
| 73 | 50 | case hostActionAdd: | |
| 74 | - | return hostAdd(deps, cmd.hostTarget) | |
| 51 | + | if err := domain.ValidateRemoteHostTarget(cmd.hostTarget); err != nil { | |
| 52 | + | return err | |
| 53 | + | } | |
| 54 | + | transport, err := remoteTransportModeFromEnv(os.Getenv(envRemoteTransport)) | |
| 55 | + | if err != nil { | |
| 56 | + | return err | |
| 57 | + | } | |
| 58 | + | _, err = service.AddHost(ctx, cmd.hostTarget, remoteBrokerPolicy(transport)) | |
| 59 | + | return err | |
| 75 | 60 | case hostActionRm: | |
| 76 | - | return hostRm(deps, cmd.hostTarget) | |
| 61 | + | if err := domain.ValidateRemoteHostTarget(cmd.hostTarget); err != nil { | |
| 62 | + | return err | |
| 63 | + | } | |
| 64 | + | var registration domain.RemoteRegistration | |
| 65 | + | for _, daemon := range service.Snapshot().Daemons { | |
| 66 | + | if !daemon.Local && daemon.Endpoint == cmd.hostTarget { | |
| 67 | + | registration = daemon.Registration | |
| 68 | + | break | |
| 69 | + | } | |
| 70 | + | } | |
| 71 | + | if registration.Endpoint == "" { | |
| 72 | + | return fmt.Errorf("vev: unknown host %q", cmd.hostTarget) | |
| 73 | + | } | |
| 74 | + | removed, err := service.RemoveHost(ctx, registration) | |
| 75 | + | if err != nil { | |
| 76 | + | return err | |
| 77 | + | } | |
| 78 | + | if !removed { | |
| 79 | + | return fmt.Errorf("vev: unknown host %q", cmd.hostTarget) | |
| 80 | + | } | |
| 81 | + | return nil | |
| 77 | 82 | case hostActionList: | |
| 78 | - | return hostList(deps) | |
| 83 | + | return printBrokerHosts(deps.stdout, service.Snapshot()) | |
| 79 | 84 | default: | |
| 80 | 85 | return usagef("unknown host action %q", cmd.hostAction) | |
| 81 | 86 | } | |
| 82 | 87 | } | |
| 83 | 88 | ||
| 84 | - | func hostAdd(deps remoteHostDeps, target string) error { | |
| 85 | - | if err := domain.ValidateRemoteHostTarget(target); err != nil { | |
| 86 | - | return err | |
| 87 | - | } | |
| 88 | - | if err := deps.hostStore().AddPinned(target); err != nil { | |
| 89 | - | return fmt.Errorf("vev: adding pinned host %q: %w", target, err) | |
| 90 | - | } | |
| 91 | - | return nil | |
| 92 | - | } | |
| 93 | - | ||
| 94 | - | func hostRm(deps remoteHostDeps, target string) error { | |
| 95 | - | if err := domain.ValidateRemoteHostTarget(target); err != nil { | |
| 96 | - | return err | |
| 89 | + | func printBrokerHosts(w io.Writer, snapshot ports.BrokerSnapshot) error { | |
| 90 | + | if err := snapshot.Validate(); err != nil { | |
| 91 | + | return fmt.Errorf("vev: reading broker catalogue: %w", err) | |
| 97 | 92 | } | |
| 98 | - | deleted, err := deps.hostStore().Remove(target) | |
| 99 | - | if err != nil { | |
| 100 | - | return fmt.Errorf("vev: removing host %q: %w", target, err) | |
| 101 | - | } | |
| 102 | - | if !deleted { | |
| 103 | - | return fmt.Errorf("vev: unknown host %q", target) | |
| 93 | + | var endpoints []string | |
| 94 | + | for _, daemon := range snapshot.Daemons { | |
| 95 | + | if !daemon.Local { | |
| 96 | + | endpoints = append(endpoints, daemon.Endpoint) | |
| 97 | + | } | |
| 104 | 98 | } | |
| 105 | - | return nil | |
| 106 | - | } | |
| 107 | - | ||
| 108 | - | func hostList(deps remoteHostDeps) error { | |
| 109 | - | hosts, err := mergeKnownHosts(deps) | |
| 110 | - | if err != nil { | |
| 99 | + | sort.Strings(endpoints) | |
| 100 | + | if len(endpoints) == 0 { | |
| 101 | + | _, err := fmt.Fprintln(w, "no hosts") | |
| 111 | 102 | return err | |
| 112 | 103 | } | |
| 113 | - | if len(hosts) == 0 { | |
| 114 | - | _, _ = fmt.Fprintln(deps.stdout, "no hosts") | |
| 115 | - | return nil | |
| 116 | - | } | |
| 117 | - | tw := tabwriter.NewWriter(deps.stdout, 0, 4, 2, ' ', 0) | |
| 104 | + | tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0) | |
| 118 | 105 | _, _ = fmt.Fprintln(tw, "TARGET\tSOURCE") | |
| 119 | - | for _, host := range hosts { | |
| 120 | - | _, _ = fmt.Fprintf(tw, "%s\t%s\n", host.Target, remoteHostSource(host)) | |
| 106 | + | for _, endpoint := range endpoints { | |
| 107 | + | _, _ = fmt.Fprintf(tw, "%s\tbroker\n", endpoint) | |
| 121 | 108 | } | |
| 122 | 109 | return tw.Flush() | |
| 123 | 110 | } | |
| 124 | 111 | ||
| 125 | - | func remoteHostSource(host domain.RemoteHost) string { | |
| 126 | - | switch { | |
| 127 | - | case host.Pinned && host.Learned: | |
| 128 | - | return "pinned,learned" | |
| 129 | - | case host.Pinned: | |
| 130 | - | return "pinned" | |
| 131 | - | case host.Learned: | |
| 132 | - | return "learned" | |
| 133 | - | default: | |
| 134 | - | return "" | |
| 112 | + | func runBrokerSnapshotList(cmd command, snapshot ports.BrokerSnapshot, stdout io.Writer) error { | |
| 113 | + | if err := snapshot.Validate(); err != nil { | |
| 114 | + | return fmt.Errorf("vev: reading broker catalogue: %w", err) | |
| 135 | 115 | } | |
| 136 | - | } | |
| 137 | - | ||
| 138 | - | func runRemoteList(ctx context.Context, cmd command, deps remoteHostDeps) error { | |
| 139 | - | deps = deps.withDefaults() | |
| 140 | - | ||
| 141 | - | hosts, err := mergeKnownHosts(deps) | |
| 142 | - | if err != nil { | |
| 143 | - | return err | |
| 144 | - | } | |
| 145 | - | if cmd.listHost != "" { | |
| 146 | - | return listOneRemoteHost(ctx, deps, hosts, cmd.listHost) | |
| 147 | - | } | |
| 148 | - | return listAllSessions(ctx, deps, hosts) | |
| 149 | - | } | |
| 150 | - | ||
| 151 | - | func listOneRemoteHost(ctx context.Context, deps remoteHostDeps, hosts []domain.RemoteHost, target string) error { | |
| 152 | - | known := false | |
| 153 | - | for _, host := range hosts { | |
| 154 | - | if host.Target == target { | |
| 155 | - | known = true | |
| 156 | - | break | |
| 157 | - | } | |
| 158 | - | } | |
| 159 | - | if !known { | |
| 160 | - | return fmt.Errorf("vev: unknown host %q", target) | |
| 161 | - | } | |
| 162 | - | catalog, err := deps.catalog.List(ctx, target) | |
| 163 | - | if err != nil { | |
| 164 | - | return fmt.Errorf("vev: listing sessions on %q: %w", target, err) | |
| 165 | - | } | |
| 166 | - | printSessions(deps.stdout, catalogSessionsAsInfo(target, catalog.Sessions)) | |
| 167 | - | return nil | |
| 168 | - | } | |
| 169 | - | ||
| 170 | - | // listAllSessions queries hosts sequentially for deterministic output ordering. | |
| 171 | - | func listAllSessions(ctx context.Context, deps remoteHostDeps, hosts []domain.RemoteHost) error { | |
| 172 | - | local, err := deps.localList(ctx) | |
| 173 | - | if err != nil { | |
| 174 | - | if local != nil { | |
| 175 | - | printSessions(deps.stdout, local) | |
| 176 | - | } | |
| 177 | - | return err | |
| 178 | - | } | |
| 179 | - | all := append([]protocol.SessionInfo(nil), local...) | |
| 180 | - | var hostErrs []error | |
| 181 | - | for _, host := range hosts { | |
| 182 | - | if err := ctx.Err(); err != nil { | |
| 183 | - | printSessions(deps.stdout, all) | |
| 184 | - | return err | |
| 185 | - | } | |
| 186 | - | catalog, listErr := deps.catalog.List(ctx, host.Target) | |
| 187 | - | if listErr != nil { | |
| 188 | - | hostErrs = append(hostErrs, fmt.Errorf("host %s: %w", host.Target, listErr)) | |
| 116 | + | var sessions []protocol.SessionInfo | |
| 117 | + | found := cmd.listHost == "" | |
| 118 | + | for _, daemon := range snapshot.Daemons { | |
| 119 | + | if cmd.listHost != "" && (daemon.Local || daemon.Endpoint != cmd.listHost) { | |
| 189 | 120 | continue | |
| 190 | 121 | } | |
| 191 | - | all = append(all, catalogSessionsAsInfo(host.Target, catalog.Sessions)...) | |
| 122 | + | found = true | |
| 123 | + | for i, session := range catalogSessionsAsInfo(daemon.Endpoint, daemon.Sessions) { | |
| 124 | + | if daemon.Local { | |
| 125 | + | session.Name = daemon.Sessions[i].Name | |
| 126 | + | } | |
| 127 | + | sessions = append(sessions, session) | |
| 128 | + | } | |
| 192 | 129 | } | |
| 193 | - | printSessions(deps.stdout, all) | |
| 194 | - | if len(hostErrs) > 0 { | |
| 195 | - | return errors.Join(hostErrs...) | |
| 130 | + | if !found { | |
| 131 | + | return fmt.Errorf("vev: unknown host %q", cmd.listHost) | |
| 196 | 132 | } | |
| 133 | + | printSessions(stdout, sessions) | |
| 197 | 134 | return nil | |
| 198 | 135 | } | |
| 199 | 136 | ||
| 200 | 137 | func catalogSessionsAsInfo(host string, sessions []catalogue.RemoteCatalogSession) []protocol.SessionInfo { | |
| 201 | 138 | out := make([]protocol.SessionInfo, 0, len(sessions)) | |
| 202 | 139 | for _, session := range sessions { | |
| 203 | - | info := protocol.SessionInfo{ | |
| 204 | - | Name: domain.RemoteSessionDisplay(session.Name, host), | |
| 205 | - | Ephemeral: session.Ephemeral, | |
| 206 | - | Tabs: catalogue.SaturateUint16(catalogue.CatalogTabCount(session)), | |
| 207 | - | Attached: session.Attached, | |
| 208 | - | } | |
| 140 | + | info := protocol.SessionInfo{Name: domain.RemoteSessionDisplay(session.Name, host), Ephemeral: session.Ephemeral, Tabs: catalogue.SaturateUint16(catalogue.CatalogTabCount(session)), Attached: session.Attached} | |
| 209 | 141 | switch session.State { | |
| 210 | 142 | case catalogue.RemoteCatalogSessionUp: | |
| 211 | 143 | info.State = protocol.SessionUp | |
| 212 | 144 | case catalogue.RemoteCatalogSessionDown: | |
| 213 | 145 | info.State = protocol.SessionDown | |
| 214 | - | case catalogue.RemoteCatalogSessionBroken: | |
| 215 | - | info.State = protocol.SessionBroken | |
| 216 | 146 | default: | |
| 217 | - | slog.Debug("remote catalog session has unknown state", "host", host, "session", session.Name, "state", session.State) | |
| 218 | 147 | info.State = protocol.SessionBroken | |
| 219 | 148 | } | |
| 220 | 149 | out = append(out, info) | |
| 221 | 150 | } | |
| 222 | 151 | return out | |
| 223 | 152 | } | |
| 224 | - | ||
| 225 | - | func mergeKnownHosts(deps remoteHostDeps) ([]domain.RemoteHost, error) { | |
| 226 | - | pinned, learned, err := deps.hostStore().Hosts() | |
| 227 | - | if err != nil { | |
| 228 | - | return nil, fmt.Errorf("vev: reading hosts: %w", err) | |
| 229 | - | } | |
| 230 | - | return mergeRemoteHosts(pinned, learned), nil | |
| 231 | - | } | |
| 232 | - | ||
| 233 | - | // mergeRemoteHosts returns pinned hosts in stored order, then learned-only | |
| 234 | - | // hosts in lexical order. Duplicates keep first occurrence and mark both sources. | |
| 235 | - | func mergeRemoteHosts(pinned, learned []domain.RemoteRegistration) []domain.RemoteHost { | |
| 236 | - | pinned = domain.UniqueRemoteRegistrations(pinned) | |
| 237 | - | learnedSet := make(map[string]struct{}, len(learned)) | |
| 238 | - | for _, record := range learned { | |
| 239 | - | learnedSet[record.Endpoint] = struct{}{} | |
| 240 | - | } | |
| 241 | - | pinnedSet := make(map[string]struct{}, len(pinned)) | |
| 242 | - | out := make([]domain.RemoteHost, 0, len(pinned)+len(learned)) | |
| 243 | - | for _, record := range pinned { | |
| 244 | - | pinnedSet[record.Endpoint] = struct{}{} | |
| 245 | - | _, isLearned := learnedSet[record.Endpoint] | |
| 246 | - | out = append(out, domain.RemoteHost{Target: record.Endpoint, Pinned: true, Learned: isLearned, Registration: record}) | |
| 247 | - | } | |
| 248 | - | learnedOnly := make([]domain.RemoteRegistration, 0, len(learned)) | |
| 249 | - | for _, record := range learned { | |
| 250 | - | if _, ok := pinnedSet[record.Endpoint]; !ok { | |
| 251 | - | learnedOnly = append(learnedOnly, record) | |
| 252 | - | } | |
| 253 | - | } | |
| 254 | - | learnedOnly = domain.UniqueRemoteRegistrations(learnedOnly) | |
| 255 | - | sort.Slice(learnedOnly, func(i, j int) bool { return learnedOnly[i].Endpoint < learnedOnly[j].Endpoint }) | |
| 256 | - | for _, record := range learnedOnly { | |
| 257 | - | out = append(out, domain.RemoteHost{Target: record.Endpoint, Learned: true, Registration: record}) | |
| 258 | - | } | |
| 259 | - | return out | |
| 260 | - | } | |
D internal/app/remote_preview.go +0 -81 collapsed to keep the page light
D internal/app/remote_test.go +0 -317 collapsed to keep the page light
M internal/app/run.go +198 -611 collapsed to keep the page light
A internal/app/run_broker_test.go +444 -0 collapsed to keep the page light
M internal/app/run_test.go +6 -657 collapsed to keep the page light
M internal/app/spawn.go +97 -52 collapsed to keep the page light
M internal/app/spawn_test.go +3 -10 collapsed to keep the page light
M internal/app/transport_observability_test.go +0 -72 collapsed to keep the page light
M internal/app/ui_driver.go +188 -710 collapsed to keep the page light
M internal/app/ui_driver_acceptance_test.go +222 -124 collapsed to keep the page light
A internal/app/ui_driver_client_test.go +351 -0 collapsed to keep the page light
A internal/app/ui_driver_fixture_test.go +349 -0 collapsed to keep the page light
M internal/app/ui_driver_test.go +56 -114 collapsed to keep the page light
M internal/app/web_daemon.go +64 -9 collapsed to keep the page light
A internal/app/web_daemon_broker_test.go +715 -0 collapsed to keep the page light
M internal/domain/config.go +18 -48 collapsed to keep the page light
M internal/ports/broker.go +29 -4 collapsed to keep the page light
A internal/ports/broker_acquisition.go +150 -0 collapsed to keep the page light
A internal/ports/broker_acquisition_test.go +57 -0 collapsed to keep the page light
M internal/ports/broker_pool.go +0 -28 collapsed to keep the page light
M internal/ports/broker_pool_test.go +10 -10 collapsed to keep the page light
M internal/ports/broker_store.go +106 -0 collapsed to keep the page light
M internal/ports/broker_store_test.go +138 -0 collapsed to keep the page light
A internal/ports/broker_stream_window.go +132 -0 collapsed to keep the page light
A internal/ports/broker_stream_window_test.go +86 -0 collapsed to keep the page light
M internal/ports/broker_test.go +25 -14 collapsed to keep the page light
A internal/ports/mocks/broker_acquisition_mock.go +198 -0 collapsed to keep the page light
M internal/ports/mocks/broker_mock.go +68 -15 collapsed to keep the page light
M internal/ports/mocks/broker_pool_mock.go +0 -95 collapsed to keep the page light
A internal/ports/mocks/session_admission_mock.go +90 -0 collapsed to keep the page light
A internal/ports/session_admission.go +176 -0 collapsed to keep the page light
A internal/ports/session_admission_test.go +274 -0 collapsed to keep the page light
M internal/ports/ui.go +27 -4 collapsed to keep the page light
A internal/ports/ui_status_test.go +32 -0 collapsed to keep the page light
M internal/protocol/routes.go +84 -0 collapsed to keep the page light
M internal/protocol/session.go +14 -14 collapsed to keep the page light
M internal/protocol/session_test.go +35 -0 collapsed to keep the page light
M internal/protocol/wire/broker.pb.go +19 -3 generated file: collapsed
M internal/protocol/wire/common.pb.go +149 -32 generated file: collapsed
M internal/protocol/wire/multiplex.pb.go +18 -4 generated file: collapsed
M internal/protocol/wire/schema/broker.proto +7 -0
| @@ -172,6 +172,10 @@ message Reconcile { | |||
| 172 | 172 | // stream names exactly how the daemon must admit it, while control and | |
| 173 | 173 | // observation streams carry none (0). name is validated session-name | |
| 174 | 174 | // authority for create-named and is empty for every other variant. | |
| 175 | + | // start_mode is the explicit daemon-start authorization carried to the | |
| 176 | + | // transport: observation and daemon-stop must be existing_only, while | |
| 177 | + | // attach/creation and the explicit list/mutate controls may be | |
| 178 | + | // start_if_needed. Zero is invalid and is refused by semantic validation. | |
| 175 | 179 | message OpenStream { | |
| 176 | 180 | BrokerStreamRef ref = 1; | |
| 177 | 181 | // Closed taxonomy: 1 = attachment, 2 = control, 3 = observation. | |
| @@ -189,6 +193,9 @@ message OpenStream { | |||
| 189 | 193 | uint32 admission = 9; | |
| 190 | 194 | // Validated session name for create-named; empty otherwise. | |
| 191 | 195 | string name = 10; | |
| 196 | + | // Closed daemon-start taxonomy (ports.BrokerDaemonStartMode): 1 = | |
| 197 | + | // existing_only, 2 = start_if_needed. Always present; zero is refused. | |
| 198 | + | uint32 start_mode = 11; | |
| 192 | 199 | } | |
| 193 | 200 | ||
| 194 | 201 | // ClientStreamData carries one opaque client-to-daemon stream frame. | |
M internal/protocol/wire/schema/common.proto +12 -0 collapsed to keep the page light
M internal/protocol/wire/schema/multiplex.proto +5 -0 collapsed to keep the page light
M internal/protocol/wire/schema/session.proto +6 -2 collapsed to keep the page light
M internal/protocol/wire/session.pb.go +57 -57 generated file: collapsed
M internal/usecase/broker/pool.go +123 -26 collapsed to keep the page light
M internal/usecase/broker/pool_idle_test.go +3 -3 collapsed to keep the page light
M internal/usecase/broker/pool_review_test.go +37 -12 collapsed to keep the page light
M internal/usecase/broker/pool_test.go +148 -29 collapsed to keep the page light
M internal/usecase/broker/registry.go +78 -4 collapsed to keep the page light
A internal/usecase/broker/registry_binding_test.go +77 -0 collapsed to keep the page light
M internal/usecase/broker/registry_durable_test.go +6 -6 collapsed to keep the page light
M internal/usecase/broker/registry_local.go +27 -5 collapsed to keep the page light
M internal/usecase/broker/registry_local_test.go +1 -1
| @@ -437,7 +437,7 @@ func TestRegistryLocalOnlyRefusesRestoredRemoteMembership(t *testing.T) { | |||
| 437 | 437 | func TestPoolLocalStreamsShareOnePhysicalEntry(t *testing.T) { | |
| 438 | 438 | var connects atomic.Int32 | |
| 439 | 439 | var physical *fakePhysical | |
| 440 | - | pool, _ := setupPool(t, func(_ context.Context, endpoint ports.BrokerResolvedEndpoint) (ports.BrokerPhysicalConnection, error) { | |
| 440 | + | pool, _ := setupPool(t, func(_ context.Context, endpoint ports.BrokerDialTarget) (ports.BrokerPhysicalConnection, error) { | |
| 441 | 441 | connects.Add(1) | |
| 442 | 442 | physical = &fakePhysical{endpoint: endpoint, done: make(chan struct{})} | |
| 443 | 443 | return physical, nil | |
A internal/usecase/broker/registry_lookup_test.go +57 -0 collapsed to keep the page light
M internal/usecase/broker/registry_membership_durable_test.go +7 -5 collapsed to keep the page light
M internal/usecase/broker/registry_membership_test.go +2 -2 collapsed to keep the page light
M internal/usecase/broker/registry_store_test.go +7 -7 collapsed to keep the page light
M internal/usecase/broker/registry_test.go +20 -7 collapsed to keep the page light
M internal/usecase/broker/service.go +27 -0 collapsed to keep the page light
M internal/usecase/broker/service_test.go +81 -7 collapsed to keep the page light
D internal/usecase/client/ack_queue_test.go +0 -209 collapsed to keep the page light
D internal/usecase/client/attachment_cache.go +0 -551 collapsed to keep the page light
D internal/usecase/client/attachment_cache_test.go +0 -612 collapsed to keep the page light
M internal/usecase/client/attachment_session.go +67 -15 collapsed to keep the page light
M internal/usecase/client/attachment_session_test.go +67 -7 collapsed to keep the page light
M internal/usecase/client/attachment_worker.go +90 -17 collapsed to keep the page light
M internal/usecase/client/attachment_worker_test.go +22 -6 collapsed to keep the page light
A internal/usecase/client/broker_operations.go +457 -0 collapsed to keep the page light
A internal/usecase/client/broker_operations_test.go +1117 -0 collapsed to keep the page light
D internal/usecase/client/client.go +0 -4096 large diff (4096 lines changed): collapsed
D internal/usecase/client/client_test.go +0 -1697 collapsed to keep the page light
D internal/usecase/client/clipboard_foreground_test.go +0 -107 collapsed to keep the page light
D internal/usecase/client/clipboard_intercept_internal_test.go +0 -186 collapsed to keep the page light
D internal/usecase/client/clipboard_intercept_test.go +0 -252 collapsed to keep the page light
D internal/usecase/client/host_registry_internal_test.go +0 -154 collapsed to keep the page light
D internal/usecase/client/hybrid_picker_route_test.go +0 -689 collapsed to keep the page light
A internal/usecase/client/initial_navigation.go +220 -0 collapsed to keep the page light
A internal/usecase/client/initial_navigation_test.go +502 -0 collapsed to keep the page light
D internal/usecase/client/inventory_attempt.go +0 -68 collapsed to keep the page light
D internal/usecase/client/inventory_attempt_test.go +0 -623 collapsed to keep the page light
D internal/usecase/client/inventory_cancellation_test.go +0 -56 collapsed to keep the page light
D internal/usecase/client/inventory_recovery_test.go +0 -91 collapsed to keep the page light
D internal/usecase/client/kitty_probe_test.go +0 -59 collapsed to keep the page light
M internal/usecase/client/lifecycle_p54a_test.go +15 -11 collapsed to keep the page light
D internal/usecase/client/navigation_fallback_capture_test.go +0 -84 collapsed to keep the page light
D internal/usecase/client/navigation_inventory.go +0 -352 collapsed to keep the page light
D internal/usecase/client/navigation_inventory_test.go +0 -348 collapsed to keep the page light
D internal/usecase/client/navigation_test.go +0 -52 collapsed to keep the page light
D internal/usecase/client/navigation_transition.go +0 -94 collapsed to keep the page light
D internal/usecase/client/navigation_transition_test.go +0 -100 collapsed to keep the page light
M internal/usecase/client/output_state.go +8 -0 collapsed to keep the page light
M internal/usecase/client/output_view_test.go +16 -2 collapsed to keep the page light
D internal/usecase/client/palette_attach_generation_test.go +0 -577 collapsed to keep the page light
D internal/usecase/client/palette_attach_test.go +0 -172 collapsed to keep the page light
M internal/usecase/client/picker_catalogue.go +143 -86 collapsed to keep the page light
M internal/usecase/client/picker_catalogue_test.go +71 -110 collapsed to keep the page light
M internal/usecase/client/picker_composition.go +2 -10 collapsed to keep the page light
D internal/usecase/client/picker_control.go +0 -87 collapsed to keep the page light
M internal/usecase/client/picker_controller.go +0 -12 collapsed to keep the page light
M internal/usecase/client/picker_controller_test.go +14 -13 collapsed to keep the page light
D internal/usecase/client/picker_e2e_support_test.go +0 -334 collapsed to keep the page light
D internal/usecase/client/picker_e2e_test.go +0 -300 collapsed to keep the page light
D internal/usecase/client/picker_input_test.go +0 -335 collapsed to keep the page light
D internal/usecase/client/picker_retire_test.go +0 -148 collapsed to keep the page light
D internal/usecase/client/reconnect.go +0 -70 collapsed to keep the page light
D internal/usecase/client/reconnect_toast_test.go +0 -1723 collapsed to keep the page light
D internal/usecase/client/remote_attach_validation_test.go +0 -63 collapsed to keep the page light
D internal/usecase/client/remote_cache_acceptance_test.go +0 -237 collapsed to keep the page light
D internal/usecase/client/route_deletion_test.go +0 -61 collapsed to keep the page light
D internal/usecase/client/route_observers.go +0 -67 collapsed to keep the page light
D internal/usecase/client/routes.go +0 -997 collapsed to keep the page light
D internal/usecase/client/routes_resume_identity_test.go +0 -36 collapsed to keep the page light
D internal/usecase/client/routes_test.go +0 -688 collapsed to keep the page light
A internal/usecase/client/runtime_primitives.go +67 -0 collapsed to keep the page light
D internal/usecase/client/same_peer_switch.go +0 -94 collapsed to keep the page light
D internal/usecase/client/same_peer_switch_test.go +0 -79 collapsed to keep the page light
D internal/usecase/client/session_connection.go +0 -99 collapsed to keep the page light
D internal/usecase/client/session_connection_internal_test.go +0 -33 collapsed to keep the page light
D internal/usecase/client/session_connection_test.go +0 -62 collapsed to keep the page light
A internal/usecase/client/session_environment.go +95 -0 collapsed to keep the page light
A internal/usecase/client/session_environment_test.go +94 -0 collapsed to keep the page light
M internal/usecase/client/supervisor.go +72 -28 collapsed to keep the page light
M internal/usecase/client/supervisor_attach.go +135 -21 collapsed to keep the page light
M internal/usecase/client/supervisor_attach_test.go +88 -38 collapsed to keep the page light
M internal/usecase/client/supervisor_integration_test.go +12 -0 collapsed to keep the page light
A internal/usecase/client/supervisor_presentation_test.go +81 -0 collapsed to keep the page light
M internal/usecase/client/supervisor_test.go +13 -0 collapsed to keep the page light
D internal/usecase/client/terminal_cause_test.go +0 -50 collapsed to keep the page light
D internal/usecase/client/terminal_input_pump_test.go +0 -505 collapsed to keep the page light
M internal/usecase/client/transition_ui.go +3 -6 collapsed to keep the page light
M internal/usecase/client/transition_ui_test.go +1 -1
| @@ -20,7 +20,7 @@ func TestTransitionMessageDescribesSwitchAndStoppedRestore(t *testing.T) { | |||
| 20 | 20 | Endpoint: "remote", DisplayOrigin: "remote", LifecycleID: domain.SessionLifecycleID{1}, | |
| 21 | 21 | SessionName: "work", Stopped: true, StoppedTab: domain.NewStableTabSelector("tab-1"), | |
| 22 | 22 | } | |
| 23 | - | stopped := protocol.AttachTarget{Endpoint: "remote", Session: "work", RemoteTarget: &stoppedTarget} | |
| 23 | + | stopped := protocol.AttachTarget{Endpoint: "remote", Session: "work", SessionTarget: &protocol.SessionAttachTarget{LifecycleID: stoppedTarget.LifecycleID, SessionName: stoppedTarget.SessionName, TabIndex: protocol.NoTabIndex, Stopped: true}} | |
| 24 | 24 | ||
| 25 | 25 | require.Equal(t, "Switching to work@remote…", transitionMessage(live)) | |
| 26 | 26 | require.Equal(t, "Starting work@remote…", transitionMessage(stopped)) | |
D internal/usecase/client/transport_observability_test.go +0 -159 collapsed to keep the page light
D internal/usecase/client/truecolor_test.go +0 -33 collapsed to keep the page light
D internal/usecase/client/typed_connection_external_test.go +0 -176 collapsed to keep the page light
D internal/usecase/client/typed_connection_test.go +0 -399 collapsed to keep the page light
M internal/usecase/client/ui.go +47 -20 collapsed to keep the page light
M internal/usecase/client/ui_actions.go +5 -4 collapsed to keep the page light
A internal/usecase/client/ui_clock_test.go +71 -0 collapsed to keep the page light
M internal/usecase/client/ui_handoff.go +3 -3 collapsed to keep the page light
D internal/usecase/client/ui_handoff_test.go +0 -100 collapsed to keep the page light
D internal/usecase/client/ui_input_pump_test.go +0 -150 collapsed to keep the page light
D internal/usecase/client/ui_input_test.go +0 -69 collapsed to keep the page light
A internal/usecase/client/ui_presentation_test.go +149 -0 collapsed to keep the page light
D internal/usecase/client/ui_publication_test.go +0 -149 collapsed to keep the page light
D internal/usecase/client/ui_remote_test.go +0 -146 collapsed to keep the page light
D internal/usecase/client/ui_runner_test.go +0 -186
| @@ -1,186 +0,0 @@ | |||
| 1 | - | package client | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "context" | |
| 5 | - | "io" | |
| 6 | - | "sync" | |
| 7 | - | "sync/atomic" | |
| 8 | - | "testing" | |
| 9 | - | "time" | |
| 10 | - | ||
| 11 | - | "github.com/bnema/vev/internal/adapters/uiterm" | |
| 12 | - | "github.com/bnema/vev/internal/domain" | |
| 13 | - | "github.com/bnema/vev/internal/ports" | |
| 14 | - | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 15 | - | "github.com/bnema/vev/internal/protocol" | |
| 16 | - | "github.com/stretchr/testify/mock" | |
| 17 | - | "github.com/stretchr/testify/require" | |
| 18 | - | ) | |
| 19 | - | ||
| 20 | - | func TestUIRunnerAppliesFenceReceiptAfterMetadataPublication(t *testing.T) { | |
| 21 | - | testUIRunnerAction(t, false) | |
| 22 | - | } | |
| 23 | - | ||
| 24 | - | func TestUIRunnerSamePeerActionWaitsForDestinationFull(t *testing.T) { | |
| 25 | - | testUIRunnerAction(t, true) | |
| 26 | - | } | |
| 27 | - | ||
| 28 | - | func testUIRunnerAction(t *testing.T, handoff bool) { | |
| 29 | - | ctx, cancel := context.WithCancel(context.Background()) | |
| 30 | - | t.Cleanup(cancel) | |
| 31 | - | terminal, err := uiterm.New(ctx, domain.Geometry{Size: domain.Size{Cols: 8, Rows: 2}}, "") | |
| 32 | - | require.NoError(t, err) | |
| 33 | - | t.Cleanup(terminal.Close) | |
| 34 | - | ui := NewUI(terminal, systemClock{}) | |
| 35 | - | incoming := make(chan protocol.ServerMessage, 16) | |
| 36 | - | closed := make(chan struct{}) | |
| 37 | - | var once sync.Once | |
| 38 | - | transport := portsmocks.NewMockClientConnection(t) | |
| 39 | - | transport.EXPECT().Close().RunAndReturn(func() error { once.Do(func() { close(closed) }); return nil }).Maybe() | |
| 40 | - | transport.EXPECT().Capabilities().Return(protocol.ConnectionCapabilities{}).Maybe() | |
| 41 | - | transport.EXPECT().LinkState().Return(ports.LinkStateConnected).Maybe() | |
| 42 | - | transport.EXPECT().LinkEvents().Return(nil).Maybe() | |
| 43 | - | transport.EXPECT().ReceiveServer().RunAndReturn(func() (protocol.ServerMessage, error) { | |
| 44 | - | select { | |
| 45 | - | case message := <-incoming: | |
| 46 | - | return message, nil | |
| 47 | - | case <-closed: | |
| 48 | - | return nil, io.EOF | |
| 49 | - | } | |
| 50 | - | }).Maybe() | |
| 51 | - | view := protocol.ViewContext{Publication: 1, Route: protocol.CommittedRouteIdentity{Target: protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "fixture"}}, TabID: "tab", FocusedPaneID: "pane"} | |
| 52 | - | var themeCount atomic.Int32 | |
| 53 | - | sentInput := make(chan protocol.Input, 16) | |
| 54 | - | fences := make(chan protocol.UIFence, 1) | |
| 55 | - | switches := make(chan protocol.SamePeerSwitchRequest, 1) | |
| 56 | - | routeSnapshots := make(chan protocol.RecentRouteSnapshot, 8) | |
| 57 | - | transport.EXPECT().SendClient(mock.Anything).RunAndReturn(func(message protocol.ClientMessage) error { | |
| 58 | - | switch message := message.(type) { | |
| 59 | - | case protocol.Theme: | |
| 60 | - | if themeCount.Add(1) == 2 { | |
| 61 | - | initial := view | |
| 62 | - | incoming <- protocol.Output{Epoch: 1, New: 1, Full: true, Size: domain.Size{Cols: 8, Rows: 2}, Context: &initial, Data: []byte("\x1b[Hready")} | |
| 63 | - | } | |
| 64 | - | case protocol.Input: | |
| 65 | - | sentInput <- message | |
| 66 | - | case protocol.UIFence: | |
| 67 | - | fences <- message | |
| 68 | - | case protocol.SamePeerSwitchRequest: | |
| 69 | - | switches <- message | |
| 70 | - | case protocol.RecentRouteSnapshot: | |
| 71 | - | routeSnapshots <- message | |
| 72 | - | } | |
| 73 | - | return nil | |
| 74 | - | }).Maybe() | |
| 75 | - | dialer := portsmocks.NewMockClientDialer(t) | |
| 76 | - | dialer.EXPECT().Dial(mock.Anything).Return(transport, nil).Once() | |
| 77 | - | runner := NewRunner(Dependencies{Dialer: dialer, Terminal: terminal, Clock: systemClock{}, UI: ui, DisableCapabilityProbe: true}) | |
| 78 | - | done := make(chan error, 1) | |
| 79 | - | go func() { done <- runner.Run(ctx, AttachRequest{Intent: protocol.IntentAttach, SessionName: "fixture"}) }() | |
| 80 | - | t.Cleanup(func() { | |
| 81 | - | cancel() | |
| 82 | - | select { | |
| 83 | - | case err := <-done: | |
| 84 | - | require.NoError(t, err) | |
| 85 | - | case <-time.After(time.Second): | |
| 86 | - | t.Error("Runner did not detach") | |
| 87 | - | } | |
| 88 | - | }) | |
| 89 | - | incoming <- protocol.Welcome{SessionID: "session", SessionName: "fixture", CommittedIdentity: &view.Route} | |
| 90 | - | attached := ports.UIStatusAttached | |
| 91 | - | _, err = ui.Wait(ctx, ports.UIWaitRequest{Attachment: ui.Handle(), Expect: ports.UIExpect{Status: &attached}}) | |
| 92 | - | require.NoError(t, err) | |
| 93 | - | result := make(chan ports.UIActionResult, 1) | |
| 94 | - | failed := make(chan error, 1) | |
| 95 | - | go func() { | |
| 96 | - | action, err := ui.Action(ctx, ports.UIActionRequest{Attachment: ui.Handle(), Generation: 1, Keys: []string{"Escape"}}) | |
| 97 | - | if err != nil { | |
| 98 | - | failed <- err | |
| 99 | - | return | |
| 100 | - | } | |
| 101 | - | result <- action | |
| 102 | - | }() | |
| 103 | - | var fence protocol.UIFence | |
| 104 | - | select { | |
| 105 | - | case fence = <-fences: | |
| 106 | - | case err := <-failed: | |
| 107 | - | t.Fatalf("action failed: %v", err) | |
| 108 | - | case <-time.After(time.Second): | |
| 109 | - | t.Fatal("missing labeled fence") | |
| 110 | - | } | |
| 111 | - | var input protocol.Input | |
| 112 | - | select { | |
| 113 | - | case input = <-sentInput: | |
| 114 | - | case <-time.After(time.Second): | |
| 115 | - | t.Fatal("input was not dispatched") | |
| 116 | - | } | |
| 117 | - | require.Equal(t, []byte("\x1b"), input.Data) | |
| 118 | - | require.Equal(t, fence.ActionID, input.ActionID) | |
| 119 | - | select { | |
| 120 | - | case <-result: | |
| 121 | - | t.Fatal("input dispatch alone completed action") | |
| 122 | - | default: | |
| 123 | - | } | |
| 124 | - | view.Publication = 2 | |
| 125 | - | if handoff { | |
| 126 | - | drainRouteSnapshots: | |
| 127 | - | for { | |
| 128 | - | select { | |
| 129 | - | case <-routeSnapshots: | |
| 130 | - | default: | |
| 131 | - | break drainRouteSnapshots | |
| 132 | - | } | |
| 133 | - | } | |
| 134 | - | target := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{2}, SessionName: "destination"} | |
| 135 | - | incoming <- protocol.AttachTarget{SamePeer: true, ExactTarget: &target, CauseActionID: fence.ActionID} | |
| 136 | - | select { | |
| 137 | - | case request := <-switches: | |
| 138 | - | require.Equal(t, target, request.Target) | |
| 139 | - | case <-time.After(time.Second): | |
| 140 | - | t.Fatal("missing existing same-peer switch request") | |
| 141 | - | } | |
| 142 | - | incoming <- protocol.CommittedRouteIdentity{Target: target} | |
| 143 | - | deadline := time.After(time.Second) | |
| 144 | - | committed := false | |
| 145 | - | for !committed { | |
| 146 | - | select { | |
| 147 | - | case snapshot := <-routeSnapshots: | |
| 148 | - | committed = snapshot.ActiveEntry.Target == target | |
| 149 | - | case <-deadline: | |
| 150 | - | t.Fatal("identity commit was not handled") | |
| 151 | - | } | |
| 152 | - | } | |
| 153 | - | select { | |
| 154 | - | case <-result: | |
| 155 | - | t.Fatal("identity alone completed the action") | |
| 156 | - | case err := <-failed: | |
| 157 | - | t.Fatalf("handoff failed: %v", err) | |
| 158 | - | default: | |
| 159 | - | } | |
| 160 | - | view.Route.Target = target | |
| 161 | - | incoming <- protocol.Output{Epoch: 2, New: 1, Full: true, Size: domain.Size{Cols: 8, Rows: 2}, Context: &view, Data: []byte("\x1b[2J\x1b[Hready")} | |
| 162 | - | } else { | |
| 163 | - | incoming <- protocol.UIViewUpdate{Epoch: 1, State: 1, Context: view} | |
| 164 | - | incoming <- protocol.UIReceipt{ActionID: fence.ActionID, Epoch: 1, State: 1, ViewPublication: 2, Outcome: protocol.UIReceiptProcessed} | |
| 165 | - | } | |
| 166 | - | var action ports.UIActionResult | |
| 167 | - | select { | |
| 168 | - | case action = <-result: | |
| 169 | - | case err := <-failed: | |
| 170 | - | t.Fatalf("action failed: %v", err) | |
| 171 | - | case <-time.After(time.Second): | |
| 172 | - | t.Fatal("applied receipt did not complete action") | |
| 173 | - | } | |
| 174 | - | require.True(t, action.Accepted) | |
| 175 | - | require.Equal(t, ports.UIActionProcessed, action.Status) | |
| 176 | - | require.Equal(t, uint64(2), action.Context.ViewPublication) | |
| 177 | - | require.Equal(t, ui.Handle(), action.Context.AttachmentHandle) | |
| 178 | - | if handoff { | |
| 179 | - | require.Equal(t, uint64(2), action.Context.Generation) | |
| 180 | - | require.Equal(t, "destination", action.Context.Route.Target.SessionName) | |
| 181 | - | } | |
| 182 | - | text := "ready" | |
| 183 | - | matched, err := ui.Wait(ctx, ports.UIWaitRequest{Attachment: ui.Handle(), AfterAction: action.ActionID, Expect: ports.UIExpect{TextContains: &text, Status: &attached}}) | |
| 184 | - | require.NoError(t, err) | |
| 185 | - | require.GreaterOrEqual(t, matched.Revision, action.Revision) | |
| 186 | - | } | |