| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "os" |
| 7 | "path/filepath" |
| 8 | "runtime" |
| 9 | "strings" |
| 10 | "sync" |
| 11 | "testing" |
| 12 | "time" |
| 13 | |
| 14 | "reasonix/internal/config" |
| 15 | "reasonix/internal/remote" |
| 16 | "reasonix/internal/remote/bootstrap" |
| 17 | "reasonix/internal/remote/forward" |
| 18 | "reasonix/internal/remote/sftpfs" |
| 19 | "reasonix/internal/remote/sshtest" |
| 20 | |
| 21 | "golang.org/x/crypto/ssh" |
| 22 | ) |
| 23 | |
| 24 | type lifecycleSSHClient struct { |
| 25 | mu sync.Mutex |
| 26 | startErr error |
| 27 | closed bool |
| 28 | sub func(remote.StatusEvent) |
| 29 | forwards *forward.Set |
| 30 | } |
| 31 | |
| 32 | type lifecycleEventSink struct { |
| 33 | statuses chan RemoteConnectionStatusView |
| 34 | } |
| 35 | |
| 36 | func (s *lifecycleEventSink) onStatus(v RemoteConnectionStatusView) { s.statuses <- v } |
| 37 | func (*lifecycleEventSink) onForwards(string, []RemoteForwardView) {} |
| 38 | func (*lifecycleEventSink) onServer(RemoteServerView) {} |
| 39 | |
| 40 | func newLifecycleSSHClient(startErr error) *lifecycleSSHClient { |
| 41 | return &lifecycleSSHClient{startErr: startErr, forwards: forward.NewSet(nil)} |
| 42 | } |
| 43 | |
| 44 | func TestDesktopSecretPromptPublishesMetadataAndReturnsOneShotSecret(t *testing.T) { |
| 45 | sink := &lifecycleEventSink{statuses: make(chan RemoteConnectionStatusView, 2)} |
| 46 | mgr := newDesktopRemoteManager(sink) |
| 47 | ctx, cancel := context.WithCancel(context.Background()) |
| 48 | defer cancel() |
| 49 | generation := &managedHost{ctx: ctx, cancel: cancel, status: RemoteConnectionStatusView{HostID: "box", State: "connecting"}} |
| 50 | mgr.hosts["box"] = generation |
| 51 | |
| 52 | type promptResult struct { |
| 53 | secret string |
| 54 | err error |
| 55 | } |
| 56 | result := make(chan promptResult, 1) |
| 57 | go func() { |
| 58 | secret, err := mgr.secretPrompt("box", generation)(ctx, remote.SecretPassword, "dev@box.test", "") |
| 59 | result <- promptResult{secret: secret, err: err} |
| 60 | }() |
| 61 | |
| 62 | var promptID string |
| 63 | select { |
| 64 | case status := <-sink.statuses: |
| 65 | if status.State != "pending_secret" || status.SecretPrompt == nil { |
| 66 | t.Fatalf("status = %+v", status) |
| 67 | } |
| 68 | if status.SecretPrompt.Host != "dev@box.test" || status.SecretPrompt.Kind != "password" { |
| 69 | t.Fatalf("prompt metadata = %+v", status.SecretPrompt) |
| 70 | } |
| 71 | promptID = status.SecretPrompt.PromptID |
| 72 | if promptID == "" { |
| 73 | t.Fatal("prompt ID was empty") |
| 74 | } |
| 75 | case <-time.After(2 * time.Second): |
| 76 | t.Fatal("secret prompt status was not emitted") |
| 77 | } |
| 78 | |
| 79 | if err := mgr.ResolveSecret("box", "stale-prompt", "wrong-secret", true); err == nil { |
| 80 | t.Fatal("stale prompt ID resolved the active credential request") |
| 81 | } |
| 82 | if err := mgr.ResolveSecret("box", promptID, "one-shot-secret", true); err != nil { |
| 83 | t.Fatal(err) |
| 84 | } |
| 85 | select { |
| 86 | case got := <-result: |
| 87 | if got.err != nil || got.secret != "one-shot-secret" { |
| 88 | t.Fatalf("prompt result = %+v", got) |
| 89 | } |
| 90 | case <-time.After(2 * time.Second): |
| 91 | t.Fatal("secret prompt did not resolve") |
| 92 | } |
| 93 | } |
| 94 | |
| 95 | func (c *lifecycleSSHClient) Start(context.Context) error { |
| 96 | c.mu.Lock() |
| 97 | sub, err := c.sub, c.startErr |
| 98 | c.mu.Unlock() |
| 99 | if sub != nil { |
| 100 | if err != nil { |
| 101 | sub(remote.StatusEvent{Status: remote.StatusStopped, Err: err}) |
| 102 | } else { |
| 103 | sub(remote.StatusEvent{Status: remote.StatusConnected}) |
| 104 | } |
| 105 | } |
| 106 | return err |
| 107 | } |
| 108 | |
| 109 | func (c *lifecycleSSHClient) Close() error { |
| 110 | c.mu.Lock() |
| 111 | if c.closed { |
| 112 | c.mu.Unlock() |
| 113 | return nil |
| 114 | } |
| 115 | c.closed = true |
| 116 | c.mu.Unlock() |
| 117 | c.forwards.Close() |
| 118 | return nil |
| 119 | } |
| 120 | |
| 121 | func (c *lifecycleSSHClient) Subscribe(fn func(remote.StatusEvent)) func() { |
| 122 | c.mu.Lock() |
| 123 | c.sub = fn |
| 124 | c.mu.Unlock() |
| 125 | fn(remote.StatusEvent{Status: remote.StatusIdle}) |
| 126 | return func() {} |
| 127 | } |
| 128 | |
| 129 | func (c *lifecycleSSHClient) Forwards() *forward.Set { return c.forwards } |
| 130 | func (c *lifecycleSSHClient) Exec(context.Context, string) (remote.ExecResult, error) { |
| 131 | return remote.ExecResult{}, nil |
| 132 | } |
| 133 | func (c *lifecycleSSHClient) SFTP() (*sftpfs.FS, error) { return nil, errors.New("unused") } |
| 134 | |
| 135 | func seedLifecycleHost(t *testing.T, hostID string) { |
| 136 | t.Helper() |
| 137 | home := t.TempDir() |
| 138 | t.Setenv("REASONIX_HOME", home) |
| 139 | t.Setenv("HOME", home) |
| 140 | if err := editUserConfig(func(c *config.Config) error { |
| 141 | return c.UpsertRemoteHost(config.RemoteHostEntry{Name: hostID, Host: "127.0.0.1", Port: 22, User: "tester"}) |
| 142 | }); err != nil { |
| 143 | t.Fatal(err) |
| 144 | } |
| 145 | } |
| 146 | |
| 147 | func TestConnectCanReplaceStoppedGeneration(t *testing.T) { |
| 148 | seedLifecycleHost(t, "box") |
| 149 | mgr := newDesktopRemoteManager(nil) |
| 150 | first := newLifecycleSSHClient(errors.New("first dial failed")) |
| 151 | second := newLifecycleSSHClient(nil) |
| 152 | var calls int |
| 153 | mgr.newClient = func(remote.Options) (desktopSSHClient, error) { |
| 154 | calls++ |
| 155 | if calls == 1 { |
| 156 | return first, nil |
| 157 | } |
| 158 | return second, nil |
| 159 | } |
| 160 | |
| 161 | if err := mgr.Connect("box"); err != nil { |
| 162 | t.Fatal(err) |
| 163 | } |
| 164 | deadline := time.Now().Add(2 * time.Second) |
| 165 | for { |
| 166 | statuses := mgr.Statuses() |
| 167 | if len(statuses) == 1 && statuses[0].State == "stopped" { |
| 168 | break |
| 169 | } |
| 170 | if time.Now().After(deadline) { |
| 171 | t.Fatalf("first generation did not stop: %+v", statuses) |
| 172 | } |
| 173 | time.Sleep(time.Millisecond) |
| 174 | } |
| 175 | if err := mgr.Connect("box"); err != nil { |
| 176 | t.Fatal(err) |
| 177 | } |
| 178 | if calls != 2 { |
| 179 | t.Fatalf("newClient calls = %d, want 2", calls) |
| 180 | } |
| 181 | first.mu.Lock() |
| 182 | firstClosed := first.closed |
| 183 | first.mu.Unlock() |
| 184 | if !firstClosed { |
| 185 | t.Fatal("replaced stopped client was not closed") |
| 186 | } |
| 187 | } |
| 188 | |
| 189 | func TestStaleClientStatusCannotOverwriteReplacement(t *testing.T) { |
| 190 | mgr := newDesktopRemoteManager(nil) |
| 191 | oldCtx, oldCancel := context.WithCancel(context.Background()) |
| 192 | defer oldCancel() |
| 193 | newCtx, newCancel := context.WithCancel(context.Background()) |
| 194 | defer newCancel() |
| 195 | old := &managedHost{ctx: oldCtx, cancel: oldCancel, client: newLifecycleSSHClient(nil)} |
| 196 | current := &managedHost{ |
| 197 | ctx: newCtx, cancel: newCancel, client: newLifecycleSSHClient(nil), |
| 198 | status: RemoteConnectionStatusView{HostID: "box", State: "connected"}, |
| 199 | } |
| 200 | mgr.hosts["box"] = current |
| 201 | mgr.onClientStatus("box", old, remote.StatusEvent{Status: remote.StatusStopped, Err: errors.New("late")}) |
| 202 | if got := mgr.Statuses()[0]; got.State != "connected" || got.Error != "" { |
| 203 | t.Fatalf("replacement status was overwritten: %+v", got) |
| 204 | } |
| 205 | } |
| 206 | |
| 207 | func TestServerLogsCancellationOnDisconnect(t *testing.T) { |
| 208 | sink := &lifecycleEventSink{statuses: make(chan RemoteConnectionStatusView, 1)} |
| 209 | mgr := newDesktopRemoteManager(sink) |
| 210 | hostCtx, hostCancel := context.WithCancel(context.Background()) |
| 211 | mh := &managedHost{ |
| 212 | ctx: hostCtx, cancel: hostCancel, client: newLifecycleSSHClient(nil), |
| 213 | serves: map[string]*serveEntry{"/work": {view: RemoteServerView{HostID: "box", Workspace: "/work", State: "ready"}}}, |
| 214 | } |
| 215 | mgr.hosts["box"] = mh |
| 216 | entered := make(chan struct{}) |
| 217 | mgr.serveLogs = func(ctx context.Context, _ bootstrap.Conn, _ string, _ int, _ *strings.Builder) error { |
| 218 | close(entered) |
| 219 | <-ctx.Done() |
| 220 | return ctx.Err() |
| 221 | } |
| 222 | done := make(chan error, 1) |
| 223 | go func() { |
| 224 | _, err := mgr.ServerLogs(context.Background(), "box", "/work", 20) |
| 225 | done <- err |
| 226 | }() |
| 227 | <-entered |
| 228 | if err := mgr.Disconnect("box"); err != nil { |
| 229 | t.Fatal(err) |
| 230 | } |
| 231 | select { |
| 232 | case status := <-sink.statuses: |
| 233 | if status.HostID != "box" || status.State != "stopped" { |
| 234 | t.Fatalf("Disconnect status = %+v", status) |
| 235 | } |
| 236 | default: |
| 237 | t.Fatal("Disconnect did not publish a stopped status") |
| 238 | } |
| 239 | select { |
| 240 | case err := <-done: |
| 241 | if !errors.Is(err, context.Canceled) { |
| 242 | t.Fatalf("ServerLogs error = %v, want context canceled", err) |
| 243 | } |
| 244 | case <-time.After(2 * time.Second): |
| 245 | t.Fatal("ServerLogs was not canceled by Disconnect") |
| 246 | } |
| 247 | } |
| 248 | |
| 249 | func TestEnsureServerResultCannotMutateReplacement(t *testing.T) { |
| 250 | seedLifecycleHost(t, "box") |
| 251 | mgr := newDesktopRemoteManager(nil) |
| 252 | hostCtx, hostCancel := context.WithCancel(context.Background()) |
| 253 | old := &managedHost{ctx: hostCtx, cancel: hostCancel, client: newLifecycleSSHClient(nil)} |
| 254 | mgr.hosts["box"] = old |
| 255 | entered := make(chan struct{}) |
| 256 | release := make(chan struct{}) |
| 257 | mgr.ensureServe = func(context.Context, bootstrap.Conn, bootstrap.Options) (bootstrap.Result, error) { |
| 258 | close(entered) |
| 259 | <-release |
| 260 | return bootstrap.Result{State: bootstrap.ServeState{Addr: "127.0.0.1:9999"}, Token: "old-token"}, nil |
| 261 | } |
| 262 | mgr.localBinary = func() string { return "" } |
| 263 | done := make(chan error, 1) |
| 264 | go func() { |
| 265 | _, _, err := mgr.EnsureServer(context.Background(), "box", "/old") |
| 266 | done <- err |
| 267 | }() |
| 268 | <-entered |
| 269 | if err := mgr.Disconnect("box"); err != nil { |
| 270 | t.Fatal(err) |
| 271 | } |
| 272 | newCtx, newCancel := context.WithCancel(context.Background()) |
| 273 | defer newCancel() |
| 274 | replacement := &managedHost{ |
| 275 | ctx: newCtx, cancel: newCancel, client: newLifecycleSSHClient(nil), |
| 276 | serves: map[string]*serveEntry{ |
| 277 | "/new": {view: RemoteServerView{HostID: "box", Workspace: "/new", State: "ready"}, token: "new-token"}, |
| 278 | }, |
| 279 | } |
| 280 | mgr.mu.Lock() |
| 281 | mgr.hosts["box"] = replacement |
| 282 | mgr.mu.Unlock() |
| 283 | close(release) |
| 284 | if err := <-done; err == nil { |
| 285 | t.Fatal("stale EnsureServer unexpectedly succeeded") |
| 286 | } |
| 287 | if got := mgr.ServerStatus("box", "/new"); got.Workspace != "/new" || got.State != "ready" { |
| 288 | t.Fatalf("replacement server state was overwritten: %+v", got) |
| 289 | } |
| 290 | if got := replacement.serves["/new"].token; got != "new-token" { |
| 291 | t.Fatalf("replacement token = %q, want new-token", got) |
| 292 | } |
| 293 | } |
| 294 | |
| 295 | func TestStopServerRejectsUnknownWorkspace(t *testing.T) { |
| 296 | mgr := newDesktopRemoteManager(nil) |
| 297 | hostCtx, hostCancel := context.WithCancel(context.Background()) |
| 298 | defer hostCancel() |
| 299 | mgr.hosts["box"] = &managedHost{ |
| 300 | ctx: hostCtx, cancel: hostCancel, client: newLifecycleSSHClient(nil), |
| 301 | serves: map[string]*serveEntry{"/srv/a": {view: RemoteServerView{HostID: "box", Workspace: "/srv/a", State: "ready"}}}, |
| 302 | } |
| 303 | called := false |
| 304 | mgr.stopServe = func(context.Context, bootstrap.Conn, string) error { called = true; return nil } |
| 305 | if err := mgr.StopServer("box", "/srv/missing"); err == nil { |
| 306 | t.Fatal("StopServer accepted an unknown workspace") |
| 307 | } |
| 308 | if called { |
| 309 | t.Fatal("StopServer called bootstrap.Stop for an untracked workspace") |
| 310 | } |
| 311 | } |
| 312 | |
| 313 | func TestDesktopCLIBinaryPathFallsBackToPATH(t *testing.T) { |
| 314 | dir := t.TempDir() |
| 315 | _, name := desktopCLIBinaryNames(runtime.GOOS) |
| 316 | cli := filepath.Join(dir, name) |
| 317 | if err := os.WriteFile(cli, []byte("test"), 0o755); err != nil { |
| 318 | t.Fatal(err) |
| 319 | } |
| 320 | t.Setenv("PATH", dir) |
| 321 | if got := desktopCLIBinaryPath(); got != cli { |
| 322 | t.Fatalf("desktopCLIBinaryPath = %q, want %q", got, cli) |
| 323 | } |
| 324 | } |
| 325 | |
| 326 | func TestDesktopCLIBinaryNamesAvoidWindowsPortableEntryCollision(t *testing.T) { |
| 327 | packaged, command := desktopCLIBinaryNames("windows") |
| 328 | if packaged != "reasonix-cli.exe" || command != "reasonix.exe" { |
| 329 | t.Fatalf("Windows CLI names = (%q, %q)", packaged, command) |
| 330 | } |
| 331 | if strings.EqualFold(packaged, "Reasonix.exe") { |
| 332 | t.Fatalf("packaged CLI %q collides with the desktop entry point", packaged) |
| 333 | } |
| 334 | if packaged, command := desktopCLIBinaryNames("linux"); packaged != "reasonix" || command != "reasonix" { |
| 335 | t.Fatalf("Linux CLI names = (%q, %q)", packaged, command) |
| 336 | } |
| 337 | } |
| 338 | |
| 339 | func TestHasUsableServeForwardRequiresExactTargetAndURL(t *testing.T) { |
| 340 | entries := []forward.Entry{{ |
| 341 | Spec: forward.Spec{Name: serveForwardName("/srv/a"), TargetAddr: "127.0.0.1:9000"}, |
| 342 | Up: true, BoundAddr: "127.0.0.1:45000", |
| 343 | }} |
| 344 | if !hasUsableServeForward(entries, serveForwardName("/srv/a"), "127.0.0.1:9000", "http://127.0.0.1:45000/") { |
| 345 | t.Fatal("exact existing serve forward was not reusable") |
| 346 | } |
| 347 | if hasUsableServeForward(entries, serveForwardName("/srv/a"), "127.0.0.1:9001", "http://127.0.0.1:45000/") { |
| 348 | t.Fatal("stale serve target was reused") |
| 349 | } |
| 350 | if hasUsableServeForward(entries, serveForwardName("/srv/a"), "127.0.0.1:9000", "http://127.0.0.1:45001/") { |
| 351 | t.Fatal("mismatched local URL was reused") |
| 352 | } |
| 353 | if hasUsableServeForward(entries, serveForwardName("/other"), "127.0.0.1:9000", "http://127.0.0.1:45000/") { |
| 354 | t.Fatal("another workspace's forward was reused") |
| 355 | } |
| 356 | } |
| 357 | |
| 358 | func TestDesktopNormalizeBind(t *testing.T) { |
| 359 | if got := desktopNormalizeBind("8080"); got != "127.0.0.1:8080" { |
| 360 | t.Fatalf("desktopNormalizeBind bare port = %q", got) |
| 361 | } |
| 362 | if got := desktopNormalizeBind("0.0.0.0:8080"); got != "0.0.0.0:8080" { |
| 363 | t.Fatalf("desktopNormalizeBind address = %q", got) |
| 364 | } |
| 365 | } |
| 366 | |
| 367 | func TestHostKeyPromptsAreSerializedForGlobalDialog(t *testing.T) { |
| 368 | sink := &lifecycleEventSink{statuses: make(chan RemoteConnectionStatusView, 2)} |
| 369 | mgr := newDesktopRemoteManager(sink) |
| 370 | ctx, cancel := context.WithCancel(context.Background()) |
| 371 | var wg sync.WaitGroup |
| 372 | t.Cleanup(func() { |
| 373 | cancel() |
| 374 | wg.Wait() |
| 375 | }) |
| 376 | type pendingPrompt struct { |
| 377 | hostID string |
| 378 | prompt remote.HostKeyPrompt |
| 379 | } |
| 380 | prompts := make([]pendingPrompt, 0, 2) |
| 381 | for _, hostID := range []string{"a", "b"} { |
| 382 | mh := &managedHost{ctx: ctx, cancel: cancel, client: newLifecycleSSHClient(nil)} |
| 383 | mgr.hosts[hostID] = mh |
| 384 | prompts = append(prompts, pendingPrompt{hostID: hostID, prompt: mgr.hostKeyPrompt(hostID, mh)}) |
| 385 | } |
| 386 | for _, pending := range prompts { |
| 387 | wg.Go(func() { |
| 388 | _, _ = pending.prompt(ctx, remote.HostKeyQuestion{ |
| 389 | Address: pending.hostID + ":22", |
| 390 | KeyType: "ssh-ed25519", |
| 391 | Fingerprint: pending.hostID, |
| 392 | }) |
| 393 | }) |
| 394 | } |
| 395 | |
| 396 | first := <-sink.statuses |
| 397 | select { |
| 398 | case second := <-sink.statuses: |
| 399 | t.Fatalf("second prompt %q replaced unresolved prompt %q", second.HostID, first.HostID) |
| 400 | case <-time.After(50 * time.Millisecond): |
| 401 | } |
| 402 | if err := mgr.ResolveHostKey(first.HostID, true); err != nil { |
| 403 | t.Fatal(err) |
| 404 | } |
| 405 | select { |
| 406 | case second := <-sink.statuses: |
| 407 | if second.HostID == first.HostID { |
| 408 | t.Fatalf("serialized prompt repeated host %q", second.HostID) |
| 409 | } |
| 410 | if err := mgr.ResolveHostKey(second.HostID, false); err != nil { |
| 411 | t.Fatal(err) |
| 412 | } |
| 413 | case <-time.After(2 * time.Second): |
| 414 | t.Fatal("second prompt did not appear after resolving the first") |
| 415 | } |
| 416 | } |
| 417 | |
| 418 | // TestEnsureServerFailureKeepsOwnershipOnPreviousReadyServe is the failed- |
| 419 | // start isolation contract: when a new workspace's Serve fails to start, the |
| 420 | // still-running previous workspace's entry stays untouched, so Stop and Logs |
| 421 | // keep operating on the workspace that actually runs. |
| 422 | func TestEnsureServerFailureKeepsOwnershipOnPreviousReadyServe(t *testing.T) { |
| 423 | seedLifecycleHost(t, "box") |
| 424 | mgr := newDesktopRemoteManager(nil) |
| 425 | hostCtx, hostCancel := context.WithCancel(context.Background()) |
| 426 | defer hostCancel() |
| 427 | client := newLifecycleSSHClient(nil) |
| 428 | mgr.hosts["box"] = &managedHost{ |
| 429 | ctx: hostCtx, cancel: hostCancel, client: client, |
| 430 | serves: map[string]*serveEntry{ |
| 431 | "/srv/a": {view: RemoteServerView{HostID: "box", Workspace: "/srv/a", State: "ready", LocalURL: "http://127.0.0.1:54321/"}, token: "token-a"}, |
| 432 | }, |
| 433 | } |
| 434 | mgr.ensureServe = func(context.Context, bootstrap.Conn, bootstrap.Options) (bootstrap.Result, error) { |
| 435 | return bootstrap.Result{}, errors.New("serve launch failed") |
| 436 | } |
| 437 | var stopped, logged []string |
| 438 | mgr.stopServe = func(_ context.Context, _ bootstrap.Conn, workspace string) error { |
| 439 | stopped = append(stopped, workspace) |
| 440 | return nil |
| 441 | } |
| 442 | mgr.serveLogs = func(_ context.Context, _ bootstrap.Conn, workspace string, _ int, _ *strings.Builder) error { |
| 443 | logged = append(logged, workspace) |
| 444 | return nil |
| 445 | } |
| 446 | |
| 447 | if _, _, err := mgr.EnsureServer(context.Background(), "box", "/srv/b"); err == nil { |
| 448 | t.Fatal("expected the serve launch failure") |
| 449 | } |
| 450 | status := mgr.ServerStatus("box", "/srv/a") |
| 451 | if status.State != "ready" || status.Workspace != "/srv/a" { |
| 452 | t.Fatalf("server state after failed start = %+v, want the previous ready /srv/a", status) |
| 453 | } |
| 454 | if got := mgr.hosts["box"].serves["/srv/a"].token; got != "token-a" { |
| 455 | t.Fatalf("token after failed start = %q, want the previous token", got) |
| 456 | } |
| 457 | if status := mgr.ServerStatus("box", "/srv/b"); status.State != "error" { |
| 458 | t.Fatalf("failed workspace state = %+v, want an error entry for /srv/b", status) |
| 459 | } |
| 460 | |
| 461 | if err := mgr.StopServer("box", "/srv/a"); err != nil { |
| 462 | t.Fatal(err) |
| 463 | } |
| 464 | if len(stopped) != 1 || stopped[0] != "/srv/a" { |
| 465 | t.Fatalf("StopServer operated on %v, want the previous /srv/a", stopped) |
| 466 | } |
| 467 | if _, err := mgr.ServerLogs(context.Background(), "box", "/srv/a", 50); err != nil { |
| 468 | t.Fatal(err) |
| 469 | } |
| 470 | if len(logged) != 1 || logged[0] != "/srv/a" { |
| 471 | t.Fatalf("ServerLogs operated on %v, want the previous /srv/a", logged) |
| 472 | } |
| 473 | } |
| 474 | |
| 475 | // TestEnsureServerReplaceFailureKeepsOwnershipOnPreviousReadyServe covers the |
| 476 | // same contract when the new Serve started but its loopback tunnel could not |
| 477 | // be bound: the just-started Serve is stopped, ownership returns to the |
| 478 | // previous ready Serve, and the failed view never replaces it. |
| 479 | func TestEnsureServerReplaceFailureKeepsOwnershipOnPreviousReadyServe(t *testing.T) { |
| 480 | seedLifecycleHost(t, "box") |
| 481 | mgr := newDesktopRemoteManager(nil) |
| 482 | hostCtx, hostCancel := context.WithCancel(context.Background()) |
| 483 | defer hostCancel() |
| 484 | client := newLifecycleSSHClient(nil) |
| 485 | // A closed forward set makes the tunnel Replace fail deterministically; |
| 486 | // the Set semantics keep any previous forward live on a failed Replace. |
| 487 | closedForwards := forward.NewSet(nil) |
| 488 | closedForwards.Close() |
| 489 | client.forwards = closedForwards |
| 490 | mgr.hosts["box"] = &managedHost{ |
| 491 | ctx: hostCtx, cancel: hostCancel, client: client, |
| 492 | serves: map[string]*serveEntry{ |
| 493 | "/srv/a": {view: RemoteServerView{HostID: "box", Workspace: "/srv/a", State: "ready", LocalURL: "http://127.0.0.1:54321/"}, token: "token-a"}, |
| 494 | }, |
| 495 | } |
| 496 | mgr.ensureServe = func(context.Context, bootstrap.Conn, bootstrap.Options) (bootstrap.Result, error) { |
| 497 | return bootstrap.Result{State: bootstrap.ServeState{Addr: "127.0.0.1:9999"}}, nil |
| 498 | } |
| 499 | var stopped []string |
| 500 | mgr.stopServe = func(_ context.Context, _ bootstrap.Conn, workspace string) error { |
| 501 | stopped = append(stopped, workspace) |
| 502 | return nil |
| 503 | } |
| 504 | |
| 505 | if _, _, err := mgr.EnsureServer(context.Background(), "box", "/srv/b"); err == nil { |
| 506 | t.Fatal("expected the tunnel Replace failure") |
| 507 | } |
| 508 | // The newly started /srv/b Serve was cleaned up, not left orphaned. |
| 509 | if len(stopped) != 1 || stopped[0] != "/srv/b" { |
| 510 | t.Fatalf("cleanup stopped %v, want the failed /srv/b", stopped) |
| 511 | } |
| 512 | status := mgr.ServerStatus("box", "/srv/a") |
| 513 | if status.State != "ready" || status.Workspace != "/srv/a" { |
| 514 | t.Fatalf("server state after failed tunnel bind = %+v, want the previous ready /srv/a", status) |
| 515 | } |
| 516 | if got := mgr.hosts["box"].serves["/srv/a"].token; got != "token-a" { |
| 517 | t.Fatalf("token after failed tunnel bind = %q, want the previous token", got) |
| 518 | } |
| 519 | } |
| 520 | |
| 521 | // TestEnsureServerFirstStartFailurePublishesError keeps the informative error |
| 522 | // view when there is no previous ready Serve to preserve. |
| 523 | func TestEnsureServerFirstStartFailurePublishesError(t *testing.T) { |
| 524 | seedLifecycleHost(t, "box") |
| 525 | mgr := newDesktopRemoteManager(nil) |
| 526 | hostCtx, hostCancel := context.WithCancel(context.Background()) |
| 527 | defer hostCancel() |
| 528 | mgr.hosts["box"] = &managedHost{ctx: hostCtx, cancel: hostCancel, client: newLifecycleSSHClient(nil)} |
| 529 | mgr.ensureServe = func(context.Context, bootstrap.Conn, bootstrap.Options) (bootstrap.Result, error) { |
| 530 | return bootstrap.Result{}, errors.New("serve launch failed") |
| 531 | } |
| 532 | if _, _, err := mgr.EnsureServer(context.Background(), "box", "/srv/b"); err == nil { |
| 533 | t.Fatal("expected the serve launch failure") |
| 534 | } |
| 535 | status := mgr.ServerStatus("box", "/srv/b") |
| 536 | if status.State != "error" || status.Workspace != "/srv/b" { |
| 537 | t.Fatalf("server state after first-start failure = %+v, want error /srv/b", status) |
| 538 | } |
| 539 | } |
| 540 | |
| 541 | // platformProbeClient scripts the uname probe CheckPlatform runs. |
| 542 | type platformProbeClient struct { |
| 543 | *lifecycleSSHClient |
| 544 | unameOut string |
| 545 | execErr error |
| 546 | } |
| 547 | |
| 548 | func (c *platformProbeClient) Exec(context.Context, string) (remote.ExecResult, error) { |
| 549 | return remote.ExecResult{Stdout: []byte(c.unameOut)}, c.execErr |
| 550 | } |
| 551 | |
| 552 | // TestCheckPlatformGatesUnsupportedOS applies the same ParseUname gate as |
| 553 | // EnsureServe at connect time: Linux/macOS pass, anything else fails with one |
| 554 | // clear message. |
| 555 | func TestCheckPlatformGatesUnsupportedOS(t *testing.T) { |
| 556 | cases := []struct { |
| 557 | name string |
| 558 | stdout string |
| 559 | execErr error |
| 560 | wantErr string |
| 561 | }{ |
| 562 | {name: "linux passes", stdout: "Linux x86_64\n"}, |
| 563 | {name: "darwin passes", stdout: "Darwin arm64\n"}, |
| 564 | {name: "mingw rejected", stdout: "MINGW64_NT-10.0-19045 x86_64\n", wantErr: "unsupported remote OS"}, |
| 565 | {name: "no uname rejected", stdout: "", wantErr: "cannot detect OS"}, |
| 566 | {name: "exec failure rejected", stdout: "Linux x86_64\n", execErr: errors.New("broken pipe"), wantErr: "cannot detect OS"}, |
| 567 | } |
| 568 | for _, tc := range cases { |
| 569 | t.Run(tc.name, func(t *testing.T) { |
| 570 | mgr := newDesktopRemoteManager(nil) |
| 571 | hostCtx, hostCancel := context.WithCancel(context.Background()) |
| 572 | defer hostCancel() |
| 573 | cl := &platformProbeClient{ |
| 574 | lifecycleSSHClient: newLifecycleSSHClient(nil), |
| 575 | unameOut: tc.stdout, |
| 576 | execErr: tc.execErr, |
| 577 | } |
| 578 | mgr.hosts["box"] = &managedHost{ |
| 579 | ctx: hostCtx, cancel: hostCancel, client: cl, |
| 580 | status: RemoteConnectionStatusView{HostID: "box", State: "connected"}, |
| 581 | } |
| 582 | err := mgr.CheckPlatform(context.Background(), "box") |
| 583 | if tc.wantErr == "" { |
| 584 | if err != nil { |
| 585 | t.Fatalf("unexpected error: %v", err) |
| 586 | } |
| 587 | return |
| 588 | } |
| 589 | if err == nil || !strings.Contains(err.Error(), tc.wantErr) { |
| 590 | t.Fatalf("error = %v, want containing %q", err, tc.wantErr) |
| 591 | } |
| 592 | }) |
| 593 | } |
| 594 | } |
| 595 | |
| 596 | func TestCheckPlatformRequiresConnection(t *testing.T) { |
| 597 | mgr := newDesktopRemoteManager(nil) |
| 598 | if err := mgr.CheckPlatform(context.Background(), "ghost"); err == nil || !strings.Contains(err.Error(), "not connected") { |
| 599 | t.Fatalf("err = %v, want not connected", err) |
| 600 | } |
| 601 | } |
| 602 | |
| 603 | // multiServeEventSink records the per-workspace server views the kernel |
| 604 | // publishes while keeping the status channel behavior of lifecycleEventSink. |
| 605 | type multiServeEventSink struct { |
| 606 | lifecycleEventSink |
| 607 | mu sync.Mutex |
| 608 | servers []RemoteServerView |
| 609 | } |
| 610 | |
| 611 | func (s *multiServeEventSink) onServer(v RemoteServerView) { |
| 612 | s.mu.Lock() |
| 613 | defer s.mu.Unlock() |
| 614 | s.servers = append(s.servers, v) |
| 615 | } |
| 616 | |
| 617 | func (s *multiServeEventSink) readyWorkspaces() []string { |
| 618 | s.mu.Lock() |
| 619 | defer s.mu.Unlock() |
| 620 | var out []string |
| 621 | for _, v := range s.servers { |
| 622 | if v.State == "ready" { |
| 623 | out = append(out, v.Workspace) |
| 624 | } |
| 625 | } |
| 626 | return out |
| 627 | } |
| 628 | |
| 629 | // newAttachedForwardsClient returns a lifecycleSSHClient whose forward set is |
| 630 | // attached to a real sshtest-backed SSH client, so EnsureServer's tunnel |
| 631 | // Replace path can bind local listeners. |
| 632 | func newAttachedForwardsClient(t *testing.T) *lifecycleSSHClient { |
| 633 | t.Helper() |
| 634 | srv := sshtest.Start(t, sshtest.Options{}) |
| 635 | cfg := &ssh.ClientConfig{User: "t", HostKeyCallback: ssh.InsecureIgnoreHostKey(), Timeout: 5 * time.Second} |
| 636 | sshCl, err := ssh.Dial("tcp", srv.Addr, cfg) |
| 637 | if err != nil { |
| 638 | t.Fatal(err) |
| 639 | } |
| 640 | t.Cleanup(func() { _ = sshCl.Close() }) |
| 641 | set := forward.NewSet(nil) |
| 642 | set.Attach(sshCl) |
| 643 | lc := newLifecycleSSHClient(nil) |
| 644 | lc.forwards = set |
| 645 | return lc |
| 646 | } |
| 647 | |
| 648 | // TestEnsureServerTwoWorkspacesIndependentForwards: one host, two workspaces — |
| 649 | // two slug-named tunnels, two ready entries, per-workspace events. |
| 650 | func TestEnsureServerTwoWorkspacesIndependentForwards(t *testing.T) { |
| 651 | seedLifecycleHost(t, "box") |
| 652 | sink := &multiServeEventSink{lifecycleEventSink: lifecycleEventSink{statuses: make(chan RemoteConnectionStatusView, 8)}} |
| 653 | mgr := newDesktopRemoteManager(sink) |
| 654 | hostCtx, hostCancel := context.WithCancel(context.Background()) |
| 655 | defer hostCancel() |
| 656 | cl := newAttachedForwardsClient(t) |
| 657 | mgr.hosts["box"] = &managedHost{ctx: hostCtx, cancel: hostCancel, client: cl, serves: map[string]*serveEntry{}} |
| 658 | mgr.ensureServe = func(_ context.Context, _ bootstrap.Conn, opts bootstrap.Options) (bootstrap.Result, error) { |
| 659 | addr := "127.0.0.1:9101" |
| 660 | if opts.Workspace == "/srv/b" { |
| 661 | addr = "127.0.0.1:9102" |
| 662 | } |
| 663 | return bootstrap.Result{State: bootstrap.ServeState{Addr: addr}, Token: "tok-" + opts.Workspace}, nil |
| 664 | } |
| 665 | mgr.localBinary = func() string { return "" } |
| 666 | |
| 667 | for _, ws := range []string{"/srv/a", "/srv/b"} { |
| 668 | view, _, err := mgr.EnsureServer(context.Background(), "box", ws) |
| 669 | if err != nil || view.State != "ready" || view.LocalURL == "" { |
| 670 | t.Fatalf("EnsureServer(%s) = %+v, %v", ws, view, err) |
| 671 | } |
| 672 | } |
| 673 | |
| 674 | forwards := cl.Forwards().List() |
| 675 | if len(forwards) != 2 { |
| 676 | t.Fatalf("forward count = %d, want 2 (%+v)", len(forwards), forwards) |
| 677 | } |
| 678 | names := map[string]bool{} |
| 679 | for _, f := range forwards { |
| 680 | names[f.Spec.Name] = true |
| 681 | if !f.Up { |
| 682 | t.Fatalf("forward %q is not up", f.Spec.Name) |
| 683 | } |
| 684 | } |
| 685 | if !names[serveForwardName("/srv/a")] || !names[serveForwardName("/srv/b")] { |
| 686 | t.Fatalf("forward names = %v, want per-workspace serve-%s and serve-%s", names, serveForwardName("/srv/a"), serveForwardName("/srv/b")) |
| 687 | } |
| 688 | if serveForwardName("/srv/a") == serveForwardName("/srv/b") { |
| 689 | t.Fatal("distinct workspaces share one forward name") |
| 690 | } |
| 691 | |
| 692 | if got := mgr.ServerStatus("box", "/srv/a"); got.State != "ready" || got.Workspace != "/srv/a" { |
| 693 | t.Fatalf("status A = %+v", got) |
| 694 | } |
| 695 | if got := mgr.ServerStatus("box", "/srv/b"); got.State != "ready" || got.Workspace != "/srv/b" { |
| 696 | t.Fatalf("status B = %+v", got) |
| 697 | } |
| 698 | if got := mgr.ServerStatus("box", "/srv/c"); got.State != "stopped" { |
| 699 | t.Fatalf("untracked workspace status = %+v, want stopped", got) |
| 700 | } |
| 701 | ready := sink.readyWorkspaces() |
| 702 | if len(ready) != 2 { |
| 703 | t.Fatalf("ready server events = %v, want one per workspace", ready) |
| 704 | } |
| 705 | } |
| 706 | |
| 707 | // TestStopServerOneWorkspaceKeepsOther: stopping A removes only A's tunnel and |
| 708 | // entry; B keeps serving. |
| 709 | func TestStopServerOneWorkspaceKeepsOther(t *testing.T) { |
| 710 | seedLifecycleHost(t, "box") |
| 711 | mgr := newDesktopRemoteManager(nil) |
| 712 | hostCtx, hostCancel := context.WithCancel(context.Background()) |
| 713 | defer hostCancel() |
| 714 | cl := newAttachedForwardsClient(t) |
| 715 | mgr.hosts["box"] = &managedHost{ctx: hostCtx, cancel: hostCancel, client: cl, serves: map[string]*serveEntry{}} |
| 716 | mgr.ensureServe = func(_ context.Context, _ bootstrap.Conn, opts bootstrap.Options) (bootstrap.Result, error) { |
| 717 | addr := "127.0.0.1:9111" |
| 718 | if opts.Workspace == "/srv/b" { |
| 719 | addr = "127.0.0.1:9112" |
| 720 | } |
| 721 | return bootstrap.Result{State: bootstrap.ServeState{Addr: addr}, Token: "tok"}, nil |
| 722 | } |
| 723 | var stopped []string |
| 724 | mgr.stopServe = func(_ context.Context, _ bootstrap.Conn, workspace string) error { |
| 725 | stopped = append(stopped, workspace) |
| 726 | return nil |
| 727 | } |
| 728 | mgr.localBinary = func() string { return "" } |
| 729 | for _, ws := range []string{"/srv/a", "/srv/b"} { |
| 730 | if _, _, err := mgr.EnsureServer(context.Background(), "box", ws); err != nil { |
| 731 | t.Fatal(err) |
| 732 | } |
| 733 | } |
| 734 | |
| 735 | if err := mgr.StopServer("box", "/srv/a"); err != nil { |
| 736 | t.Fatal(err) |
| 737 | } |
| 738 | if len(stopped) != 1 || stopped[0] != "/srv/a" { |
| 739 | t.Fatalf("stopServe operated on %v, want [/srv/a]", stopped) |
| 740 | } |
| 741 | forwards := cl.Forwards().List() |
| 742 | if len(forwards) != 1 || forwards[0].Spec.Name != serveForwardName("/srv/b") { |
| 743 | t.Fatalf("forwards after stop = %+v, want only /srv/b's", forwards) |
| 744 | } |
| 745 | if got := mgr.ServerStatus("box", "/srv/a"); got.State != "stopped" { |
| 746 | t.Fatalf("status A after stop = %+v", got) |
| 747 | } |
| 748 | if got := mgr.ServerStatus("box", "/srv/b"); got.State != "ready" { |
| 749 | t.Fatalf("status B after stopping A = %+v, want ready", got) |
| 750 | } |
| 751 | } |
| 752 |