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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ __pycache__/
.certs/

# Build artifacts
harness
sandbox/launcher/openshell
sandbox/launcher/launcher
infracluster/
Expand Down
43 changes: 35 additions & 8 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,16 @@ PLATFORM := linux/amd64
SANDBOX_IMAGE := $(REGISTRY):sandbox
LAUNCHER_IMAGE := $(REGISTRY):launcher

.PHONY: sandbox push-sandbox cli-launcher launcher push-launcher \
test test-podman test-ocp clean help
.PHONY: cli sandbox push-sandbox cli-launcher launcher push-launcher \
test-unit test test-podman test-ocp \
test-go-podman test-go-ocp test-all clean help

## ── CLI ──────────────────────────────────────────────────────────────

## Build the harness CLI binary
cli:
CGO_ENABLED=0 go build -o harness .
@echo "Built: ./harness"

## ── Images ────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -42,24 +50,43 @@ push-launcher: launcher

## ── Test targets ─────────────────────────────────────────────────────

## Build + push sandbox and launcher, then run full tests on both platforms
## Unit tests only (no live gateway, fast)
test-unit:
CGO_ENABLED=0 go test ./...
cd sandbox/launcher && go test ./...
bats test/preflight.bats

## Bash + both platforms (full lifecycle, rebuilds images)
test: sandbox push-launcher
./test/test-flow.sh all --full

## Build + push sandbox and launcher, then run full podman test
## Bash + podman (full lifecycle)
test-podman: sandbox push-launcher
./test/test-flow.sh podman --full

## Build + push sandbox and launcher, then run full OCP test
## Bash + OCP (full lifecycle)
test-ocp: sandbox push-launcher
./test/test-flow.sh ocp --full

## Go + podman (full lifecycle, rebuilds CLI + images)
test-go-podman: cli sandbox push-launcher
./test/test-flow.sh podman --full --go

## Go + OCP (full lifecycle)
test-go-ocp: cli sandbox push-launcher
./test/test-flow.sh ocp --full --go

## All 4 combinations: {bash,go} x {podman,ocp}
test-all: cli sandbox push-launcher
./test/test-flow.sh all --full
./test/test-flow.sh all --full --go

## ── Convenience targets ───────────────────────────────────────────────

## Clean staged binaries
## Clean built binaries
clean:
rm -f sandbox/launcher/openshell sandbox/launcher/launcher
@echo "Cleaned staged binaries"
rm -f harness sandbox/launcher/openshell sandbox/launcher/launcher
@echo "Cleaned binaries"

## Show available targets
help:
Expand Down
22 changes: 22 additions & 0 deletions cmd/connect.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package cmd

import (
"github.com/robbycochran/harness-openshell/internal/gateway"
"github.com/spf13/cobra"
)

func NewConnectCmd(cli string) *cobra.Command {
return &cobra.Command{
Use: "connect [SANDBOX_NAME]",
Short: "Reconnect to a running sandbox",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
gw := gateway.NewCLI(cli)
name := ""
if len(args) > 0 {
name = args[0]
}
return gw.SandboxConnect(name)
},
}
}
17 changes: 17 additions & 0 deletions cmd/deploy.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package cmd

import (
"github.com/robbycochran/harness-openshell/internal/runner"
"github.com/spf13/cobra"
)

func NewDeployCmd(harnessDir string) *cobra.Command {
return &cobra.Command{
Use: "deploy [--local|--remote]",
Short: "Deploy or verify the gateway",
DisableFlagParsing: true,
RunE: func(cmd *cobra.Command, args []string) error {
return runner.RunScript(harnessDir, "deploy.sh", args...)
},
}
}
178 changes: 178 additions & 0 deletions cmd/new.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
package cmd

import (
"fmt"
"os"
"time"

"github.com/robbycochran/harness-openshell/internal/gateway"
"github.com/robbycochran/harness-openshell/internal/profile"
"github.com/robbycochran/harness-openshell/internal/runner"
"github.com/spf13/cobra"
)

