Skip to content

Commit fc9465a

Browse files
michael-websterwebsterclaude
authored
Remote validation: route commands to a sidecar with auto-creation (#288)
So we can automatically run validation in sidecars. The chunk validate command accepts a remote flag which is good for direct usage, but is limited for hooks since we can't easily vary the arguments to the hook command. Instead we make hooks use configuration based settings for specific validate commands that can run remotely. This also necessitated adding an image to use for sidecars when creating Chunk sidecars. This also adds some falls backs for connectivity issues over ssh to run commands locally as a fall back. Execution tracking will depend on this. --------- Co-authored-by: webster <michael@webster.fyi> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 09b885c commit fc9465a

7 files changed

Lines changed: 270 additions & 39 deletions

File tree

.chunk/config.json

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66
"role": "gate",
77
"fileExt": ".go",
88
"timeout": 300,
9-
"limit": 3
9+
"limit": 3,
10+
"remote": true
1011
},
1112
{
1213
"name": "test-changed",
@@ -46,7 +47,7 @@
4647
},
4748
{
4849
"name": "system",
49-
"command": "sudo apt-get update \u0026\u0026 sudo apt-get install -y git --no-install-recommends \u0026\u0026 sudo rm -rf /var/lib/apt/lists/*"
50+
"command": "sudo apt-get update && sudo apt-get install -y git --no-install-recommends && sudo rm -rf /var/lib/apt/lists/*"
5051
},
5152
{
5253
"name": "install",
@@ -55,5 +56,6 @@
5556
],
5657
"image": "cimg/go",
5758
"image_version": "1.26.2"
58-
}
59+
},
60+
"orgID": "f22b6566-597d-46d5-ba74-99ef5bb3d85c"
5961
}

internal/cmd/config.go

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package cmd
22

