diff --git a/acceptance/validate_test.go b/acceptance/validate_test.go index 8954eada..da93d04b 100644 --- a/acceptance/validate_test.go +++ b/acceptance/validate_test.go @@ -3,8 +3,10 @@ package acceptance import ( "crypto/ed25519" "crypto/rand" + "crypto/sha256" "encoding/json" "encoding/pem" + "fmt" "net/http/httptest" "os" "os/exec" @@ -393,3 +395,118 @@ func TestValidateRunRemoteUsesSSH(t *testing.T) { execReqs := filterByPath(reqs, "/api/v2/sidecar/instances/sidecar-123/exec") assert.Equal(t, len(execReqs), 0, "expected 0 HTTP exec requests (SSH should be used)") } + +func TestValidateAutoCreatesSidecar(t *testing.T) { + // Verify that chunk validate (no --remote) auto-creates a sidecar using the + // image stored in validation.sidecarImage when no active sidecar exists. + cci := fakes.NewFakeCircleCI() + cci.AddKeyURL = "127.0.0.1" // SSH will fail — no real server at port 2222 + srv := httptest.NewServer(cci) + defer srv.Close() + + workDir := gitrepo.SetupGitRepo(t, "test-org", "test-repo") + + // Write a config with a remote command and a snapshot image reference. + chunkDir := filepath.Join(workDir, ".chunk") + assert.NilError(t, os.MkdirAll(chunkDir, 0o755)) + cfg := map[string]interface{}{ + "commands": []map[string]interface{}{ + {"name": "test", "run": "echo test-output", "remote": true}, + }, + "validation": map[string]interface{}{ + "sidecarImage": "my-snapshot-abc123", + }, + } + data, err := json.Marshal(cfg) + assert.NilError(t, err) + assert.NilError(t, os.WriteFile(filepath.Join(chunkDir, "config.json"), data, 0o644)) + + sshDir := filepath.Join(t.TempDir(), ".ssh") + assert.NilError(t, os.MkdirAll(sshDir, 0o700)) + identityFile := filepath.Join(sshDir, "chunk_ai") + assert.NilError(t, generateTestSSHKey(t, identityFile)) + + env := testenv.NewTestEnv(t) + env.CircleCIURL = srv.URL + env.Extra["CIRCLECI_ORG_ID"] = "org-aaa" + + result := binary.RunCLI(t, []string{ + "validate", + "--identity-file", identityFile, + }, env, workDir) + + // SSH to 127.0.0.1:2222 fails — expected, but a sidecar must have been created first. + assert.Assert(t, result.ExitCode != 0, "expected failure because no SSH server is running") + + reqs := cci.Recorder.AllRequests() + + // A sidecar must have been created with the configured image. + createReqs := filterByPath(reqs, "/api/v2/sidecar/instances") + assert.Equal(t, len(createReqs), 1, "expected 1 create-sidecar request; got: %v", reqs) + + var body map[string]interface{} + assert.NilError(t, json.Unmarshal(createReqs[0].Body, &body)) + assert.Equal(t, body["image"], "my-snapshot-abc123", "expected sidecar image from config") + assert.Equal(t, body["org_id"], "org-aaa", "expected org from CIRCLECI_ORG_ID") + + // AddSSHKey must be called on the newly created sidecar — proves it was used. + addKeyReqs := filterByPath(reqs, "/api/v2/sidecar/instances/sidecar-new-123/ssh/add-key") + assert.Equal(t, len(addKeyReqs), 1, "expected 1 add-key request for newly created sidecar; got: %v", reqs) +} + +// writeRemoteProjectConfig writes a config with a single remote command. +func writeRemoteProjectConfig(t *testing.T, workDir string) { + t.Helper() + chunkDir := filepath.Join(workDir, ".chunk") + assert.NilError(t, os.MkdirAll(chunkDir, 0o755)) + cfg := `{"commands":[{"name":"test","run":"true","remote":true}]}` + assert.NilError(t, os.WriteFile(filepath.Join(chunkDir, "config.json"), []byte(cfg), 0o644)) +} + +// writeSidecarState writes a session-keyed sidecar state file into the test +// environment's XDG data directory for the given project root. +func writeSidecarState(t *testing.T, e *testenv.TestEnv, projectRoot, sessionID, sidecarID string) { + t.Helper() + // Resolve symlinks so the hash matches what os.Getwd() returns in the subprocess. + // On macOS, t.TempDir() returns /var/folders/... but os.Getwd() resolves to /private/var/... + realRoot, err := filepath.EvalSymlinks(projectRoot) + assert.NilError(t, err) + // Compute the data dir directly from e.HomeDir so we don't touch the parent process env. + // This mirrors config.ProjectDataDir: /chunk/ + sum := sha256.Sum256([]byte(filepath.Clean(realRoot))) + dir := filepath.Join(e.HomeDir, ".local", "share", "chunk", fmt.Sprintf("%x", sum)) + assert.NilError(t, os.MkdirAll(dir, 0o755)) + filename := "sidecar." + sessionID + ".json" + data := []byte(`{"sidecar_id":"` + sidecarID + `"}`) + assert.NilError(t, os.WriteFile(filepath.Join(dir, filename), data, 0o644)) +} + +// TestValidateHookMode_SessionIsolation verifies that two concurrent Claude +// sessions each see their own sidecar state rather than sharing one file. +func TestValidateHookMode_SessionIsolation(t *testing.T) { + workDir := gitrepo.SetupGitRepo(t, "test-org", "test-repo") + writeRemoteProjectConfig(t, workDir) + // Add an untracked file so the working tree is dirty and validate runs. + assert.NilError(t, os.WriteFile(filepath.Join(workDir, "dirty.txt"), []byte("x"), 0o644)) + + envA := testenv.NewTestEnv(t) + envB := testenv.NewTestEnv(t) + + writeSidecarState(t, envA, workDir, "sess-a", "sidecar-aaa") + writeSidecarState(t, envB, workDir, "sess-b", "sidecar-bbb") + + resultA := binary.RunCLIWithStdin(t, []string{"validate"}, envA, workDir, + hookStdin(t, "sess-a", true)) + resultB := binary.RunCLIWithStdin(t, []string{"validate"}, envB, workDir, + hookStdin(t, "sess-b", true)) + + assert.Assert(t, strings.Contains(resultA.Stderr, "sidecar-aaa"), + "session A should load sidecar-aaa; stderr: %s", resultA.Stderr) + assert.Assert(t, !strings.Contains(resultA.Stderr, "sidecar-bbb"), + "session A should not see sidecar-bbb; stderr: %s", resultA.Stderr) + + assert.Assert(t, strings.Contains(resultB.Stderr, "sidecar-bbb"), + "session B should load sidecar-bbb; stderr: %s", resultB.Stderr) + assert.Assert(t, !strings.Contains(resultB.Stderr, "sidecar-aaa"), + "session B should not see sidecar-aaa; stderr: %s", resultB.Stderr) +} diff --git a/acceptance/validate_variants_test.go b/acceptance/validate_variants_test.go new file mode 100644 index 00000000..3ebbe6b8 --- /dev/null +++ b/acceptance/validate_variants_test.go @@ -0,0 +1,271 @@ +package acceptance + +import ( + "encoding/json" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "gotest.tools/v3/assert" + + "github.com/CircleCI-Public/chunk-cli/internal/testing/binary" + testenv "github.com/CircleCI-Public/chunk-cli/internal/testing/env" + "github.com/CircleCI-Public/chunk-cli/internal/testing/fakes" + "github.com/CircleCI-Public/chunk-cli/internal/testing/recorder" + "github.com/CircleCI-Public/chunk-cli/internal/variants" +) + +// writeVariantsFile writes a variants JSON file to dir and returns its path. +func writeVariantsFile(t *testing.T, dir string, vs []variants.Variant) string { + t.Helper() + data, err := json.Marshal(vs) + assert.NilError(t, err) + path := filepath.Join(dir, "variants.json") + assert.NilError(t, os.WriteFile(path, data, 0o644)) + return path +} + +// writeChunkConfig writes a minimal .chunk/config.json with one remote command. +func writeChunkConfig(t *testing.T, dir string, extra map[string]interface{}) { + t.Helper() + chunkDir := filepath.Join(dir, ".chunk") + assert.NilError(t, os.MkdirAll(chunkDir, 0o755)) + cfg := map[string]interface{}{ + "commands": []map[string]interface{}{ + {"name": "test", "run": "go test ./...", "remote": true}, + }, + } + for k, v := range extra { + cfg[k] = v + } + data, err := json.Marshal(cfg) + assert.NilError(t, err) + assert.NilError(t, os.WriteFile(filepath.Join(chunkDir, "config.json"), data, 0o644)) +} + +func filterVariantRequests(reqs []recorder.RecordedRequest, method, pathPrefix string) []recorder.RecordedRequest { + var out []recorder.RecordedRequest + for _, r := range reqs { + if r.Method == method && strings.HasPrefix(r.URL.Path, pathPrefix) { + out = append(out, r) + } + } + return out +} + +func TestValidateVariantsMissingFile(t *testing.T) { + env := testenv.NewTestEnv(t) + + result := binary.RunCLI(t, []string{ + "validate", "variants", "/nonexistent/variants.json", + "--org-id", "org-aaa", + }, env, env.HomeDir) + + assert.Assert(t, result.ExitCode != 0, "expected non-zero exit code") +} + +func TestValidateVariantsMissingArg(t *testing.T) { + env := testenv.NewTestEnv(t) + + result := binary.RunCLI(t, []string{"validate", "variants"}, env, env.HomeDir) + + assert.Assert(t, result.ExitCode != 0, "expected non-zero exit code") +} + +func TestValidateVariantsMalformedJSON(t *testing.T) { + env := testenv.NewTestEnv(t) + path := filepath.Join(env.HomeDir, "bad.json") + assert.NilError(t, os.WriteFile(path, []byte("not json"), 0o644)) + + result := binary.RunCLI(t, []string{ + "validate", "variants", path, + "--org-id", "org-aaa", + }, env, env.HomeDir) + + assert.Assert(t, result.ExitCode != 0, "expected non-zero exit code") +} + +func TestValidateVariantsEmptyArray(t *testing.T) { + env := testenv.NewTestEnv(t) + path := filepath.Join(env.HomeDir, "empty.json") + assert.NilError(t, os.WriteFile(path, []byte("[]"), 0o644)) + + result := binary.RunCLI(t, []string{ + "validate", "variants", path, + "--org-id", "org-aaa", + }, env, env.HomeDir) + + assert.Equal(t, result.ExitCode, 0, "stderr: %s", result.Stderr) + assert.Assert(t, strings.Contains(result.Stderr, "No variants"), + "expected 'No variants' in stderr, got: %s", result.Stderr) +} + +func TestValidateVariantsMissingToken(t *testing.T) { + env := testenv.NewTestEnv(t) + env.CircleToken = "" + + workDir := env.HomeDir + writeChunkConfig(t, workDir, nil) + path := writeVariantsFile(t, workDir, []variants.Variant{ + {ID: "MUT-001", Description: "test"}, + }) + + result := binary.RunCLI(t, []string{ + "validate", "variants", path, + "--org-id", "org-aaa", + }, env, workDir) + + assert.Assert(t, result.ExitCode != 0, "expected non-zero exit code") +} + +func TestValidateVariantsNoRemoteCommands(t *testing.T) { + cci := fakes.NewFakeCircleCI() + srv := httptest.NewServer(cci) + defer srv.Close() + + env := testenv.NewTestEnv(t) + env.CircleCIURL = srv.URL + + workDir := env.HomeDir + // Write config with only local (non-remote) commands. + chunkDir := filepath.Join(workDir, ".chunk") + assert.NilError(t, os.MkdirAll(chunkDir, 0o755)) + cfg := `{"commands":[{"name":"test","run":"go test ./...","remote":false}]}` + assert.NilError(t, os.WriteFile(filepath.Join(chunkDir, "config.json"), []byte(cfg), 0o644)) + + path := writeVariantsFile(t, workDir, []variants.Variant{ + {ID: "MUT-001"}, + }) + + result := binary.RunCLI(t, []string{ + "validate", "variants", path, + "--org-id", "org-aaa", + }, env, workDir) + + assert.Assert(t, result.ExitCode != 0, "expected non-zero exit code") + combined := result.Stdout + result.Stderr + assert.Assert(t, strings.Contains(combined, "remote"), + "expected 'remote' in error output, got: %s", combined) +} + +func TestValidateVariantsNamedCommand(t *testing.T) { + cci := fakes.NewFakeCircleCI() + srv := httptest.NewServer(cci) + defer srv.Close() + + env := testenv.NewTestEnv(t) + env.CircleCIURL = srv.URL + + workDir := env.HomeDir + writeChunkConfig(t, workDir, nil) + path := writeVariantsFile(t, workDir, []variants.Variant{ + {ID: "MUT-001"}, + }) + + // --name references a command that is not marked remote; still accepted since + // --name bypasses the remote filter. + result := binary.RunCLI(t, []string{ + "validate", "variants", path, + "--org-id", "org-aaa", + "--name", "test", + }, env, workDir) + + // Command exits 0; individual errors are in JSON results (Sync fails — no SSH). + assert.Equal(t, result.ExitCode, 0, "stderr: %s\nstdout: %s", result.Stderr, result.Stdout) +} + +func TestValidateVariantsUnknownNamedCommand(t *testing.T) { + cci := fakes.NewFakeCircleCI() + srv := httptest.NewServer(cci) + defer srv.Close() + + env := testenv.NewTestEnv(t) + env.CircleCIURL = srv.URL + + workDir := env.HomeDir + writeChunkConfig(t, workDir, nil) + path := writeVariantsFile(t, workDir, []variants.Variant{ + {ID: "MUT-001"}, + }) + + result := binary.RunCLI(t, []string{ + "validate", "variants", path, + "--org-id", "org-aaa", + "--name", "nonexistent", + }, env, workDir) + + assert.Assert(t, result.ExitCode != 0, "expected non-zero exit code") +} + +func TestValidateVariantsCreatesAndDeletesSidecars(t *testing.T) { + cci := fakes.NewFakeCircleCI() + srv := httptest.NewServer(cci) + defer srv.Close() + + env := testenv.NewTestEnv(t) + env.CircleCIURL = srv.URL + + workDir := env.HomeDir + writeChunkConfig(t, workDir, nil) + path := writeVariantsFile(t, workDir, []variants.Variant{ + {ID: "MUT-001"}, + {ID: "MUT-002"}, + }) + + result := binary.RunCLI(t, []string{ + "validate", "variants", path, + "--org-id", "org-aaa", + "--image", "snap-abc", + }, env, workDir) + + // Command exits 0; Sync fails (no git repo / no SSH) but that's captured in Result.Error. + assert.Equal(t, result.ExitCode, 0, "stderr: %s\nstdout: %s", result.Stderr, result.Stdout) + + reqs := cci.Recorder.AllRequests() + creates := filterVariantRequests(reqs, "POST", "/api/v2/sidecar/instances") + deletes := filterVariantRequests(reqs, "DELETE", "/api/v2/sidecar/instances/") + assert.Check(t, len(creates) >= 2, "expected 2 create requests, got %d", len(creates)) + assert.Check(t, len(deletes) >= 2, "expected 2 delete requests, got %d", len(deletes)) + + // Output should be valid JSON array. + var results []map[string]interface{} + assert.NilError(t, json.Unmarshal([]byte(result.Stdout), &results), + "expected valid JSON array in stdout, got: %s", result.Stdout) + assert.Equal(t, len(results), 2) +} + +func TestValidateVariantsImageFromConfig(t *testing.T) { + cci := fakes.NewFakeCircleCI() + srv := httptest.NewServer(cci) + defer srv.Close() + + env := testenv.NewTestEnv(t) + env.CircleCIURL = srv.URL + + workDir := env.HomeDir + writeChunkConfig(t, workDir, map[string]interface{}{ + "validation": map[string]interface{}{ + "sidecarImage": "snap-from-config", + }, + }) + path := writeVariantsFile(t, workDir, []variants.Variant{ + {ID: "MUT-001"}, + }) + + binary.RunCLI(t, []string{ + "validate", "variants", path, + "--org-id", "org-aaa", + // No --image flag; should use config value. + }, env, workDir) + + reqs := cci.Recorder.AllRequests() + creates := filterVariantRequests(reqs, "POST", "/api/v2/sidecar/instances") + assert.Assert(t, len(creates) >= 1, "expected at least 1 create request") + + var body map[string]interface{} + assert.NilError(t, json.Unmarshal(creates[0].Body, &body)) + assert.Equal(t, body["image"], "snap-from-config", + "expected image from config, got: %v", body["image"]) +} diff --git a/go.mod b/go.mod index 628b0380..3dbdbada 100644 --- a/go.mod +++ b/go.mod @@ -14,7 +14,7 @@ require ( github.com/sethvargo/go-envconfig v1.3.0 github.com/spf13/cobra v1.10.2 golang.org/x/crypto v0.50.0 - golang.org/x/term v0.42.0 + golang.org/x/term v0.43.0 gotest.tools/v3 v3.5.2 ) @@ -256,7 +256,7 @@ require ( golang.org/x/mod v0.34.0 // indirect golang.org/x/net v0.52.0 // indirect golang.org/x/sync v0.20.0 // indirect - golang.org/x/sys v0.43.0 // indirect + golang.org/x/sys v0.44.0 // indirect golang.org/x/text v0.36.0 // indirect golang.org/x/tools v0.43.0 // indirect google.golang.org/protobuf v1.36.11 // indirect diff --git a/go.sum b/go.sum index 161f59b3..046f6d39 100644 --- a/go.sum +++ b/go.sum @@ -895,16 +895,16 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= -golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= +golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= -golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= -golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= +golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= +golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= diff --git a/internal/cmd/sidecar.go b/internal/cmd/sidecar.go index 909da650..cc0503b4 100644 --- a/internal/cmd/sidecar.go +++ b/internal/cmd/sidecar.go @@ -52,11 +52,11 @@ func newSidecarCmd() *cobra.Command { } // resolveSidecarID fills in sidecarID from the active sidecar file if it is empty. -func resolveSidecarID(sidecarID *string) error { +func resolveSidecarID(ctx context.Context, sidecarID *string) error { if *sidecarID != "" { return nil } - active, err := sidecar.LoadActive() + active, err := sidecar.LoadActive(ctx) if err != nil { return &userError{msg: "Could not load the active sidecar.", suggestion: configFilePermHint, err: err} } @@ -190,7 +190,7 @@ func newSidecarCreateCmd() *cobra.Command { } } io.ErrPrintf("%s\n", ui.Success(fmt.Sprintf("Created sidecar %s (%s)", sb.Name, sb.ID))) - if err := sidecar.SaveActive(sidecar.ActiveSidecar{SidecarID: sb.ID, Name: sb.Name}); err != nil { + if err := sidecar.SaveActive(cmd.Context(), sidecar.ActiveSidecar{SidecarID: sb.ID, Name: sb.Name}); err != nil { io.ErrPrintf("warning: could not save active sidecar: %v\n", err) } else { io.ErrPrintf("Set %s as active sidecar\n", sb.ID) @@ -216,7 +216,7 @@ func newSidecarExecCmd() *cobra.Command { Args: cobra.ArbitraryArgs, RunE: func(cmd *cobra.Command, args []string) error { io := iostream.FromCmd(cmd) - if err := resolveSidecarID(&sidecarID); err != nil { + if err := resolveSidecarID(cmd.Context(), &sidecarID); err != nil { return err } client, err := ensureCircleCIClient(cmd.Context(), io, tui.PromptHidden) @@ -260,7 +260,7 @@ func newSidecarAddSSHKeyCmd() *cobra.Command { Short: "Add an SSH public key to a sidecar", RunE: func(cmd *cobra.Command, _ []string) error { io := iostream.FromCmd(cmd) - if err := resolveSidecarID(&sidecarID); err != nil { + if err := resolveSidecarID(cmd.Context(), &sidecarID); err != nil { return err } client, err := ensureCircleCIClient(cmd.Context(), io, tui.PromptHidden) @@ -315,7 +315,7 @@ func newSidecarSSHCmd() *cobra.Command { Args: cobra.ArbitraryArgs, RunE: func(cmd *cobra.Command, args []string) error { io := iostream.FromCmd(cmd) - if err := resolveSidecarID(&sidecarID); err != nil { + if err := resolveSidecarID(cmd.Context(), &sidecarID); err != nil { return err } authSock := os.Getenv(config.EnvSSHAuthSock) @@ -381,7 +381,7 @@ func newSidecarSyncCmd() *cobra.Command { Short: "Sync files to a sidecar", RunE: func(cmd *cobra.Command, _ []string) error { io := iostream.FromCmd(cmd) - if err := resolveSidecarID(&sidecarID); err != nil { + if err := resolveSidecarID(cmd.Context(), &sidecarID); err != nil { return err } authSock := os.Getenv(config.EnvSSHAuthSock) @@ -427,7 +427,7 @@ func newSidecarUseCmd() *cobra.Command { Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { io := iostream.FromCmd(cmd) - if err := sidecar.SaveActive(sidecar.ActiveSidecar{SidecarID: args[0]}); err != nil { + if err := sidecar.SaveActive(cmd.Context(), sidecar.ActiveSidecar{SidecarID: args[0]}); err != nil { return &userError{msg: "Could not save the active sidecar.", suggestion: configFilePermHint, err: err} } io.ErrPrintf("Set %s as active sidecar\n", args[0]) @@ -442,7 +442,7 @@ func newSidecarCurrentCmd() *cobra.Command { Short: "Show the active sidecar", RunE: func(cmd *cobra.Command, _ []string) error { io := iostream.FromCmd(cmd) - active, err := sidecar.LoadActive() + active, err := sidecar.LoadActive(cmd.Context()) if err != nil { return &userError{msg: "Could not load the active sidecar.", suggestion: configFilePermHint, err: err} } @@ -466,7 +466,7 @@ func newSidecarForgetCmd() *cobra.Command { Short: "Clear the active sidecar", RunE: func(cmd *cobra.Command, _ []string) error { io := iostream.FromCmd(cmd) - if err := sidecar.ClearActive(); err != nil { + if err := sidecar.ClearActive(cmd.Context()); err != nil { return &userError{msg: "Could not clear the active sidecar.", suggestion: configFilePermHint, err: err} } io.ErrPrintln("Active sidecar cleared") @@ -649,7 +649,7 @@ snapshot with 'chunk sidecar create --image '.`, if len(name) > 255 { return fmt.Errorf("snapshot name must be 255 characters or fewer (got %d)", len(name)) } - if err := resolveSidecarID(&sidecarID); err != nil { + if err := resolveSidecarID(cmd.Context(), &sidecarID); err != nil { return err } client, err := ensureCircleCIClient(cmd.Context(), io, tui.PromptHidden) @@ -669,8 +669,8 @@ snapshot with 'chunk sidecar create --image '.`, } io.ErrPrintf("%s\n", ui.Success(fmt.Sprintf("Deleted sidecar %s", sidecarID))) - if active, lerr := sidecar.LoadActive(); lerr == nil && active != nil && active.SidecarID == sidecarID { - if cerr := sidecar.ClearActive(); cerr != nil { + if active, lerr := sidecar.LoadActive(cmd.Context()); lerr == nil && active != nil && active.SidecarID == sidecarID { + if cerr := sidecar.ClearActive(cmd.Context()); cerr != nil { io.ErrPrintf("Warning: could not clear active sidecar state: %v\n", cerr) } } @@ -822,7 +822,7 @@ func sidecarSetupResolveSidecar( status iostream.StatusFunc, streams iostream.Streams, ) (id, displayName, workspace string, err error) { - active, err := sidecar.LoadActive() + active, err := sidecar.LoadActive(ctx) if err != nil { return "", "", "", &userError{msg: "Could not load the active sidecar.", suggestion: configFilePermHint, err: err} } @@ -849,7 +849,7 @@ func sidecarSetupResolveSidecar( err: err, } } - if saveErr := sidecar.SaveActive(sidecar.ActiveSidecar{SidecarID: sc.ID, Name: sc.Name}); saveErr != nil { + if saveErr := sidecar.SaveActive(ctx, sidecar.ActiveSidecar{SidecarID: sc.ID, Name: sc.Name}); saveErr != nil { streams.ErrPrintf("warning: could not save active sidecar: %v\n", saveErr) } status(iostream.LevelDone, fmt.Sprintf("Created sidecar %s (%s)", sc.Name, sc.ID)) @@ -917,7 +917,7 @@ func sidecarSetupRunSetup( ws := workspace if ws == "" { - if active, lerr := sidecar.LoadActive(); lerr == nil && active != nil && active.Workspace != "" { + if active, lerr := sidecar.LoadActive(ctx); lerr == nil && active != nil && active.Workspace != "" { ws = active.Workspace } } diff --git a/internal/cmd/usererr.go b/internal/cmd/usererr.go index 2c4a1c03..536ca6a2 100644 --- a/internal/cmd/usererr.go +++ b/internal/cmd/usererr.go @@ -1,6 +1,7 @@ package cmd import ( + "context" "errors" "fmt" @@ -44,6 +45,13 @@ func sshSessionError(err error) error { err: err, } } + if errors.Is(err, context.DeadlineExceeded) { + return &userError{ + msg: "Request timed out.", + suggestion: "Try again. This request may time out on initial key registration.", + err: err, + } + } return nil } diff --git a/internal/cmd/validate.go b/internal/cmd/validate.go index 33179b77..d76549af 100644 --- a/internal/cmd/validate.go +++ b/internal/cmd/validate.go @@ -16,6 +16,7 @@ import ( "github.com/CircleCI-Public/chunk-cli/internal/config" "github.com/CircleCI-Public/chunk-cli/internal/iostream" + "github.com/CircleCI-Public/chunk-cli/internal/session" "github.com/CircleCI-Public/chunk-cli/internal/sidecar" "github.com/CircleCI-Public/chunk-cli/internal/tui" "github.com/CircleCI-Public/chunk-cli/internal/ui" @@ -83,7 +84,9 @@ func newValidateCmd() *cobra.Command { } hook := detectHook(cmd.InOrStdin()) + ctx := cmd.Context() if hook != nil { + ctx = session.WithID(ctx, hook.sessionID) if !hook.stopHookActive { validate.ResetAttempts(hook.sessionID) } @@ -145,26 +148,15 @@ func newValidateCmd() *cobra.Command { if remote { // --remote: force all commands to sidecar, creating one if needed. - if err := resolveOrCreateSidecarID(cmd.Context(), &sidecarID, orgID, image, workDir, streams); err != nil { + if err := resolveOrCreateSidecarID(ctx, &sidecarID, orgID, image, workDir, streams); err != nil { return err } statusFn(iostream.LevelInfo, fmt.Sprintf("running all commands on sidecar %s", sidecarID)) } else if cfg.HasRemoteCommands() { - // Per-command remote: use active sidecar if available. - if active, err := sidecar.LoadActive(); err == nil && active != nil { - sidecarID = active.SidecarID - statusFn(iostream.LevelInfo, fmt.Sprintf("using sidecar %s for remote commands", sidecarID)) - } else if hook != nil { - // In Stop hook context: auto-create a sandbox if possible. - if err := resolveOrCreateSidecarID(cmd.Context(), &sidecarID, orgID, image, workDir, streams); err != nil { - streams.ErrPrintf("warning: no sandbox available (%v); run 'chunk config set orgID ' to enable remote validation, running locally instead\n", err) - } - } else { - statusFn(iostream.LevelWarn, "no active sidecar found — remote commands will run locally") - } + resolveSidecar(cmd.Context(), &sidecarID, orgID, image, workDir, hook, streams) } - execErr := runValidate(cmd.Context(), workDir, name, inlineCmd, save, sidecarID, identityFile, workdir, allRemote, cfg, statusFn, streams) + execErr := runValidate(ctx, workDir, name, inlineCmd, save, sidecarID, identityFile, workdir, allRemote, cfg, statusFn, streams) if hook != nil { maxAttempts := cfg.StopHookMaxAttempts @@ -188,6 +180,8 @@ func newValidateCmd() *cobra.Command { cmd.Flags().BoolVar(&save, "save", false, "Save --cmd to .chunk/config.json") cmd.Flags().StringVar(&projectDir, "project", "", "Override project directory") + cmd.AddCommand(newValidateVariantsCmd()) + return cmd } @@ -322,7 +316,7 @@ func openSSHSession(ctx context.Context, sidecarID, identityFile, workdir string } dest := workdir if dest == "" { - if active, err := sidecar.LoadActive(); err == nil && active != nil && active.Workspace != "" { + if active, err := sidecar.LoadActive(ctx); err == nil && active != nil && active.Workspace != "" { dest = active.Workspace } else { dest = "./workspace" @@ -376,13 +370,35 @@ func resolveImage(name string, cfg *config.ProjectConfig) string { return "" } +// resolveSidecar fills sidecarID for per-command remote routing +// (i.e. when --remote is not set but some commands have Remote:true). +// It uses the active sidecar when available, auto-creates one when a sidecar +// image is configured or the caller is a Stop hook, and warns otherwise. +func resolveSidecar(ctx context.Context, sidecarID *string, orgID, image, workDir string, hook *hookContext, streams iostream.Streams) { + statusFn := newStatusFunc(streams) + if active, err := sidecar.LoadActive(ctx); err == nil && active != nil { + *sidecarID = active.SidecarID + statusFn(iostream.LevelInfo, fmt.Sprintf("using sidecar %s for remote commands", *sidecarID)) + return + } + if hook != nil || image != "" { + // In Stop hook context, or when a sidecar image is configured: auto-create + // from the stored snapshot so remote commands get the prepared environment. + if err := resolveOrCreateSidecarID(ctx, sidecarID, orgID, image, workDir, streams); err != nil { + streams.ErrPrintf("warning: no sandbox available (%v); run 'chunk config set orgID ' to enable remote validation, running locally instead\n", err) + } + return + } + statusFn(iostream.LevelWarn, "no active sidecar found — remote commands will run locally") +} + // resolveOrCreateSidecarID fills sidecarID from the active sidecar, or creates // a new sandbox when none is configured. func resolveOrCreateSidecarID(ctx context.Context, sidecarID *string, orgID, image, workDir string, streams iostream.Streams) error { if *sidecarID != "" { return nil } - active, err := sidecar.LoadActive() + active, err := sidecar.LoadActive(ctx) if err != nil { return &userError{msg: "Could not load the active sidecar.", suggestion: configFilePermHint, err: err} } @@ -417,7 +433,7 @@ func resolveOrCreateSidecarID(ctx context.Context, sidecarID *string, orgID, ima err: err, } } - if saveErr := sidecar.SaveActive(sidecar.ActiveSidecar{SidecarID: sc.ID, Name: sc.Name}); saveErr != nil { + if saveErr := sidecar.SaveActive(ctx, sidecar.ActiveSidecar{SidecarID: sc.ID, Name: sc.Name}); saveErr != nil { streams.ErrPrintf("warning: could not save active sidecar: %v\n", saveErr) } // Persist the org ID so future sandbox creation skips the picker. diff --git a/internal/cmd/validate_variants.go b/internal/cmd/validate_variants.go new file mode 100644 index 00000000..ea70c0e3 --- /dev/null +++ b/internal/cmd/validate_variants.go @@ -0,0 +1,172 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "os" + + "github.com/spf13/cobra" + + "github.com/CircleCI-Public/chunk-cli/internal/config" + "github.com/CircleCI-Public/chunk-cli/internal/gitremote" + "github.com/CircleCI-Public/chunk-cli/internal/iostream" + "github.com/CircleCI-Public/chunk-cli/internal/sidecar" + "github.com/CircleCI-Public/chunk-cli/internal/tui" + "github.com/CircleCI-Public/chunk-cli/internal/variants" +) + +func newValidateVariantsCmd() *cobra.Command { + var name, orgID, image, identityFile, workdir string + var parallel int + + cmd := &cobra.Command{ + Use: "variants ", + Short: "Run validation commands against code variants on parallel sidecars", + SilenceUsage: true, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + streams := iostream.FromCmd(cmd) + ctx := cmd.Context() + + data, err := os.ReadFile(args[0]) + if err != nil { + return &userError{ + msg: fmt.Sprintf("Could not read variants file %q.", args[0]), + err: err, + } + } + var vs []variants.Variant + if err := json.Unmarshal(data, &vs); err != nil { + return &userError{ + msg: "Invalid variants file.", + suggestion: "Expected a JSON array of {id, description, patch} objects.", + err: err, + } + } + if len(vs) == 0 { + streams.ErrPrintln("No variants to run.") + return nil + } + + workDir, err := os.Getwd() + if err != nil { + return err + } + cfg, err := config.LoadProjectConfig(workDir) + if err != nil { + return &userError{ + msg: "No validate commands configured.", + suggestion: "Run 'chunk init' first.", + err: err, + } + } + + var cmds []config.Command + if name != "" { + c := cfg.FindCommand(name) + if c == nil { + return &userError{ + msg: fmt.Sprintf("Command %q is not configured.", name), + errMsg: fmt.Sprintf("command %q not found", name), + } + } + cmds = []config.Command{*c} + } else { + for _, c := range cfg.Commands { + if c.Remote { + cmds = append(cmds, c) + } + } + } + if len(cmds) == 0 { + return &userError{ + msg: "No remote commands configured.", + suggestion: "Mark at least one command as remote in .chunk/config.json, or use --name to specify a command.", + errMsg: "no remote commands configured", + } + } + + client, err := ensureCircleCIClient(ctx, streams, tui.PromptHidden) + if err != nil { + return err + } + + if orgID == "" && cfg.OrgID != "" { + orgID = cfg.OrgID + } + resolvedOrgID, err := resolveOrgID(orgID, orgPicker(ctx, client)) + if err != nil { + return err + } + + if image == "" && cfg.Validation != nil { + image = cfg.Validation.SidecarImage + } + + workspace := resolveVariantsWorkspace(ctx, workdir, workDir) + + cmdStrings := make([]string, len(cmds)) + for i, c := range cmds { + cmdStrings[i] = c.Run + } + + authSock := os.Getenv(config.EnvSSHAuthSock) + results, err := variants.Run(ctx, client, vs, variants.Options{ + OrgID: resolvedOrgID, + Image: image, + IdentityFile: identityFile, + AuthSock: authSock, + Workspace: workspace, + Parallel: parallel, + Commands: cmdStrings, + StatusFn: newStatusFunc(streams), + }) + if err != nil { + return &userError{msg: "Variants run failed.", err: err} + } + + killed := 0 + for _, r := range results { + if r.Killed { + killed++ + } + } + statusFn := newStatusFunc(streams) + statusFn(iostream.LevelDone, fmt.Sprintf("%d/%d variants killed", killed, len(results))) + + out, err := json.MarshalIndent(results, "", " ") + if err != nil { + return fmt.Errorf("encode results: %w", err) + } + streams.Printf("%s\n", out) + return nil + }, + } + + cmd.Flags().StringVar(&name, "name", "", "Validate command name to run (default: all remote commands)") + cmd.Flags().IntVar(¶llel, "parallel", 5, "Max concurrent sidecars") + cmd.Flags().StringVar(&orgID, "org-id", "", "Organization ID") + cmd.Flags().StringVar(&image, "image", "", "Snapshot image ID (default: validation.sidecarImage from config)") + cmd.Flags().StringVar(&identityFile, "identity-file", "", "SSH identity file") + cmd.Flags().StringVar(&workdir, "workdir", "", "Remote working directory") + + return cmd +} + +// resolveVariantsWorkspace derives the remote workspace path for variant workers. +// All workers share the same workspace to avoid concurrent writes to the active +// sidecar file. Priority: --workdir flag, active sidecar, git remote default. +func resolveVariantsWorkspace(ctx context.Context, workdirFlag, projectDir string) string { + if workdirFlag != "" { + return workdirFlag + } + if active, err := sidecar.LoadActive(ctx); err == nil && active != nil && active.Workspace != "" { + return active.Workspace + } + _, repo, err := gitremote.DetectOrgAndRepo(projectDir) + if err == nil && repo != "" { + return "./workspace/" + repo + } + return "./workspace" +} diff --git a/internal/config/config.go b/internal/config/config.go index 336be372..418019d3 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -49,7 +49,6 @@ const ( EnvXDGConfigHome = "XDG_CONFIG_HOME" EnvXDGStateHome = "XDG_STATE_HOME" EnvXDGDataHome = "XDG_DATA_HOME" - EnvClaudeSession = "CLAUDE_SESSION_ID" ) // EnvVars holds all environment variables the application reads. @@ -72,7 +71,6 @@ type EnvVars struct { XDGConfigHome string `env:"XDG_CONFIG_HOME"` XDGStateHome string `env:"XDG_STATE_HOME"` XDGDataHome string `env:"XDG_DATA_HOME"` - ClaudeSession string `env:"CLAUDE_SESSION_ID"` } // LoadEnv populates an EnvVars struct from the process environment. diff --git a/internal/session/session.go b/internal/session/session.go new file mode 100644 index 00000000..efb622bc --- /dev/null +++ b/internal/session/session.go @@ -0,0 +1,17 @@ +package session + +import "context" + +// key is unexported so no other package can construct it, guaranteeing no collisions. +type key struct{} + +// WithID returns a new context carrying the given Claude session ID. +func WithID(ctx context.Context, id string) context.Context { + return context.WithValue(ctx, key{}, id) +} + +// IDFromCtx returns the Claude session ID stored in ctx, or "" if not set. +func IDFromCtx(ctx context.Context) string { + id, _ := ctx.Value(key{}).(string) + return id +} diff --git a/internal/sidecar/active.go b/internal/sidecar/active.go index e80781b3..7d48bb31 100644 --- a/internal/sidecar/active.go +++ b/internal/sidecar/active.go @@ -1,12 +1,14 @@ package sidecar import ( + "context" "encoding/json" "errors" "os" "path/filepath" "github.com/CircleCI-Public/chunk-cli/internal/config" + "github.com/CircleCI-Public/chunk-cli/internal/session" ) // ActiveSidecar holds the currently active sidecar for a project. @@ -16,12 +18,12 @@ type ActiveSidecar struct { Workspace string `json:"workspace,omitempty"` } -// sidecarFileName returns the name of the sidecar state file. When -// CLAUDE_SESSION_ID is set the file is keyed to that session so concurrent -// Claude sessions in the same repo each maintain their own active sidecar. -func sidecarFileName() string { - if id := os.Getenv(config.EnvClaudeSession); id != "" { - return "sidecar." + id + ".json" +// sidecarFileName returns the name of the sidecar state file. When sessionID +// is non-empty the file is keyed to that session so concurrent Claude sessions +// in the same repo each maintain their own active sidecar. +func sidecarFileName(sessionID string) string { + if sessionID != "" { + return "sidecar." + sessionID + ".json" } return "sidecar.json" } @@ -37,17 +39,17 @@ func StateDir() (string, error) { // LoadActive reads the active sidecar for the current project from XDG_DATA_HOME. // Returns nil if not found. -func LoadActive() (*ActiveSidecar, error) { +func LoadActive(ctx context.Context) (*ActiveSidecar, error) { dir, err := saveDir() if err != nil { return nil, err } - return LoadActiveFrom(dir) + return LoadActiveFrom(ctx, dir) } // LoadActiveFrom reads the active sidecar from dir. -func LoadActiveFrom(dir string) (*ActiveSidecar, error) { - path, err := findSidecarFile(dir) +func LoadActiveFrom(ctx context.Context, dir string) (*ActiveSidecar, error) { + path, err := findSidecarFile(dir, session.IDFromCtx(ctx)) if err != nil { return nil, err } @@ -66,16 +68,16 @@ func LoadActiveFrom(dir string) (*ActiveSidecar, error) { } // SaveActive writes the active sidecar to XDG_DATA_HOME for the current project. -func SaveActive(a ActiveSidecar) error { +func SaveActive(ctx context.Context, a ActiveSidecar) error { dir, err := saveDir() if err != nil { return err } - return SaveActiveTo(dir, a) + return SaveActiveTo(ctx, dir, a) } // SaveActiveTo writes the active sidecar to dir. -func SaveActiveTo(dir string, a ActiveSidecar) error { +func SaveActiveTo(ctx context.Context, dir string, a ActiveSidecar) error { if err := os.MkdirAll(dir, 0o755); err != nil { return err } @@ -83,7 +85,7 @@ func SaveActiveTo(dir string, a ActiveSidecar) error { if err != nil { return err } - return os.WriteFile(filepath.Join(dir, sidecarFileName()), data, 0o644) + return os.WriteFile(filepath.Join(dir, sidecarFileName(session.IDFromCtx(ctx))), data, 0o644) } // saveDir returns the XDG_DATA_HOME directory for the current project. @@ -123,17 +125,17 @@ func findGitRoot() (string, error) { } // ClearActive removes the active sidecar state file. -func ClearActive() error { +func ClearActive(ctx context.Context) error { dir, err := saveDir() if err != nil { return err } - return ClearActiveFrom(dir) + return ClearActiveFrom(ctx, dir) } // ClearActiveFrom removes the active sidecar state file in dir. -func ClearActiveFrom(dir string) error { - path, err := findSidecarFile(dir) +func ClearActiveFrom(ctx context.Context, dir string) error { + path, err := findSidecarFile(dir, session.IDFromCtx(ctx)) if err != nil { return err } @@ -144,8 +146,8 @@ func ClearActiveFrom(dir string) error { } // findSidecarFile returns the sidecar state file path in dir, or "" if it doesn't exist. -func findSidecarFile(dir string) (string, error) { - return statOrEmpty(filepath.Join(dir, sidecarFileName())) +func findSidecarFile(dir, sessionID string) (string, error) { + return statOrEmpty(filepath.Join(dir, sidecarFileName(sessionID))) } // statOrEmpty returns path if it exists, "" if it does not, or an error for other failures. diff --git a/internal/sidecar/active_test.go b/internal/sidecar/active_test.go index 93ed0588..9ee80add 100644 --- a/internal/sidecar/active_test.go +++ b/internal/sidecar/active_test.go @@ -1,6 +1,7 @@ package sidecar import ( + "context" "os" "path/filepath" "strings" @@ -9,6 +10,7 @@ import ( "gotest.tools/v3/assert" "github.com/CircleCI-Public/chunk-cli/internal/config" + "github.com/CircleCI-Public/chunk-cli/internal/session" ) func TestSaveActiveWritesToXDGDataPath(t *testing.T) { @@ -18,7 +20,7 @@ func TestSaveActiveWritesToXDGDataPath(t *testing.T) { dir := t.TempDir() t.Chdir(dir) - assert.NilError(t, SaveActive(ActiveSidecar{SidecarID: "sb-1"})) + assert.NilError(t, SaveActive(context.Background(), ActiveSidecar{SidecarID: "sb-1"})) // Must not appear inside the project's .chunk directory. _, err := os.Stat(filepath.Join(dir, ".chunk", "sidecar.json")) @@ -53,11 +55,12 @@ func TestSaveAndLoadActive(t *testing.T) { t.Chdir(dir) setupXDGData(t) + ctx := context.Background() want := ActiveSidecar{SidecarID: "sb-abc", Name: "my-box"} - err := SaveActive(want) + err := SaveActive(ctx, want) assert.NilError(t, err) - got, err := LoadActive() + got, err := LoadActive(ctx) assert.NilError(t, err) assert.Assert(t, got != nil, "expected non-nil ActiveSidecar") assert.Equal(t, got.SidecarID, want.SidecarID) @@ -69,7 +72,7 @@ func TestLoadActiveReturnsNilWhenMissing(t *testing.T) { t.Chdir(dir) setupXDGData(t) - got, err := LoadActive() + got, err := LoadActive(context.Background()) assert.NilError(t, err) assert.Assert(t, got == nil, "expected nil when no active sidecar file") } @@ -82,19 +85,21 @@ func TestLoadActiveUsesGitRootAsKey(t *testing.T) { setupXDGData(t) + ctx := context.Background() + // Save from child — keyed to parent (git root). t.Chdir(child) - assert.NilError(t, SaveActive(ActiveSidecar{SidecarID: "sb-git-root"})) + assert.NilError(t, SaveActive(ctx, ActiveSidecar{SidecarID: "sb-git-root"})) // Load from child — should find it. - got, err := LoadActive() + got, err := LoadActive(ctx) assert.NilError(t, err) assert.Assert(t, got != nil) assert.Equal(t, got.SidecarID, "sb-git-root") // Load from parent (the git root) — same project, same file. t.Chdir(parent) - got, err = LoadActive() + got, err = LoadActive(ctx) assert.NilError(t, err) assert.Assert(t, got != nil) assert.Equal(t, got.SidecarID, "sb-git-root") @@ -105,9 +110,10 @@ func TestLoadActiveUsesCwdWhenNoGitRepo(t *testing.T) { t.Chdir(dir) setupXDGData(t) - assert.NilError(t, SaveActive(ActiveSidecar{SidecarID: "sb-cwd"})) + ctx := context.Background() + assert.NilError(t, SaveActive(ctx, ActiveSidecar{SidecarID: "sb-cwd"})) - got, err := LoadActive() + got, err := LoadActive(ctx) assert.NilError(t, err) assert.Assert(t, got != nil) assert.Equal(t, got.SidecarID, "sb-cwd") @@ -118,15 +124,16 @@ func TestClearActive(t *testing.T) { t.Chdir(dir) setupXDGData(t) - assert.NilError(t, SaveActive(ActiveSidecar{SidecarID: "sb-xyz"})) + ctx := context.Background() + assert.NilError(t, SaveActive(ctx, ActiveSidecar{SidecarID: "sb-xyz"})) - got, err := LoadActive() + got, err := LoadActive(ctx) assert.NilError(t, err) assert.Assert(t, got != nil) - assert.NilError(t, ClearActive()) + assert.NilError(t, ClearActive(ctx)) - got, err = LoadActive() + got, err = LoadActive(ctx) assert.NilError(t, err) assert.Assert(t, got == nil) } @@ -136,26 +143,27 @@ func TestSessionKeyedSidecar(t *testing.T) { t.Chdir(dir) setupXDGData(t) + ctx := context.Background() + sessCtx := session.WithID(ctx, "sess-abc") + // Save without a session — generic file. - assert.NilError(t, SaveActive(ActiveSidecar{SidecarID: "sb-generic"})) + assert.NilError(t, SaveActive(ctx, ActiveSidecar{SidecarID: "sb-generic"})) - // With a session ID set, load should return nil (isolated from the generic file). - t.Setenv(config.EnvClaudeSession, "sess-abc") - got, err := LoadActive() + // Session-keyed load should not see the generic file. + got, err := LoadActive(sessCtx) assert.NilError(t, err) assert.Assert(t, got == nil, "session-keyed load should not see generic file") // Save under the session. - assert.NilError(t, SaveActive(ActiveSidecar{SidecarID: "sb-session"})) + assert.NilError(t, SaveActive(sessCtx, ActiveSidecar{SidecarID: "sb-session"})) - got, err = LoadActive() + got, err = LoadActive(sessCtx) assert.NilError(t, err) assert.Assert(t, got != nil) assert.Equal(t, got.SidecarID, "sb-session") - // Without the session env var, the original generic file is still intact. - t.Setenv(config.EnvClaudeSession, "") - got, err = LoadActive() + // Without the session, the original generic file is still intact. + got, err = LoadActive(ctx) assert.NilError(t, err) assert.Assert(t, got != nil) assert.Equal(t, got.SidecarID, "sb-generic") @@ -166,7 +174,7 @@ func TestClearActiveNoopWhenMissing(t *testing.T) { t.Chdir(dir) setupXDGData(t) - assert.NilError(t, ClearActive()) + assert.NilError(t, ClearActive(context.Background())) } func TestWorkspaceFieldRoundTrip(t *testing.T) { @@ -174,10 +182,11 @@ func TestWorkspaceFieldRoundTrip(t *testing.T) { t.Chdir(dir) setupXDGData(t) + ctx := context.Background() want := ActiveSidecar{SidecarID: "sb-1", Name: "test", Workspace: "/workspace/myrepo"} - assert.NilError(t, SaveActive(want)) + assert.NilError(t, SaveActive(ctx, want)) - got, err := LoadActive() + got, err := LoadActive(ctx) assert.NilError(t, err) assert.Assert(t, got != nil) assert.Equal(t, got.Workspace, want.Workspace) @@ -189,11 +198,12 @@ func TestWorkspaceOmittedWhenEmpty(t *testing.T) { t.Chdir(dir) setupXDGData(t) - assert.NilError(t, SaveActive(ActiveSidecar{SidecarID: "sb-1"})) + ctx := context.Background() + assert.NilError(t, SaveActive(ctx, ActiveSidecar{SidecarID: "sb-1"})) stateDir, err := saveDir() assert.NilError(t, err) - data, err := os.ReadFile(filepath.Join(stateDir, sidecarFileName())) + data, err := os.ReadFile(filepath.Join(stateDir, sidecarFileName(""))) assert.NilError(t, err) assert.Assert(t, !strings.Contains(string(data), "workspace"), "empty workspace should be omitted from JSON") } @@ -203,9 +213,10 @@ func TestResolveWorkspaceCLIFlagWins(t *testing.T) { t.Chdir(dir) setupXDGData(t) - assert.NilError(t, SaveActive(ActiveSidecar{SidecarID: "sb-1", Workspace: "/workspace/saved"})) + ctx := context.Background() + assert.NilError(t, SaveActive(ctx, ActiveSidecar{SidecarID: "sb-1", Workspace: "/workspace/saved"})) - got := resolveWorkspace("/workspace/override", "myrepo") + got := resolveWorkspace(ctx, "/workspace/override", "myrepo") assert.Equal(t, got, "/workspace/override") } @@ -214,9 +225,10 @@ func TestResolveWorkspaceSidecarFallback(t *testing.T) { t.Chdir(dir) setupXDGData(t) - assert.NilError(t, SaveActive(ActiveSidecar{SidecarID: "sb-1", Workspace: "/workspace/saved"})) + ctx := context.Background() + assert.NilError(t, SaveActive(ctx, ActiveSidecar{SidecarID: "sb-1", Workspace: "/workspace/saved"})) - got := resolveWorkspace("", "myrepo") + got := resolveWorkspace(ctx, "", "myrepo") assert.Equal(t, got, "/workspace/saved") } @@ -225,6 +237,6 @@ func TestResolveWorkspaceDefaultFallback(t *testing.T) { t.Chdir(dir) setupXDGData(t) - got := resolveWorkspace("", "myrepo") + got := resolveWorkspace(context.Background(), "", "myrepo") assert.Equal(t, got, "./workspace/myrepo") } diff --git a/internal/sidecar/snapshot.go b/internal/sidecar/snapshot.go index 853bb931..6b621175 100644 --- a/internal/sidecar/snapshot.go +++ b/internal/sidecar/snapshot.go @@ -1,11 +1,12 @@ package sidecar import ( + "context" "encoding/json" "os" "path/filepath" - "github.com/CircleCI-Public/chunk-cli/internal/config" + "github.com/CircleCI-Public/chunk-cli/internal/session" ) // ActiveSnapshot holds the most recently created snapshot for a project. @@ -14,26 +15,26 @@ type ActiveSnapshot struct { Name string `json:"name,omitempty"` } -func snapshotFileName() string { - if id := os.Getenv(config.EnvClaudeSession); id != "" { - return "snapshot." + id + ".json" +func snapshotFileName(sessionID string) string { + if sessionID != "" { + return "snapshot." + sessionID + ".json" } return "snapshot.json" } // LoadActiveSnapshot reads the active snapshot for the current project from XDG_DATA_HOME. // Returns nil if not found. -func LoadActiveSnapshot() (*ActiveSnapshot, error) { +func LoadActiveSnapshot(ctx context.Context) (*ActiveSnapshot, error) { dir, err := StateDir() if err != nil { return nil, err } - return LoadSnapshotFrom(dir) + return LoadSnapshotFrom(ctx, dir) } // LoadSnapshotFrom reads the active snapshot from dir. -func LoadSnapshotFrom(dir string) (*ActiveSnapshot, error) { - path, err := findSnapshotFile(dir) +func LoadSnapshotFrom(ctx context.Context, dir string) (*ActiveSnapshot, error) { + path, err := findSnapshotFile(dir, session.IDFromCtx(ctx)) if err != nil { return nil, err } @@ -52,16 +53,16 @@ func LoadSnapshotFrom(dir string) (*ActiveSnapshot, error) { } // SaveActiveSnapshot writes the active snapshot to XDG_DATA_HOME for the current project. -func SaveActiveSnapshot(a ActiveSnapshot) error { +func SaveActiveSnapshot(ctx context.Context, a ActiveSnapshot) error { dir, err := StateDir() if err != nil { return err } - return SaveSnapshotTo(dir, a) + return SaveSnapshotTo(ctx, dir, a) } // SaveSnapshotTo writes the active snapshot to dir. -func SaveSnapshotTo(dir string, a ActiveSnapshot) error { +func SaveSnapshotTo(ctx context.Context, dir string, a ActiveSnapshot) error { if err := os.MkdirAll(dir, 0o755); err != nil { return err } @@ -69,21 +70,21 @@ func SaveSnapshotTo(dir string, a ActiveSnapshot) error { if err != nil { return err } - return os.WriteFile(filepath.Join(dir, snapshotFileName()), data, 0o644) + return os.WriteFile(filepath.Join(dir, snapshotFileName(session.IDFromCtx(ctx))), data, 0o644) } // ClearActiveSnapshot removes the active snapshot state file. -func ClearActiveSnapshot() error { +func ClearActiveSnapshot(ctx context.Context) error { dir, err := StateDir() if err != nil { return err } - return ClearSnapshotFrom(dir) + return ClearSnapshotFrom(ctx, dir) } // ClearSnapshotFrom removes the active snapshot state file in dir. -func ClearSnapshotFrom(dir string) error { - path, err := findSnapshotFile(dir) +func ClearSnapshotFrom(ctx context.Context, dir string) error { + path, err := findSnapshotFile(dir, session.IDFromCtx(ctx)) if err != nil { return err } @@ -94,6 +95,6 @@ func ClearSnapshotFrom(dir string) error { } // findSnapshotFile returns the snapshot state file path in dir, or "" if it doesn't exist. -func findSnapshotFile(dir string) (string, error) { - return statOrEmpty(filepath.Join(dir, snapshotFileName())) +func findSnapshotFile(dir, sessionID string) (string, error) { + return statOrEmpty(filepath.Join(dir, snapshotFileName(sessionID))) } diff --git a/internal/sidecar/snapshot_test.go b/internal/sidecar/snapshot_test.go index 3e3fa5b9..25ab7dab 100644 --- a/internal/sidecar/snapshot_test.go +++ b/internal/sidecar/snapshot_test.go @@ -1,11 +1,12 @@ package sidecar import ( + "context" "testing" "gotest.tools/v3/assert" - "github.com/CircleCI-Public/chunk-cli/internal/config" + "github.com/CircleCI-Public/chunk-cli/internal/session" ) func TestSaveAndLoadActiveSnapshot(t *testing.T) { @@ -13,10 +14,11 @@ func TestSaveAndLoadActiveSnapshot(t *testing.T) { t.Chdir(dir) setupXDGData(t) + ctx := context.Background() want := ActiveSnapshot{ID: "snap-abc", Name: "my-snap"} - assert.NilError(t, SaveActiveSnapshot(want)) + assert.NilError(t, SaveActiveSnapshot(ctx, want)) - got, err := LoadActiveSnapshot() + got, err := LoadActiveSnapshot(ctx) assert.NilError(t, err) assert.Assert(t, got != nil, "expected non-nil ActiveSnapshot") assert.Equal(t, got.ID, want.ID) @@ -28,7 +30,7 @@ func TestLoadActiveSnapshotReturnsNilWhenMissing(t *testing.T) { t.Chdir(dir) setupXDGData(t) - got, err := LoadActiveSnapshot() + got, err := LoadActiveSnapshot(context.Background()) assert.NilError(t, err) assert.Assert(t, got == nil, "expected nil when no snapshot file") } @@ -38,15 +40,16 @@ func TestClearActiveSnapshot(t *testing.T) { t.Chdir(dir) setupXDGData(t) - assert.NilError(t, SaveActiveSnapshot(ActiveSnapshot{ID: "snap-xyz"})) + ctx := context.Background() + assert.NilError(t, SaveActiveSnapshot(ctx, ActiveSnapshot{ID: "snap-xyz"})) - got, err := LoadActiveSnapshot() + got, err := LoadActiveSnapshot(ctx) assert.NilError(t, err) assert.Assert(t, got != nil) - assert.NilError(t, ClearActiveSnapshot()) + assert.NilError(t, ClearActiveSnapshot(ctx)) - got, err = LoadActiveSnapshot() + got, err = LoadActiveSnapshot(ctx) assert.NilError(t, err) assert.Assert(t, got == nil) } @@ -56,7 +59,7 @@ func TestClearActiveSnapshotNoopWhenMissing(t *testing.T) { t.Chdir(dir) setupXDGData(t) - assert.NilError(t, ClearActiveSnapshot()) + assert.NilError(t, ClearActiveSnapshot(context.Background())) } func TestSnapshotSessionKeyed(t *testing.T) { @@ -64,26 +67,27 @@ func TestSnapshotSessionKeyed(t *testing.T) { t.Chdir(dir) setupXDGData(t) + ctx := context.Background() + sessCtx := session.WithID(ctx, "sess-abc") + // Save without a session — generic file. - assert.NilError(t, SaveActiveSnapshot(ActiveSnapshot{ID: "snap-generic"})) + assert.NilError(t, SaveActiveSnapshot(ctx, ActiveSnapshot{ID: "snap-generic"})) - // With a session ID set, load should return nil (isolated from the generic file). - t.Setenv(config.EnvClaudeSession, "sess-abc") - got, err := LoadActiveSnapshot() + // Session-keyed load should not see the generic file. + got, err := LoadActiveSnapshot(sessCtx) assert.NilError(t, err) assert.Assert(t, got == nil, "session-keyed load should not see generic file") // Save under the session. - assert.NilError(t, SaveActiveSnapshot(ActiveSnapshot{ID: "snap-session"})) + assert.NilError(t, SaveActiveSnapshot(sessCtx, ActiveSnapshot{ID: "snap-session"})) - got, err = LoadActiveSnapshot() + got, err = LoadActiveSnapshot(sessCtx) assert.NilError(t, err) assert.Assert(t, got != nil) assert.Equal(t, got.ID, "snap-session") - // Without the session env var, the original generic file is still intact. - t.Setenv(config.EnvClaudeSession, "") - got, err = LoadActiveSnapshot() + // Without the session, the original generic file is still intact. + got, err = LoadActiveSnapshot(ctx) assert.NilError(t, err) assert.Assert(t, got != nil) assert.Equal(t, got.ID, "snap-generic") diff --git a/internal/sidecar/ssh.go b/internal/sidecar/ssh.go index 02afe875..60c5e6f7 100644 --- a/internal/sidecar/ssh.go +++ b/internal/sidecar/ssh.go @@ -24,6 +24,15 @@ import ( "github.com/CircleCI-Public/chunk-cli/internal/closer" ) +// connError wraps a connection-level failure from dialSSH or NewSession — i.e. +// the SSH channel could not be opened before any command ran. Callers can use +// errors.As to distinguish these from command-exit errors and treat them as +// transient boot failures worth retrying. +type connError struct{ err error } + +func (e *connError) Error() string { return e.err.Error() } +func (e *connError) Unwrap() error { return e.err } + // ExecResult holds the output of a command executed over SSH. type ExecResult struct { Stdout string @@ -134,7 +143,7 @@ func dialSSH(ctx context.Context, session *Session) (*sshConn, error) { _ = resp.Body.Close() } cleanup() - return nil, fmt.Errorf("websocket connect to %s: %w", wsURL, err) + return nil, &connError{fmt.Errorf("websocket connect to %s: %w", wsURL, err)} } netConn := websocket.NetConn(ctx, wsConn, websocket.MessageBinary) @@ -148,7 +157,7 @@ func dialSSH(ctx context.Context, session *Session) (*sshConn, error) { if err != nil { _ = netConn.Close() cleanup() - return nil, fmt.Errorf("ssh handshake: %w", err) + return nil, &connError{fmt.Errorf("ssh handshake: %w", err)} } return &sshConn{Client: ssh.NewClient(conn, chans, reqs), cleanup: cleanup}, nil @@ -179,7 +188,7 @@ func ExecOverSSH(ctx context.Context, session *Session, command string, stdin io sess, err := client.NewSession() if err != nil { - return nil, fmt.Errorf("ssh session: %w", err) + return nil, &connError{fmt.Errorf("ssh session: %w", err)} } defer func() { _ = sess.Close() }() diff --git a/internal/sidecar/sync.go b/internal/sidecar/sync.go index 8e9aa286..bf24b8a6 100644 --- a/internal/sidecar/sync.go +++ b/internal/sidecar/sync.go @@ -4,9 +4,12 @@ import ( "context" "errors" "fmt" + "io" + "net" "os" "path/filepath" "strings" + "time" "github.com/CircleCI-Public/chunk-cli/internal/circleci" "github.com/CircleCI-Public/chunk-cli/internal/gitremote" @@ -18,11 +21,11 @@ const workspaceDir = "./workspace" // resolveWorkspace determines the workspace path. Priority: // 1. CLI --workdir flag 2. sidecar.json workspace 3. default. -func resolveWorkspace(cliWorkdir, repo string) string { +func resolveWorkspace(ctx context.Context, cliWorkdir, repo string) string { if cliWorkdir != "" { return cliWorkdir } - if active, err := LoadActive(); err == nil && active != nil && active.Workspace != "" { + if active, err := LoadActive(ctx); err == nil && active != nil && active.Workspace != "" { return active.Workspace } return workspaceDir + "/" + repo @@ -30,8 +33,8 @@ func resolveWorkspace(cliWorkdir, repo string) string { // persistWorkspace saves the resolved workspace back to the sidecar file if it // differs from the current value. -func persistWorkspace(workspace string) error { - active, err := LoadActive() +func persistWorkspace(ctx context.Context, workspace string) error { + active, err := LoadActive(ctx) if err != nil { return err } @@ -39,7 +42,7 @@ func persistWorkspace(workspace string) error { return nil } active.Workspace = workspace - return SaveActive(*active) + return SaveActive(ctx, *active) } // Sync synchronises local changes to a sidecar over SSH. @@ -49,7 +52,7 @@ func persistWorkspace(workspace string) error { func Sync(ctx context.Context, client *circleci.Client, sidecarID, identityFile, authSock, workdir string, status iostream.StatusFunc) error { - session, err := OpenSession(ctx, client, sidecarID, identityFile, authSock) + session, err := openSessionWithRetry(ctx, client, sidecarID, identityFile, authSock, status) if err != nil { return err } @@ -64,9 +67,9 @@ func Sync(ctx context.Context, return fmt.Errorf("sync: %w", err) } - repoPath := resolveWorkspace(workdir, repo) + repoPath := resolveWorkspace(ctx, workdir, repo) - if err := persistWorkspace(repoPath); err != nil { + if err := persistWorkspace(ctx, repoPath); err != nil { status(iostream.LevelWarn, fmt.Sprintf("Could not save workspace: %v", err)) } @@ -76,6 +79,19 @@ func Sync(ctx context.Context, status(iostream.LevelDone, "Synced") return nil } + + // If the SSH connection dropped mid-sync (e.g. sidecar still booting), + // re-open the session and retry once before falling through to other recovery. + if isTransientSSHError(err) { + if session, err = openSessionWithRetry(ctx, client, sidecarID, identityFile, authSock, status); err == nil { + err = syncWorkspace(ctx, status, org, repo, repoPath, session) + if err == nil { + status(iostream.LevelDone, "Synced") + return nil + } + } + } + // We should only try again if the failure was in the apply phase. if !errors.Is(err, errApplyFailed) { return err @@ -152,10 +168,14 @@ func syncWorkspace(ctx context.Context, status iostream.StatusFunc, org, repo, r status(iostream.LevelInfo, fmt.Sprintf("Synchronising local %s/%s to remote: %s...", org, repo, repoPath)) - base, err := gitutil.MergeBase() + baseResult, err := ExecOverSSH(ctx, session, fmt.Sprintf("git -C %s rev-parse origin/HEAD", ShellEscape(repoPath)), nil, nil) if err != nil { - return &RemoteBaseError{Err: err} + return fmt.Errorf("sync: resolve remote base: %w", err) } + if baseResult.ExitCode != 0 { + return &RemoteBaseError{Err: fmt.Errorf("git rev-parse origin/HEAD: %s", baseResult.Stderr)} + } + base := strings.TrimSpace(baseResult.Stdout) patch, err := gitutil.GeneratePatch(base) if err != nil { @@ -198,3 +218,47 @@ func syncWorkspace(ctx context.Context, status iostream.StatusFunc, org, repo, r } return nil } + +// openSessionWithRetry calls OpenSession, retrying on transient errors to give +// a newly-created sidecar time to finish booting before its SSH service is ready. +func openSessionWithRetry(ctx context.Context, client *circleci.Client, sidecarID, identityFile, authSock string, status iostream.StatusFunc) (*Session, error) { + const retryDelay = 5 * time.Second + const maxAttempts = 12 // up to ~1 minute + + var err error + for i := range maxAttempts { + var session *Session + session, err = OpenSession(ctx, client, sidecarID, identityFile, authSock) + if err == nil { + return session, nil + } + if !isTransientSSHError(err) || i == maxAttempts-1 { + break + } + if i == 0 { + status(iostream.LevelInfo, "Waiting for sidecar SSH to become available...") + } + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(retryDelay): + } + } + return nil, err +} + +// isTransientSSHError returns true for errors worth retrying when the sidecar's +// SSH service is not yet ready. Covers net.Error (connection refused, timeout), +// io.EOF (dropped connection during SSH handshake), and connError (any +// connection-level failure from dialSSH or NewSession before a command ran). +func isTransientSSHError(err error) bool { + var netErr net.Error + if errors.As(err, &netErr) { + return true + } + if errors.Is(err, io.EOF) { + return true + } + var ce *connError + return errors.As(err, &ce) +} diff --git a/internal/sidecar/sync_test.go b/internal/sidecar/sync_test.go new file mode 100644 index 00000000..4af22d57 --- /dev/null +++ b/internal/sidecar/sync_test.go @@ -0,0 +1,77 @@ +package sidecar + +import ( + "fmt" + "io" + "net" + "testing" + + "gotest.tools/v3/assert" + + "github.com/CircleCI-Public/chunk-cli/internal/circleci" +) + +func TestIsTransientSSHError(t *testing.T) { + t.Run("timeout is transient", func(t *testing.T) { + err := &net.OpError{Op: "dial", Err: &timeoutError{}} + assert.Equal(t, isTransientSSHError(err), true) + }) + + t.Run("connection refused is transient", func(t *testing.T) { + err := &net.OpError{Op: "dial", Net: "tcp", Err: fmt.Errorf("connection refused")} + assert.Equal(t, isTransientSSHError(err), true) + }) + + t.Run("net error wrapped with fmt.Errorf is transient", func(t *testing.T) { + inner := &net.OpError{Op: "dial", Err: &timeoutError{}} + err := fmt.Errorf("register SSH key: %w", inner) + assert.Equal(t, isTransientSSHError(err), true) + }) + + t.Run("ErrNotAuthorized is not transient", func(t *testing.T) { + err := fmt.Errorf("add ssh key: %w", circleci.ErrNotAuthorized) + assert.Equal(t, isTransientSSHError(err), false) + }) + + t.Run("StatusError is not transient", func(t *testing.T) { + err := &circleci.StatusError{Op: "add ssh key", StatusCode: 503} + assert.Equal(t, isTransientSSHError(err), false) + }) + + t.Run("KeyNotFoundError is not transient", func(t *testing.T) { + err := &KeyNotFoundError{Path: "/home/user/.ssh/chunk_ai"} + assert.Equal(t, isTransientSSHError(err), false) + }) + + t.Run("PublicKeyNotFoundError is not transient", func(t *testing.T) { + err := &PublicKeyNotFoundError{KeyPath: "/home/user/.ssh/chunk_ai.pub"} + assert.Equal(t, isTransientSSHError(err), false) + }) + + t.Run("io.EOF is transient", func(t *testing.T) { + err := fmt.Errorf("ssh handshake: ssh: handshake failed: failed to get reader: failed to read frame header: %w", io.EOF) + assert.Equal(t, isTransientSSHError(err), true) + }) + + t.Run("websocket 502 is transient", func(t *testing.T) { + err := &connError{fmt.Errorf("websocket connect to wss://example.com: failed to WebSocket dial: expected handshake response status code 101 but got 502")} + assert.Equal(t, isTransientSSHError(err), true) + }) + + t.Run("ssh unexpected packet is transient", func(t *testing.T) { + err := &connError{fmt.Errorf("ssh session: ssh: unexpected packet in response to channel open: ")} + assert.Equal(t, isTransientSSHError(err), true) + }) + + t.Run("generic error is not transient", func(t *testing.T) { + err := fmt.Errorf("resolve home directory: permission denied") + assert.Equal(t, isTransientSSHError(err), false) + }) +} + +// timeoutError is a net.Error that reports Timeout() == true. +type timeoutError struct{} + +func (timeoutError) Error() string { return "i/o timeout" } +func (timeoutError) Timeout() bool { return true } +func (timeoutError) Temporary() bool { return true } diff --git a/internal/variants/variants.go b/internal/variants/variants.go new file mode 100644 index 00000000..fd4df1b2 --- /dev/null +++ b/internal/variants/variants.go @@ -0,0 +1,148 @@ +package variants + +import ( + "context" + "fmt" + "strings" + + "golang.org/x/sync/errgroup" + + "github.com/CircleCI-Public/chunk-cli/internal/circleci" + "github.com/CircleCI-Public/chunk-cli/internal/iostream" + "github.com/CircleCI-Public/chunk-cli/internal/sidecar" +) + +// Variant is one entry from the input JSON file. +type Variant struct { + ID string `json:"id"` + Description string `json:"description"` + Patch string `json:"patch"` +} + +// Result is one entry in the output JSON array. +type Result struct { + ID string `json:"id"` + Description string `json:"description"` + Killed bool `json:"killed"` + Stdout string `json:"stdout"` + Stderr string `json:"stderr"` + ExitCode int `json:"exit_code"` + Error string `json:"error,omitempty"` +} + +// Options holds all configuration for running variants. +type Options struct { + OrgID string + Image string + IdentityFile string + AuthSock string + Workspace string // remote working directory, must be non-empty + Parallel int // max concurrent sidecars (default 5) + Commands []string // shell commands to run on each sidecar in order + StatusFn iostream.StatusFunc +} + +// Run executes all variants in parallel and returns results in input order. +// It only returns an error for fatal pre-flight failures; per-variant errors +// are captured in Result.Error. +func Run(ctx context.Context, client *circleci.Client, variants []Variant, opts Options) ([]Result, error) { + if len(variants) == 0 { + return nil, nil + } + if opts.Parallel <= 0 { + opts.Parallel = 5 + } + + results := make([]Result, len(variants)) + sem := make(chan struct{}, opts.Parallel) + + g, gctx := errgroup.WithContext(ctx) + for i, v := range variants { + i, v := i, v + g.Go(func() error { + sem <- struct{}{} + defer func() { <-sem }() + results[i] = runVariant(gctx, client, v, opts) + return nil + }) + } + _ = g.Wait() + return results, nil +} + +func runVariant(ctx context.Context, client *circleci.Client, v Variant, opts Options) Result { + base := Result{ID: v.ID, Description: v.Description} + + opts.StatusFn(iostream.LevelInfo, fmt.Sprintf("[%s] creating sidecar", v.ID)) + sc, err := sidecar.Create(ctx, client, opts.OrgID, variantSidecarName(v.ID), opts.Image) + if err != nil { + base.Error = fmt.Sprintf("create sidecar: %v", err) + return base + } + defer func() { + // Use a fresh context so cleanup runs even when the parent is cancelled. + if err := client.DeleteSidecar(context.Background(), sc.ID); err != nil { + opts.StatusFn(iostream.LevelWarn, fmt.Sprintf("[%s] could not delete sidecar %s: %v", v.ID, sc.ID, err)) + } + }() + + opts.StatusFn(iostream.LevelInfo, fmt.Sprintf("[%s] syncing", v.ID)) + if err := sidecar.Sync(ctx, client, sc.ID, opts.IdentityFile, opts.AuthSock, opts.Workspace, opts.StatusFn); err != nil { + base.Error = fmt.Sprintf("sync: %v", err) + return base + } + + session, err := sidecar.OpenSession(ctx, client, sc.ID, opts.IdentityFile, opts.AuthSock) + if err != nil { + base.Error = fmt.Sprintf("open session: %v", err) + return base + } + + if v.Patch != "" { + opts.StatusFn(iostream.LevelInfo, fmt.Sprintf("[%s] applying patch", v.ID)) + applyCmd := "git -C " + sidecar.ShellEscape(opts.Workspace) + " apply" + applyResult, err := sidecar.ExecOverSSH(ctx, session, applyCmd, strings.NewReader(v.Patch), nil) + if err != nil { + base.Error = fmt.Sprintf("apply patch: %v", err) + return base + } + if applyResult.ExitCode != 0 { + base.Error = "patch did not apply" + return base + } + } + + opts.StatusFn(iostream.LevelInfo, fmt.Sprintf("[%s] running commands", v.ID)) + for _, cmd := range opts.Commands { + script := "cd " + sidecar.ShellEscape(opts.Workspace) + " && " + cmd + result, err := sidecar.ExecOverSSH(ctx, session, "sh -c "+sidecar.ShellEscape(script), nil, nil) + if err != nil { + base.Error = fmt.Sprintf("exec: %v", err) + return base + } + if result.ExitCode != 0 { + base.Stdout = result.Stdout + base.Stderr = result.Stderr + base.ExitCode = result.ExitCode + base.Killed = true + opts.StatusFn(iostream.LevelDone, fmt.Sprintf("[%s] killed (exit %d)", v.ID, result.ExitCode)) + return base + } + } + + opts.StatusFn(iostream.LevelWarn, fmt.Sprintf("[%s] survived", v.ID)) + return base +} + +// variantSidecarName produces a sidecar-safe name from a variant ID. +func variantSidecarName(id string) string { + var b strings.Builder + for _, r := range strings.ToLower(id) { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' { + b.WriteRune(r) + } else { + b.WriteRune('-') + } + } + return "variant-" + b.String() +} diff --git a/internal/variants/variants_test.go b/internal/variants/variants_test.go new file mode 100644 index 00000000..b3212adf --- /dev/null +++ b/internal/variants/variants_test.go @@ -0,0 +1,114 @@ +package variants_test + +import ( + "context" + "net/http/httptest" + "testing" + + "gotest.tools/v3/assert" + "gotest.tools/v3/assert/cmp" + + "github.com/CircleCI-Public/chunk-cli/internal/circleci" + "github.com/CircleCI-Public/chunk-cli/internal/iostream" + "github.com/CircleCI-Public/chunk-cli/internal/testing/fakes" + "github.com/CircleCI-Public/chunk-cli/internal/variants" +) + +func nopStatus(_ iostream.Level, _ string) {} + +func newTestClient(t *testing.T, srv *httptest.Server) *circleci.Client { + t.Helper() + client, err := circleci.NewClient(circleci.Config{Token: "fake-token", BaseURL: srv.URL}) + assert.NilError(t, err) + return client +} + +func defaultOpts() variants.Options { + return variants.Options{ + OrgID: "org-aaa", + Image: "snap-abc", + Workspace: "./workspace/repo", + Commands: []string{"go test ./..."}, + Parallel: 5, + StatusFn: nopStatus, + } +} + +func TestRunEmpty(t *testing.T) { + cci := fakes.NewFakeCircleCI() + srv := httptest.NewServer(cci) + defer srv.Close() + + results, err := variants.Run(context.Background(), newTestClient(t, srv), nil, defaultOpts()) + assert.NilError(t, err) + assert.Check(t, cmp.Len(results, 0)) + assert.Check(t, cmp.Len(cci.Recorder.AllRequests(), 0)) +} + +func TestRunCreateError(t *testing.T) { + cci := fakes.NewFakeCircleCI() + cci.CreateStatusCode = 500 + srv := httptest.NewServer(cci) + defer srv.Close() + + vs := []variants.Variant{ + {ID: "MUT-001", Description: "invert nil check", Patch: ""}, + } + results, err := variants.Run(context.Background(), newTestClient(t, srv), vs, defaultOpts()) + assert.NilError(t, err) + assert.Check(t, cmp.Len(results, 1)) + assert.Equal(t, results[0].ID, "MUT-001") + assert.Assert(t, results[0].Error != "", "expected error in result, got empty") + assert.Check(t, !results[0].Killed) +} + +func TestRunResultsInOrder(t *testing.T) { + // All variants fail at create — fast path for ordering verification. + cci := fakes.NewFakeCircleCI() + cci.CreateStatusCode = 500 + srv := httptest.NewServer(cci) + defer srv.Close() + + vs := []variants.Variant{ + {ID: "MUT-001"}, + {ID: "MUT-002"}, + {ID: "MUT-003"}, + } + results, err := variants.Run(context.Background(), newTestClient(t, srv), vs, defaultOpts()) + assert.NilError(t, err) + assert.Check(t, cmp.Len(results, 3)) + assert.Equal(t, results[0].ID, "MUT-001") + assert.Equal(t, results[1].ID, "MUT-002") + assert.Equal(t, results[2].ID, "MUT-003") +} + +func TestRunDeleteCalledOnCreateSuccess(t *testing.T) { + // Create succeeds; Sync fails at SSH key registration so delete must still run. + // AddKeyStatusCode=500 prevents OpenSession from succeeding, which means Sync + // never reaches persistWorkspace and cannot corrupt the caller's active sidecar. + cci := fakes.NewFakeCircleCI() + cci.AddKeyStatusCode = 500 + srv := httptest.NewServer(cci) + defer srv.Close() + + vs := []variants.Variant{ + {ID: "MUT-001", Patch: ""}, + } + results, err := variants.Run(context.Background(), newTestClient(t, srv), vs, defaultOpts()) + assert.NilError(t, err) + assert.Check(t, cmp.Len(results, 1)) + assert.Assert(t, results[0].Error != "", "expected error (no SSH server)") + + reqs := cci.Recorder.AllRequests() + var creates, deletes int + for _, r := range reqs { + if r.URL.Path == "/api/v2/sidecar/instances" && r.Method == "POST" { + creates++ + } + if r.URL.Path != "/api/v2/sidecar/instances" && r.Method == "DELETE" { + deletes++ + } + } + assert.Check(t, creates >= 1, "expected at least 1 create request") + assert.Check(t, deletes >= 1, "expected at least 1 delete request") +}