func NewNewCmd(harnessDir, cli string) *cobra.Command {
var (
local bool
remote bool
profileName string
sandboxName string
noTTY bool
)

cmd := &cobra.Command{
Use: "new [flags]",
Short: "Create a new sandbox",
Long: "Deploy gateway and providers if needed, then create a sandbox from a profile.",
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) > 0 && sandboxName == "" {
sandboxName = args[0]
}

if remote {
return newRemote(harnessDir, profileName, sandboxName, noTTY)
}

gw := gateway.NewCLI(cli)
return newLocal(newLocalOpts{
harnessDir: harnessDir,
gw: gw,
ensureLocal: local,
profileName: profileName,
sandboxName: sandboxName,
noTTY: noTTY,
runScript: func(name string, args ...string) error {
return runner.RunScript(harnessDir, name, args...)
},
retrySleep: 5 * time.Second,
})
},
}

cmd.Flags().BoolVar(&local, "local", false, "Ensure local podman gateway")
cmd.Flags().BoolVar(&remote, "remote", false, "Ensure OCP gateway")
cmd.Flags().StringVar(&profileName, "profile", "default", "Profile name (from profiles/)")
cmd.Flags().StringVar(&sandboxName, "name", "", "Sandbox name (overrides profile)")
cmd.Flags().BoolVar(&noTTY, "no-tty", false, "Non-interactive mode (for testing)")

return cmd
}

type newLocalOpts struct {
harnessDir string
gw gateway.Gateway
ensureLocal bool
profileName string
sandboxName string
noTTY bool
runScript func(name string, args ...string) error
retrySleep time.Duration
}

func newRemote(harnessDir, profileName, sandboxName string, noTTY bool) error {
args := []string{"--remote", "--profile", profileName}
if sandboxName != "" {
args = append(args, "--name", sandboxName)
}
if noTTY {
args = append(args, "--no-tty")
}
return runner.RunScript(harnessDir, "new.sh", args...)
}

func newLocal(opts newLocalOpts) error {
gw := opts.gw

// 1. Ensure gateway
if opts.ensureLocal {
fmt.Println("=== Ensuring local gateway ===")
if err := opts.runScript("deploy.sh", "--local"); err != nil {
return fmt.Errorf("deploy failed: %w", err)
}
} else {
if err := gw.InferenceGet(); err != nil {
return fmt.Errorf("no active gateway — use --local or --remote")
}
}

// 2. Ensure providers
providers, _ := gw.ProviderList()
if len(providers) == 0 {
fmt.Println("\n=== Registering providers ===")
if err := opts.runScript("providers.sh"); err != nil {
return fmt.Errorf("provider registration failed: %w", err)
}
}

// 3. Parse profile
cfg, err := profile.Parse(opts.harnessDir, opts.profileName)
if err != nil {
return err
}
if opts.sandboxName != "" {
cfg.Name = opts.sandboxName
}

fmt.Println()
fmt.Println("=== Sandbox ===")
fmt.Printf(" Profile: %s\n", opts.profileName)
fmt.Printf(" Image: %s\n", cfg.Image)

// 4. Validate providers against profile
fmt.Println()
fmt.Println("=== Providers ===")
registered, missing := profile.ValidateProviders(cfg.Providers, gw)
for _, name := range registered {
fmt.Printf(" ✓ %s: attached\n", name)
}
for _, name := range missing {
fmt.Printf(" ✗ %s: not registered (skipping)\n", name)
}
if len(missing) > 0 && len(registered) == 0 {
fmt.Println()
fmt.Println("WARNING: no providers available. Run: harness providers")
}

// 5. Stage files
harnessUploadDir := "/tmp/openshell"
os.RemoveAll(harnessUploadDir)
if err := profile.StageHarnessDir(cfg, harnessUploadDir); err != nil {
return fmt.Errorf("staging files: %w", err)
}

// 6. Build command
var sandboxCmd []string
if opts.noTTY {
sandboxCmd = []string{"bash", "/sandbox/startup.sh"}
} else {
sandboxCmd = []string{"bash", "-c", fmt.Sprintf(". /sandbox/startup.sh && exec %s", cfg.Command)}
}

// 7. Create sandbox with retry
fmt.Println()
fmt.Println("=== Creating sandbox ===")
for attempt := 1; attempt <= 5; attempt++ {
err := gw.SandboxCreate(gateway.SandboxCreateOpts{
Name: cfg.Name,
Image: cfg.Image,
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 (supervisor race), retrying in 5s...\n", attempt)
gw.SandboxDelete(cfg.Name)

if attempt == 5 {
return fmt.Errorf("failed after 5 attempts")
}
time.Sleep(opts.retrySleep)
}
return nil
}
Loading