33
import (
44
"fmt"
5+
"os"
56

67
"github.com/spf13/cobra"
78

@@ -61,16 +62,43 @@ func newConfigSetCmd() *cobra.Command {
6162
return &cobra.Command{
6263
Use: "set <key> <value>",
6364
Short: "Set a config value",
64-
Long: "Set a config value (model). Use 'chunk auth set <provider>' to store credentials with validation.",
65+
Long: "Set a config value. Use 'chunk auth set <provider>' to store credentials with validation.\n\nUser keys: model\nProject keys: orgID, validation.sidecarImage",
6566
Args: cobra.ExactArgs(2),
6667
RunE: func(cmd *cobra.Command, args []string) error {
6768
io := iostream.FromCmd(cmd)
6869
key, value := args[0], args[1]
6970

71+
if config.ValidProjectConfigKeys[key] {
72+
workDir, err := os.Getwd()
73+
if err != nil {
74+
return err
75+
}
76+
projCfg, err := config.LoadProjectConfig(workDir)
77+
if err != nil {
78+
projCfg = &config.ProjectConfig{}
79+
}
80+
switch key {
81+
case "orgID":
82+
projCfg.OrgID = value
83+
case "validation.sidecarImage":
84+
if projCfg.Validation == nil {
85+
projCfg.Validation = &config.ValidationConfig{}
86+
}
87+
projCfg.Validation.SidecarImage = value
88+
default:
89+
return fmt.Errorf("internal: unhandled project config key %q", key)
90+
}
91+
if err := config.SaveProjectConfig(workDir, projCfg); err != nil {
92+
return &userError{msg: "Could not save project configuration.", suggestion: configFilePermHint, err: err}
93+
}
94+
io.Printf("%s\n", ui.Success(fmt.Sprintf("Set %s to %s", key, value)))
95+
return nil
96+
}
97+
7098
if !config.ValidConfigKeys[key] {
7199
return &userError{
72100
msg: fmt.Sprintf("Unknown config key: %q.", key),
73-
detail: "Supported keys: model.",
101+
detail: "Supported keys: model, orgID, validation.sidecarImage.",
74102
errMsg: fmt.Sprintf("unknown config key %q", key),
75103
}
76104
}

internal/cmd/validate.go

Lines changed: 181 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"fmt"
99
"io"
1010
"os"
11+
"path/filepath"
1112
"strings"
1213

1314
"github.com/spf13/cobra"
@@ -60,7 +61,7 @@ func detectHook(r io.Reader) *hookContext {
6061
}
6162

6263
func newValidateCmd() *cobra.Command {
63-
var sidecarID, identityFile, workdir string
64+
var sidecarID, identityFile, workdir, orgID string
6465
var dryRun, list, save, remote bool
6566
var inlineCmd, projectDir string
6667

@@ -135,13 +136,35 @@ func newValidateCmd() *cobra.Command {
135136
return mapValidateError(validate.RunDryRun(cfg, name, statusFn))
136137
}
137138

139+
// allRemote is true when the caller explicitly targets the sidecar
140+
// (--remote or --sidecar-id), meaning every command runs there.
141+
// Per-command routing only applies when the sidecar is resolved implicitly.
142+
allRemote := remote || sidecarID != ""
143+
144+
image := resolveImage(name, cfg)
145+
138146
if remote {
139-
if err := resolveSidecarID(&sidecarID); err != nil {
147+
// --remote: force all commands to sidecar, creating one if needed.
148+
if err := resolveOrCreateSidecarID(cmd.Context(), &sidecarID, orgID, image, workDir, streams); err != nil {
140149
return err
141150
}
151+
statusFn(iostream.LevelInfo, fmt.Sprintf("running all commands on sidecar %s", sidecarID))
152+
} else if cfg.HasRemoteCommands() {
153+
// Per-command remote: use active sidecar if available.
154+
if active, err := sidecar.LoadActive(); err == nil && active != nil {
155+
sidecarID = active.SidecarID
156+
statusFn(iostream.LevelInfo, fmt.Sprintf("using sidecar %s for remote commands", sidecarID))
157+
} else if hook != nil {
158+
// In Stop hook context: auto-create a sandbox if possible.
159+
if err := resolveOrCreateSidecarID(cmd.Context(), &sidecarID, orgID, image, workDir, streams); err != nil {
160+
streams.ErrPrintf("warning: no sandbox available (%v); run 'chunk config set orgID <id>' to enable remote validation, running locally instead\n", err)
161+
}
162+
} else {
163+
statusFn(iostream.LevelWarn, "no active sidecar found — remote commands will run locally")
164+
}
142165
}
143166

144-
execErr := runValidate(cmd.Context(), workDir, name, inlineCmd, save, sidecarID, identityFile, workdir, cfg, statusFn, streams)
167+
execErr := runValidate(cmd.Context(), workDir, name, inlineCmd, save, sidecarID, identityFile, workdir, allRemote, cfg, statusFn, streams)
145168

146169
if hook != nil {
147170
maxAttempts := cfg.StopHookMaxAttempts
@@ -154,8 +177,9 @@ func newValidateCmd() *cobra.Command {
154177
},
155178
}
156179

157-
cmd.Flags().BoolVar(&remote, "remote", false, "Run on active sidecar (reads .chunk/sidecar.json)")
180+
cmd.Flags().BoolVar(&remote, "remote", false, "Run on active sidecar, or create one if none is set")
158181
cmd.Flags().StringVar(&sidecarID, "sidecar-id", "", "Sidecar ID for remote execution")
182+
cmd.Flags().StringVar(&orgID, "org-id", "", "Organization ID (used when creating a new sidecar)")
159183
cmd.Flags().StringVar(&identityFile, "identity-file", "", "SSH identity file (uses ssh-agent or ~/.ssh/chunk_ai when omitted)")
160184
cmd.Flags().StringVar(&workdir, "workdir", "", "Working directory on sidecar (reads from sidecar.json, defaults to ./workspace)")
161185
cmd.Flags().BoolVar(&dryRun, "dry-run", false, "Show commands without executing")
@@ -169,8 +193,10 @@ func newValidateCmd() *cobra.Command {
169193

170194
// runValidate dispatches to the appropriate Run* function based on the
171195
// provided options. It is shared by both direct and hook invocations.
172-
func runValidate(ctx context.Context, workDir, name, inlineCmd string, save bool, sidecarID, identityFile, workdir string, cfg *config.ProjectConfig, statusFn iostream.StatusFunc, streams iostream.Streams) error {
173-
// --cmd: inline command
196+
// allRemote is true when --remote is passed explicitly (all commands run on the
197+
// sidecar); false means only commands with Remote:true are routed to the sidecar.
198+
func runValidate(ctx context.Context, workDir, name, inlineCmd string, save bool, sidecarID, identityFile, workdir string, allRemote bool, cfg *config.ProjectConfig, statusFn iostream.StatusFunc, streams iostream.Streams) error {
199+
// --cmd: inline command (always local in per-command mode)
174200
if inlineCmd != "" {
175201
cmdName := name
176202
if cmdName == "" {
@@ -182,23 +208,65 @@ func runValidate(ctx context.Context, workDir, name, inlineCmd string, save bool
182208
}
183209
streams.ErrPrintf("%s\n", ui.Success(fmt.Sprintf("Saved %s to .chunk/config.json", cmdName)))
184210
}
185-
if sidecarID != "" {
211+
if sidecarID != "" && allRemote {
186212
execFn, dest, err := openSSHSession(ctx, sidecarID, identityFile, workdir, streams)
187213
if err != nil {
188214
return err
189215
}
190-
return validate.RunRemoteInline(ctx, execFn, cmdName, inlineCmd, dest, streams)
216+
return validate.RunRemoteInline(ctx, execFn, cmdName, inlineCmd, dest, statusFn, streams)
191217
}
192218
return validate.RunInline(ctx, workDir, cmdName, inlineCmd, statusFn, streams)
193219
}
194220

195-
// Remote execution
196-
if sidecarID != "" {
221+
// All-remote execution (--remote flag): send everything to the sidecar.
222+
if sidecarID != "" && allRemote {
197223
execFn, dest, err := openSSHSession(ctx, sidecarID, identityFile, workdir, streams)
198224
if err != nil {
199225
return err
200226
}
201-
return validate.RunRemote(ctx, execFn, cfg, name, dest, streams)
227+
return validate.RunRemote(ctx, execFn, cfg, name, dest, statusFn, streams)
228+
}
229+
230+
// Per-command remote routing: commands with Remote:true go to the sidecar,
231+
// the rest run locally.
232+
if sidecarID != "" {
233+
if name != "" {
234+
if cmd := cfg.FindCommand(name); cmd != nil && cmd.Remote {
235+
statusFn(iostream.LevelInfo, fmt.Sprintf("running %s on sidecar %s", name, sidecarID))
236+
execFn, dest, err := openSSHSession(ctx, sidecarID, identityFile, workdir, streams)
237+
if err != nil {
238+
return err
239+
}
240+
return validate.RunRemote(ctx, execFn, cfg, name, dest, statusFn, streams)
241+
}
242+
statusFn(iostream.LevelInfo, fmt.Sprintf("running %s locally (not marked remote)", name))
243+
// Named command is not marked remote; fall through to local execution.
244+
} else {
245+
remoteCfg, localCfg := splitByRemote(cfg)
246+
if len(remoteCfg.Commands) > 0 {
247+
names := commandNames(remoteCfg.Commands)
248+
statusFn(iostream.LevelInfo, fmt.Sprintf("running on sidecar %s: %s", sidecarID, names))
249+
}
250+
if len(localCfg.Commands) > 0 {
251+
statusFn(iostream.LevelInfo, fmt.Sprintf("running locally: %s", commandNames(localCfg.Commands)))
252+
}
253+
var runErr error
254+
if len(remoteCfg.Commands) > 0 {
255+
execFn, dest, err := openSSHSession(ctx, sidecarID, identityFile, workdir, streams)
256+
if err != nil {
257+
streams.ErrPrintf("warning: could not reach sidecar (%v); running %s locally instead\n", err, commandNames(remoteCfg.Commands))
258+
localCfg.Commands = append(remoteCfg.Commands, localCfg.Commands...)
259+
} else {
260+
runErr = validate.RunRemote(ctx, execFn, remoteCfg, "", dest, statusFn, streams)
261+
}
262+
}
263+
if len(localCfg.Commands) > 0 {
264+
if err := mapValidateError(validate.RunAll(ctx, workDir, localCfg, statusFn, streams)); err != nil {
265+
runErr = errors.Join(runErr, err)
266+
}
267+
}
268+
return runErr
269+
}
202270
}
203271

204272
// Named command
@@ -270,6 +338,108 @@ func openSSHSession(ctx context.Context, sidecarID, identityFile, workdir string
270338
return execFn, dest, nil
271339
}
272340

341+
// splitByRemote partitions cfg.Commands into two configs: one containing only
342+
// commands with Remote:true, and one containing the rest.
343+
func splitByRemote(cfg *config.ProjectConfig) (remote, local *config.ProjectConfig) {
344+
remote = &config.ProjectConfig{}
345+
local = &config.ProjectConfig{}
346+
for _, cmd := range cfg.Commands {
347+
if cmd.Remote {
348+
remote.Commands = append(remote.Commands, cmd)
349+
} else {
350+
local.Commands = append(local.Commands, cmd)
351+
}
352+
}
353+
return remote, local
354+
}
355+
356+
// commandNames returns a comma-separated list of command names.
357+
func commandNames(cmds []config.Command) string {
358+
names := make([]string, len(cmds))
359+
for i, c := range cmds {
360+
names[i] = c.Name
361+
}
362+
return strings.Join(names, ", ")
363+
}
364+
365+
// resolveImage returns the sidecar image to use for sandbox creation.
366+
// A per-command sidecarImage takes precedence over the project-level default.
367+
func resolveImage(name string, cfg *config.ProjectConfig) string {
368+
if name != "" && cfg != nil {
369+
if cmd := cfg.FindCommand(name); cmd != nil && cmd.SidecarImage != "" {
370+
return cmd.SidecarImage
371+
}
372+
}
373+
if cfg != nil && cfg.Validation != nil {
374+
return cfg.Validation.SidecarImage
375+
}
376+
return ""
377+
}
378+
379+
// resolveOrCreateSidecarID fills sidecarID from the active sidecar, or creates
380+
// a new sandbox when none is configured.
381+
func resolveOrCreateSidecarID(ctx context.Context, sidecarID *string, orgID, image, workDir string, streams iostream.Streams) error {
382+
if *sidecarID != "" {
383+
return nil
384+
}
385+
active, err := sidecar.LoadActive()
386+
if err != nil {
387+
return &userError{msg: "Could not load the active sidecar.", suggestion: configFilePermHint, err: err}
388+
}
389+
if active != nil {
390+
*sidecarID = active.SidecarID
391+
return nil
392+
}
393+
streams.ErrPrintf("No active sidecar found, creating a new sandbox...\n")
394+
client, err := ensureCircleCIClient(ctx, streams, tui.PromptHidden)
395+
if err != nil {
396+
return err
397+
}
398+
// Fallback: read org ID from project config if not provided via flag or env.
399+
if orgID == "" {
400+
if projCfg, loadErr := config.LoadProjectConfig(workDir); loadErr == nil && projCfg.OrgID != "" {
401+
orgID = projCfg.OrgID
402+
}
403+
}
404+
resolvedOrgID, err := resolveOrgID(orgID, orgPicker(ctx, client))
405+
if err != nil {
406+
return err
407+
}
408+
provider := os.Getenv(config.EnvSidecarProvider)
409+
if provider == "" {
410+
provider = defaultProvider
411+
}
412+
sandboxName := filepath.Base(workDir) + "-validate"
413+
sc, err := sidecar.Create(ctx, client, resolvedOrgID, sandboxName, provider, image)
414+
if err != nil {
415+
if authErr := notAuthorized("create sidecars", err); authErr != nil {
416+
return authErr
417+
}
418+
return &userError{
419+
msg: "Could not create a sandbox.",
420+
suggestion: "Check your network connection or run 'chunk sidecar create' manually.",
421+
err: err,
422+
}
423+
}
424+
if saveErr := sidecar.SaveActive(sidecar.ActiveSidecar{SidecarID: sc.ID, Name: sc.Name}); saveErr != nil {
425+
streams.ErrPrintf("warning: could not save active sidecar: %v\n", saveErr)
426+
}
427+
// Persist the org ID so future sandbox creation skips the picker.
428+
projCfg, loadErr := config.LoadProjectConfig(workDir)
429+
if loadErr != nil {
430+
projCfg = &config.ProjectConfig{}
431+
}
432+
if projCfg.OrgID == "" {
433+
projCfg.OrgID = resolvedOrgID
434+
if saveErr := config.SaveProjectConfig(workDir, projCfg); saveErr != nil {
435+
streams.ErrPrintf("warning: could not save org ID to project config: %v\n", saveErr)
436+
}
437+
}
438+
streams.ErrPrintf("%s\n", ui.Success(fmt.Sprintf("Created sandbox %s (%s)", sc.Name, sc.ID)))
439+
*sidecarID = sc.ID
440+
return nil
441+
}
442+
273443
func mapValidateError(err error) error {
274444
if errors.Is(err, validate.ErrNotConfigured) {
275445
return &userError{

internal/config/config.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -256,9 +256,16 @@ func MaskKey(key string) string {
256256
return strings.Repeat("*", len(key)-4) + key[len(key)-4:]
257257
}
258258

259-
// ValidConfigKeys are the keys accepted by "config set".
259+
// ValidConfigKeys are the keys accepted by "config set" that write to the user config.
260260
// Credentials (anthropicAPIKey, circleCIToken) are intentionally excluded —
261261
// users should use "auth set" which validates before storing.
262262
var ValidConfigKeys = map[string]bool{
263263
"model": true,
264264
}
265+
266+
// ValidProjectConfigKeys are the keys accepted by "config set" that write to
267+
// the project config (.chunk/config.json).
268+
var ValidProjectConfigKeys = map[string]bool{
269+
"orgID": true,
270+
"validation.sidecarImage": true,
271+
}

0 commit comments

Comments
 (0)