diff --git a/cmd/create.go b/cmd/create.go index 0a67068..8f309bd 100644 --- a/cmd/create.go +++ b/cmd/create.go @@ -197,23 +197,6 @@ func createViaLauncher(harnessDir string, gwCfg *gateway.GatewayConfig, gw gatew // createDirect deploys a sandbox via the openshell CLI (no launcher needed). func createDirect(harnessDir string, gw gateway.Gateway, profileName string, cfg *profile.Config, registered []string) error { - if cfg.From != "" && !filepath.IsAbs(cfg.From) { - candidate := filepath.Join(harnessDir, cfg.From) - if info, err := os.Stat(candidate); err == nil && info.IsDir() { - cfg.From = candidate - } - } - - tmpParent, err := os.MkdirTemp("", "harness-") - if err != nil { - return fmt.Errorf("creating staging dir: %w", err) - } - defer os.RemoveAll(tmpParent) - harnessUploadDir := filepath.Join(tmpParent, "openshell") - if err := profile.StageHarnessDir(cfg, harnessUploadDir); err != nil { - return fmt.Errorf("staging files: %w", err) - } - var sandboxCmd []string if cfg.Startup != "" { sandboxCmd = []string{"bash", "-c", fmt.Sprintf(". %s", cfg.Startup)} @@ -221,32 +204,19 @@ func createDirect(harnessDir string, gw gateway.Gateway, profileName string, cfg sandboxCmd = []string{"true"} } - for attempt := 1; attempt <= 5; attempt++ { - err := gw.SandboxCreate(gateway.SandboxCreateOpts{ - Name: cfg.Name, - From: cfg.From, - Providers: registered, - TTY: false, - Keep: cfg.KeepSandbox(), - UploadSrc: harnessUploadDir, - UploadDst: "/sandbox/.config", - Command: sandboxCmd, - }) - if err == nil { + return createSandbox(sandboxOpts{ + harnessDir: harnessDir, + gw: gw, + cfg: cfg, + providers: registered, + noTTY: true, + retrySleep: 5 * time.Second, + sandboxCmd: sandboxCmd, + onSuccess: func(name string) { fmt.Println() - status.OKf("Sandbox created: %s — connect with: harness connect %s", cfg.Name, cfg.Name) - return nil - } - - fmt.Printf(" Attempt %d failed: %v, retrying in 5s...\n", attempt, err) - gw.SandboxDelete(cfg.Name) - - if attempt == 5 { - return fmt.Errorf("sandbox create failed after 5 attempts: %w", err) - } - time.Sleep(5 * time.Second) - } - return nil + status.OKf("Sandbox created: %s — connect with: harness connect %s", name, name) + }, + }) } func providerInList(name string, providers []string) bool { diff --git a/cmd/sandbox.go b/cmd/sandbox.go new file mode 100644 index 0000000..5b98da8 --- /dev/null +++ b/cmd/sandbox.go @@ -0,0 +1,84 @@ +package cmd + +import ( + "fmt" + "os" + "path/filepath" + "time" + + "github.com/robbycochran/harness-openshell/internal/gateway" + "github.com/robbycochran/harness-openshell/internal/profile" +) + +// sandboxOpts holds the parameters that vary between callers of +// createSandbox (upLocal vs createDirect). Everything else is +// derived from the profile.Config passed alongside. +type sandboxOpts struct { + harnessDir string + gw gateway.Gateway + cfg *profile.Config + providers []string // registered providers to attach + noTTY bool // true → TTY=false for the sandbox + retrySleep time.Duration // pause between retry attempts + sandboxCmd []string // command to run inside the sandbox + onSuccess func(name string) // called after successful creation (optional) +} + +// createSandbox resolves the image path, stages the harness directory, +// creates the sandbox with up to 5 retries, and cleans up on failure. +// Both upLocal and createDirect delegate to this function after +// preparing their caller-specific sandboxOpts. +func createSandbox(opts sandboxOpts) error { + cfg := opts.cfg + + // Resolve image path: SANDBOX_IMAGE env overrides profile; relative + // Dockerfile dirs are resolved against harnessDir. + if envImage := os.Getenv("SANDBOX_IMAGE"); envImage != "" { + cfg.From = envImage + } else if cfg.From != "" && !filepath.IsAbs(cfg.From) { + candidate := filepath.Join(opts.harnessDir, cfg.From) + if info, err := os.Stat(candidate); err == nil && info.IsDir() { + cfg.From = candidate + } + } + + // Stage files for upload into the sandbox. + tmpParent, err := os.MkdirTemp("", "harness-") + if err != nil { + return fmt.Errorf("creating staging dir: %w", err) + } + defer os.RemoveAll(tmpParent) + harnessUploadDir := filepath.Join(tmpParent, "openshell") + if err := profile.StageHarnessDir(cfg, harnessUploadDir); err != nil { + return fmt.Errorf("staging files: %w", err) + } + + // Create sandbox with retry loop (up to 5 attempts). + for attempt := 1; attempt <= 5; attempt++ { + err := opts.gw.SandboxCreate(gateway.SandboxCreateOpts{ + Name: cfg.Name, + From: cfg.From, + Providers: opts.providers, + TTY: !opts.noTTY, + Keep: cfg.KeepSandbox(), + UploadSrc: harnessUploadDir, + UploadDst: "/sandbox/.config", + Command: opts.sandboxCmd, + }) + if err == nil { + if opts.onSuccess != nil { + opts.onSuccess(cfg.Name) + } + return nil + } + + fmt.Printf(" Attempt %d failed: %v, retrying in 5s...\n", attempt, err) + opts.gw.SandboxDelete(cfg.Name) // best-effort cleanup + + if attempt == 5 { + return fmt.Errorf("sandbox create failed after 5 attempts: %w", err) + } + time.Sleep(opts.retrySleep) + } + return nil // unreachable but required by compiler +} diff --git a/cmd/up.go b/cmd/up.go index e16f7a5..6cadc0c 100644 --- a/cmd/up.go +++ b/cmd/up.go @@ -303,11 +303,11 @@ func upLocal(opts upLocalOpts) error { } injectAtlassianEnv(cfg) - // SANDBOX_IMAGE env var overrides the profile's image (dev/CI builds). + // Resolve image before printing so the log shows the effective path. + // createSandbox resolves idempotently, so this won't double-resolve. if envImage := os.Getenv("SANDBOX_IMAGE"); envImage != "" { cfg.From = envImage } else if cfg.From != "" && !filepath.IsAbs(cfg.From) { - // Resolve Dockerfile path relative to harnessDir candidate := filepath.Join(opts.harnessDir, cfg.From) if info, err := os.Stat(candidate); err == nil && info.IsDir() { cfg.From = candidate @@ -333,21 +333,7 @@ func upLocal(opts upLocalOpts) error { status.Warn("no providers available — run: harness providers") } - // 5. Stage files - // The upload preserves the source dir name as a subdirectory at the destination. - // startup.sh expects files at /sandbox/.config/openshell/sandbox.env, so the - // staging dir must be named "openshell". - tmpParent, err := os.MkdirTemp("", "harness-") - if err != nil { - return fmt.Errorf("creating staging dir: %w", err) - } - defer os.RemoveAll(tmpParent) - harnessUploadDir := filepath.Join(tmpParent, "openshell") - if err := profile.StageHarnessDir(cfg, harnessUploadDir); err != nil { - return fmt.Errorf("staging files: %w", err) - } - - // 6. Build command + // 5. Build command var sandboxCmd []string if cfg.Startup != "" { if opts.noTTY { @@ -363,33 +349,18 @@ func upLocal(opts upLocalOpts) error { } } - // 7. Create sandbox with retry + // 6. Create sandbox fmt.Println() fmt.Println("=== Creating sandbox ===") - for attempt := 1; attempt <= 5; attempt++ { - err := gw.SandboxCreate(gateway.SandboxCreateOpts{ - Name: cfg.Name, - From: cfg.From, - Providers: registered, - TTY: !opts.noTTY, - Keep: cfg.KeepSandbox(), - UploadSrc: harnessUploadDir, - UploadDst: "/sandbox/.config", - Command: sandboxCmd, - }) - if err == nil { - return nil - } - - fmt.Printf(" Attempt %d failed: %v, retrying in 5s...\n", attempt, err) - gw.SandboxDelete(cfg.Name) // best-effort cleanup - - if attempt == 5 { - return fmt.Errorf("sandbox create failed after 5 attempts: %w", err) - } - time.Sleep(opts.retrySleep) - } - return nil // unreachable but required by compiler + return createSandbox(sandboxOpts{ + harnessDir: opts.harnessDir, + gw: gw, + cfg: cfg, + providers: registered, + noTTY: opts.noTTY, + retrySleep: opts.retrySleep, + sandboxCmd: sandboxCmd, + }) } // injectAtlassianEnv adds JIRA_URL and JIRA_USERNAME from the local environment