Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 12 additions & 42 deletions cmd/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -197,56 +197,26 @@ 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)}
} else {
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 {
Expand Down
84 changes: 84 additions & 0 deletions cmd/sandbox.go
Original file line number Diff line number Diff line change
@@ -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
}
55 changes: 13 additions & 42 deletions cmd/up.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand All @@ -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
Expand Down
Loading