diff --git a/.gitignore b/.gitignore index 4680f41..bdfe82d 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ __pycache__/ .certs/ # Build artifacts +harness sandbox/launcher/openshell sandbox/launcher/launcher infracluster/ diff --git a/Makefile b/Makefile index fe0b34a..cef0857 100644 --- a/Makefile +++ b/Makefile @@ -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 ──────────────────────────────────────────────────────────── @@ -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: diff --git a/cmd/connect.go b/cmd/connect.go new file mode 100644 index 0000000..b5595bb --- /dev/null +++ b/cmd/connect.go @@ -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) + }, + } +} diff --git a/cmd/deploy.go b/cmd/deploy.go new file mode 100644 index 0000000..82f1735 --- /dev/null +++ b/cmd/deploy.go @@ -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...) + }, + } +} diff --git a/cmd/new.go b/cmd/new.go new file mode 100644 index 0000000..887d9cb --- /dev/null +++ b/cmd/new.go @@ -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 +} diff --git a/cmd/new_test.go b/cmd/new_test.go new file mode 100644 index 0000000..a4d9362 --- /dev/null +++ b/cmd/new_test.go @@ -0,0 +1,233 @@ +package cmd + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/robbycochran/harness-openshell/internal/gateway" +) + +type mockGW struct { + inferenceErr error + providers map[string]bool + providerList []string + providerErr error + createErr error + createCalls int + createOpts []gateway.SandboxCreateOpts + deletedNames []string +} + +func (m *mockGW) InferenceGet() error { return m.inferenceErr } +func (m *mockGW) ProviderGet(name string) error { + if m.providers[name] { + return nil + } + return fmt.Errorf("not found") +} +func (m *mockGW) ProviderList() ([]string, error) { return m.providerList, m.providerErr } +func (m *mockGW) SandboxCreate(opts gateway.SandboxCreateOpts) error { + m.createCalls++ + m.createOpts = append(m.createOpts, opts) + if m.createErr != nil && m.createCalls == 1 { + return m.createErr + } + return nil +} +func (m *mockGW) SandboxDelete(name string) error { + m.deletedNames = append(m.deletedNames, name) + return nil +} +func (m *mockGW) SandboxConnect(string) error { return nil } +func (m *mockGW) SandboxUpload(string, string, string) error { return nil } +func (m *mockGW) SandboxExec(string, ...string) error { return nil } + +func setupTestProfile(t *testing.T) string { + t.Helper() + dir := t.TempDir() + os.MkdirAll(filepath.Join(dir, "profiles"), 0o755) + os.WriteFile(filepath.Join(dir, "profiles", "default.toml"), []byte(` +name = "test-agent" +image = "quay.io/test:latest" +command = "claude --bare" +providers = ["github", "vertex-local", "atlassian"] + +[env] +FOO = "bar" +`), 0o644) + return dir +} + +func noopScript(name string, args ...string) error { return nil } + +func TestNewLocal_NoGateway(t *testing.T) { + dir := setupTestProfile(t) + gw := &mockGW{inferenceErr: fmt.Errorf("connection refused")} + + err := newLocal(newLocalOpts{ + harnessDir: dir, + gw: gw, + profileName: "default", + noTTY: true, + runScript: noopScript, + }) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "no active gateway") { + t.Errorf("error = %q, want 'no active gateway'", err) + } +} + +func TestNewLocal_NoProviders_CallsScript(t *testing.T) { + dir := setupTestProfile(t) + scriptCalled := false + gw := &mockGW{ + providerList: nil, + providers: map[string]bool{}, + } + + newLocal(newLocalOpts{ + harnessDir: dir, + gw: gw, + profileName: "default", + noTTY: true, + runScript: func(name string, args ...string) error { + if name == "providers.sh" { + scriptCalled = true + } + return nil + }, + }) + if !scriptCalled { + t.Error("expected providers.sh to be called when no providers registered") + } +} + +func TestNewLocal_MissingProviders(t *testing.T) { + dir := setupTestProfile(t) + gw := &mockGW{ + providerList: []string{"github"}, + providers: map[string]bool{"github": true}, + } + + err := newLocal(newLocalOpts{ + harnessDir: dir, + gw: gw, + profileName: "default", + noTTY: true, + runScript: noopScript, + }) + if err != nil { + t.Fatalf("newLocal: %v", err) + } + if gw.createCalls != 1 { + t.Fatalf("createCalls = %d, want 1", gw.createCalls) + } + opts := gw.createOpts[0] + if len(opts.Providers) != 1 || opts.Providers[0] != "github" { + t.Errorf("Providers = %v, want [github] only", opts.Providers) + } +} + +func TestNewLocal_AllProvidersMissing(t *testing.T) { + dir := setupTestProfile(t) + gw := &mockGW{ + providerList: []string{"github"}, + providers: map[string]bool{}, + } + + err := newLocal(newLocalOpts{ + harnessDir: dir, + gw: gw, + profileName: "default", + noTTY: true, + runScript: noopScript, + }) + if err != nil { + t.Fatalf("newLocal: %v", err) + } + opts := gw.createOpts[0] + if len(opts.Providers) != 0 { + t.Errorf("Providers = %v, want empty", opts.Providers) + } +} + +func TestNewLocal_ProfileNotFound(t *testing.T) { + dir := setupTestProfile(t) + gw := &mockGW{providerList: []string{"github"}} + + err := newLocal(newLocalOpts{ + harnessDir: dir, + gw: gw, + profileName: "nonexistent", + noTTY: true, + runScript: noopScript, + }) + if err == nil { + t.Fatal("expected error for missing profile") + } +} + +func TestNewLocal_SandboxCreateRetry(t *testing.T) { + dir := setupTestProfile(t) + gw := &mockGW{ + providerList: []string{"github"}, + providers: map[string]bool{"github": true}, + createErr: fmt.Errorf("supervisor race"), + } + + err := newLocal(newLocalOpts{ + harnessDir: dir, + gw: gw, + profileName: "default", + noTTY: true, + runScript: noopScript, + retrySleep: 0, + }) + if err != nil { + t.Fatalf("newLocal: %v", err) + } + if gw.createCalls != 2 { + t.Errorf("createCalls = %d, want 2 (first fails, second succeeds)", gw.createCalls) + } + if len(gw.deletedNames) != 1 { + t.Errorf("deletedNames = %v, want 1 cleanup delete", gw.deletedNames) + } +} + +func TestNewLocal_SandboxCreateOpts(t *testing.T) { + dir := setupTestProfile(t) + gw := &mockGW{ + providerList: []string{"github", "vertex-local"}, + providers: map[string]bool{"github": true, "vertex-local": true}, + } + + err := newLocal(newLocalOpts{ + harnessDir: dir, + gw: gw, + profileName: "default", + sandboxName: "custom-name", + noTTY: true, + runScript: noopScript, + }) + if err != nil { + t.Fatalf("newLocal: %v", err) + } + opts := gw.createOpts[0] + if opts.Name != "custom-name" { + t.Errorf("Name = %q, want custom-name", opts.Name) + } + if opts.Image != "quay.io/test:latest" { + t.Errorf("Image = %q", opts.Image) + } + if opts.TTY { + t.Error("TTY = true, want false (noTTY)") + } + if !opts.Keep { + t.Error("Keep = false, want true (default)") + } +} diff --git a/cmd/preflight.go b/cmd/preflight.go new file mode 100644 index 0000000..62782e1 --- /dev/null +++ b/cmd/preflight.go @@ -0,0 +1,17 @@ +package cmd + +import ( + "github.com/robbycochran/harness-openshell/internal/runner" + "github.com/spf13/cobra" +) + +func NewPreflightCmd(harnessDir string) *cobra.Command { + return &cobra.Command{ + Use: "preflight [--strict]", + Short: "Check environment prerequisites", + DisableFlagParsing: true, + RunE: func(cmd *cobra.Command, args []string) error { + return runner.RunScript(harnessDir, "preflight.sh", args...) + }, + } +} diff --git a/cmd/providers.go b/cmd/providers.go new file mode 100644 index 0000000..843f164 --- /dev/null +++ b/cmd/providers.go @@ -0,0 +1,17 @@ +package cmd + +import ( + "github.com/robbycochran/harness-openshell/internal/runner" + "github.com/spf13/cobra" +) + +func NewProvidersCmd(harnessDir string) *cobra.Command { + return &cobra.Command{ + Use: "providers [--force]", + Short: "Register providers with the gateway", + DisableFlagParsing: true, + RunE: func(cmd *cobra.Command, args []string) error { + return runner.RunScript(harnessDir, "providers.sh", args...) + }, + } +} diff --git a/cmd/teardown.go b/cmd/teardown.go new file mode 100644 index 0000000..6cb2e6f --- /dev/null +++ b/cmd/teardown.go @@ -0,0 +1,17 @@ +package cmd + +import ( + "github.com/robbycochran/harness-openshell/internal/runner" + "github.com/spf13/cobra" +) + +func NewTeardownCmd(harnessDir string) *cobra.Command { + return &cobra.Command{ + Use: "teardown [--sandboxes] [--providers] [--k8s]", + Short: "Tear down sandboxes, providers, or k8s resources", + DisableFlagParsing: true, + RunE: func(cmd *cobra.Command, args []string) error { + return runner.RunScript(harnessDir, "teardown.sh", args...) + }, + } +} diff --git a/cmd/test.go b/cmd/test.go new file mode 100644 index 0000000..c679819 --- /dev/null +++ b/cmd/test.go @@ -0,0 +1,26 @@ +package cmd + +import ( + "os" + "os/exec" + "path/filepath" + + "github.com/spf13/cobra" +) + +func NewTestCmd(harnessDir string) *cobra.Command { + return &cobra.Command{ + Use: "test [podman|ocp|all] [--full]", + Short: "End-to-end validation", + DisableFlagParsing: true, + RunE: func(cmd *cobra.Command, args []string) error { + path := filepath.Join(harnessDir, "test", "test-flow.sh") + c := exec.Command("bash", append([]string{path}, args...)...) + c.Stdin = os.Stdin + c.Stdout = os.Stdout + c.Stderr = os.Stderr + c.Dir = harnessDir + return c.Run() + }, + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..9ce1bc4 --- /dev/null +++ b/go.mod @@ -0,0 +1,13 @@ +module github.com/robbycochran/harness-openshell + +go 1.22.4 + +require ( + github.com/BurntSushi/toml v1.6.0 + github.com/spf13/cobra v1.10.2 +) + +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/pflag v1.0.9 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..c09a7f6 --- /dev/null +++ b/go.sum @@ -0,0 +1,12 @@ +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/gateway/cli.go b/internal/gateway/cli.go new file mode 100644 index 0000000..3dcb3d1 --- /dev/null +++ b/internal/gateway/cli.go @@ -0,0 +1,120 @@ +package gateway + +import ( + "io" + "os" + "os/exec" + "strings" + "syscall" +) + +// CLI implements Gateway by shelling out to the openshell binary. +type CLI struct { + bin string // path or name of the openshell binary +} + +func NewCLI(bin string) *CLI { + return &CLI{bin: bin} +} + +func (c *CLI) InferenceGet() error { + return c.silent("inference", "get") +} + +func (c *CLI) ProviderGet(name string) error { + return c.silent("provider", "get", name) +} + +func (c *CLI) ProviderList() ([]string, error) { + out, err := c.output("provider", "list") + if err != nil { + return nil, err + } + var names []string + for i, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + if i == 0 || strings.TrimSpace(line) == "" { + continue // skip header + } + fields := strings.Fields(line) + if len(fields) > 0 { + names = append(names, fields[0]) + } + } + return names, nil +} + +func (c *CLI) SandboxCreate(opts SandboxCreateOpts) error { + args := []string{"sandbox", "create", "--name", opts.Name} + if opts.TTY { + args = append(args, "--tty") + } else { + args = append(args, "--no-tty") + } + if opts.Image != "" { + args = append(args, "--from", opts.Image) + } + for _, p := range opts.Providers { + args = append(args, "--provider", p) + } + if !opts.Keep { + args = append(args, "--no-keep") + } + if opts.UploadSrc != "" { + args = append(args, "--upload", opts.UploadSrc+":"+opts.UploadDst, "--no-git-ignore") + } + if len(opts.Command) > 0 { + args = append(args, "--") + args = append(args, opts.Command...) + } + return c.passthrough(args...) +} + +func (c *CLI) SandboxDelete(name string) error { + return c.silent("sandbox", "delete", name) +} + +func (c *CLI) SandboxConnect(name string) error { + path, err := exec.LookPath(c.bin) + if err != nil { + return err + } + args := []string{c.bin, "sandbox", "connect"} + if name != "" { + args = append(args, name) + } + return syscall.Exec(path, args, os.Environ()) +} + +func (c *CLI) SandboxUpload(name, localDir, remotePath string) error { + return c.passthrough("sandbox", "upload", name, localDir, remotePath, "--no-git-ignore") +} + +func (c *CLI) SandboxExec(name string, command ...string) error { + args := []string{"sandbox", "exec", "--name", name, "--"} + args = append(args, command...) + return c.passthrough(args...) +} + +// passthrough runs the CLI with stdin/stdout/stderr connected. +func (c *CLI) passthrough(args ...string) error { + cmd := exec.Command(c.bin, args...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +// silent runs the CLI with all output discarded. +func (c *CLI) silent(args ...string) error { + cmd := exec.Command(c.bin, args...) + cmd.Stdout = io.Discard + cmd.Stderr = io.Discard + return cmd.Run() +} + +// output runs the CLI and returns stdout. +func (c *CLI) output(args ...string) ([]byte, error) { + cmd := exec.Command(c.bin, args...) + cmd.Stderr = io.Discard + return cmd.Output() +} diff --git a/internal/gateway/cli_test.go b/internal/gateway/cli_test.go new file mode 100644 index 0000000..42a3fed --- /dev/null +++ b/internal/gateway/cli_test.go @@ -0,0 +1,185 @@ +package gateway + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func writeStub(t *testing.T, script string) string { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "stub") + if err := os.WriteFile(path, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + return path +} + +func TestProviderList_ParsesTable(t *testing.T) { + bin := writeStub(t, `#!/bin/bash +printf "NAME\tTYPE\tSTATUS\n" +printf "github\tgithub\tactive\n" +printf "vertex-local\tgoogle-vertex-ai\tactive\n" +printf "atlassian\tatlassian\tactive\n" +`) + gw := NewCLI(bin) + names, err := gw.ProviderList() + if err != nil { + t.Fatalf("ProviderList: %v", err) + } + if len(names) != 3 { + t.Fatalf("got %d providers, want 3: %v", len(names), names) + } + if names[0] != "github" || names[1] != "vertex-local" || names[2] != "atlassian" { + t.Errorf("names = %v", names) + } +} + +func TestProviderList_Empty(t *testing.T) { + bin := writeStub(t, `#!/bin/bash +printf "NAME\tTYPE\tSTATUS\n" +`) + gw := NewCLI(bin) + names, err := gw.ProviderList() + if err != nil { + t.Fatalf("ProviderList: %v", err) + } + if len(names) != 0 { + t.Errorf("got %d providers, want 0: %v", len(names), names) + } +} + +func TestProviderList_CLINotFound(t *testing.T) { + gw := NewCLI("/nonexistent/openshell") + _, err := gw.ProviderList() + if err == nil { + t.Error("expected error for missing CLI") + } +} + +func TestProviderGet_Exists(t *testing.T) { + bin := writeStub(t, `#!/bin/bash +[[ "$3" == "github" ]] && exit 0 +exit 1 +`) + gw := NewCLI(bin) + if err := gw.ProviderGet("github"); err != nil { + t.Errorf("ProviderGet(github) = %v, want nil", err) + } +} + +func TestProviderGet_Missing(t *testing.T) { + bin := writeStub(t, `#!/bin/bash +exit 1 +`) + gw := NewCLI(bin) + if err := gw.ProviderGet("nonexistent"); err == nil { + t.Error("ProviderGet(nonexistent) = nil, want error") + } +} + +func TestInferenceGet_Active(t *testing.T) { + bin := writeStub(t, `#!/bin/bash +exit 0 +`) + gw := NewCLI(bin) + if err := gw.InferenceGet(); err != nil { + t.Errorf("InferenceGet = %v, want nil", err) + } +} + +func TestInferenceGet_NoGateway(t *testing.T) { + bin := writeStub(t, `#!/bin/bash +exit 1 +`) + gw := NewCLI(bin) + if err := gw.InferenceGet(); err == nil { + t.Error("InferenceGet = nil, want error") + } +} + +func TestSandboxCreate_ArgsMinimal(t *testing.T) { + bin := writeStub(t, `#!/bin/bash +echo "$@" > /tmp/test-create-args +`) + gw := NewCLI(bin) + gw.SandboxCreate(SandboxCreateOpts{ + Name: "test", + TTY: false, + Keep: true, + }) + data, _ := os.ReadFile("/tmp/test-create-args") + args := strings.TrimSpace(string(data)) + if !strings.Contains(args, "--name test") { + t.Errorf("missing --name: %s", args) + } + if !strings.Contains(args, "--no-tty") { + t.Errorf("missing --no-tty: %s", args) + } + if strings.Contains(args, "--no-keep") { + t.Errorf("should not have --no-keep: %s", args) + } + if strings.Contains(args, "--from") { + t.Errorf("should not have --from: %s", args) + } + os.Remove("/tmp/test-create-args") +} + +func TestSandboxCreate_ArgsFull(t *testing.T) { + dir := t.TempDir() + argsFile := filepath.Join(dir, "args") + bin := writeStub(t, `#!/bin/bash +echo "$@" > `+argsFile+` +`) + gw := NewCLI(bin) + gw.SandboxCreate(SandboxCreateOpts{ + Name: "my-agent", + Image: "quay.io/test:latest", + Providers: []string{"github", "vertex-local"}, + TTY: true, + Keep: false, + UploadSrc: "/tmp/openshell", + UploadDst: "/sandbox/.config", + Command: []string{"bash", "-c", "exec claude"}, + }) + data, _ := os.ReadFile(argsFile) + args := strings.TrimSpace(string(data)) + + for _, want := range []string{ + "--name my-agent", + "--tty", + "--from quay.io/test:latest", + "--provider github", + "--provider vertex-local", + "--no-keep", + "--upload /tmp/openshell:/sandbox/.config", + "--no-git-ignore", + "-- bash -c exec claude", + } { + if !strings.Contains(args, want) { + t.Errorf("missing %q in: %s", want, args) + } + } +} + +func TestSandboxDelete_Silent(t *testing.T) { + bin := writeStub(t, `#!/bin/bash +exit 0 +`) + gw := NewCLI(bin) + if err := gw.SandboxDelete("test"); err != nil { + t.Errorf("SandboxDelete = %v", err) + } +} + +func TestSandboxDelete_NotFound(t *testing.T) { + bin := writeStub(t, `#!/bin/bash +exit 1 +`) + gw := NewCLI(bin) + if err := gw.SandboxDelete("missing"); err == nil { + t.Error("SandboxDelete = nil, want error") + } +} diff --git a/internal/gateway/gateway.go b/internal/gateway/gateway.go new file mode 100644 index 0000000..6d4fb4a --- /dev/null +++ b/internal/gateway/gateway.go @@ -0,0 +1,44 @@ +package gateway + +// Gateway abstracts all OpenShell gateway operations. The CLI implementation +// shells out to the openshell binary; a future gRPC implementation will call +// the gateway API directly. +type Gateway interface { + // InferenceGet checks if the gateway is active and reachable. + InferenceGet() error + + // ProviderGet checks if a provider is registered. Returns nil if it exists. + ProviderGet(name string) error + + // ProviderList returns the names of all registered providers. + ProviderList() ([]string, error) + + // SandboxCreate creates a new sandbox. Stdin/stdout/stderr are connected + // for TTY mode. + SandboxCreate(opts SandboxCreateOpts) error + + // SandboxDelete deletes a sandbox by name. Ignores "not found" errors. + SandboxDelete(name string) error + + // SandboxConnect opens an interactive session to a running sandbox. + // In CLI mode this replaces the process (exec syscall). In gRPC mode + // this would use bidirectional streaming. + SandboxConnect(name string) error + + // SandboxUpload copies local files into a running sandbox. + SandboxUpload(name, localDir, remotePath string) error + + // SandboxExec runs a command inside a sandbox and waits for it to finish. + SandboxExec(name string, command ...string) error +} + +type SandboxCreateOpts struct { + Name string + Image string + Providers []string + TTY bool + Keep bool + UploadSrc string // local path (empty = no upload) + UploadDst string // remote path + Command []string +} diff --git a/internal/profile/parse.go b/internal/profile/parse.go new file mode 100644 index 0000000..e00ee66 --- /dev/null +++ b/internal/profile/parse.go @@ -0,0 +1,29 @@ +package profile + +import ( + "fmt" + "path/filepath" + + "github.com/BurntSushi/toml" +) + +// Parse reads a profile TOML file and returns a Config with defaults applied. +func Parse(harnessDir, name string) (*Config, error) { + path := filepath.Join(harnessDir, "profiles", name+".toml") + return ParseFile(path) +} + +// ParseFile reads a profile TOML file by path. +func ParseFile(path string) (*Config, error) { + var cfg Config + if _, err := toml.DecodeFile(path, &cfg); err != nil { + return nil, fmt.Errorf("parsing %s: %w", path, err) + } + if cfg.Name == "" { + cfg.Name = "agent" + } + if cfg.Command == "" { + cfg.Command = "claude --bare" + } + return &cfg, nil +} diff --git a/internal/profile/profile.go b/internal/profile/profile.go new file mode 100644 index 0000000..66234c4 --- /dev/null +++ b/internal/profile/profile.go @@ -0,0 +1,114 @@ +package profile + +import ( + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + + "github.com/robbycochran/harness-openshell/internal/gateway" +) + +type Config struct { + Name string `toml:"name"` + Image string `toml:"image"` + Command string `toml:"command"` + Keep *bool `toml:"keep"` + Providers []string `toml:"providers"` + Env map[string]string `toml:"env"` +} + +func (c *Config) KeepSandbox() bool { + if c.Keep == nil { + return true + } + return *c.Keep +} + +func (c *Config) BuildSandboxEnv() string { + if len(c.Env) == 0 { + return "" + } + keys := make([]string, 0, len(c.Env)) + for k := range c.Env { + keys = append(keys, k) + } + sort.Strings(keys) + var b strings.Builder + for _, k := range keys { + fmt.Fprintf(&b, "export %s=%s\n", k, c.Env[k]) + } + return b.String() +} + +// ValidateProviders checks which profile providers are registered on the +// gateway. Returns the list of registered providers and a list of missing ones. +func ValidateProviders(providers []string, gw gateway.Gateway) (registered, missing []string) { + for _, name := range providers { + if gw.ProviderGet(name) == nil { + registered = append(registered, name) + } else { + missing = append(missing, name) + } + } + return +} + +// StageHarnessDir writes sandbox.env and copies GWS credentials to harnessDir. +func StageHarnessDir(cfg *Config, harnessDir string) error { + if err := os.MkdirAll(harnessDir, 0o755); err != nil { + return err + } + + envContent := cfg.BuildSandboxEnv() + if envContent != "" { + if err := os.WriteFile(filepath.Join(harnessDir, "sandbox.env"), []byte(envContent), 0o644); err != nil { + return fmt.Errorf("writing sandbox.env: %w", err) + } + lines := strings.Count(envContent, "\n") + fmt.Printf(" Env: %d vars staged\n", lines) + } + + if err := stageGWSCreds(harnessDir); err != nil { + fmt.Printf(" GWS: %v\n", err) + } + return nil +} + +func stageGWSCreds(harnessDir string) error { + gwsPath, err := exec.LookPath("gws") + if err != nil { + return fmt.Errorf("not installed (skipping)") + } + + check := exec.Command(gwsPath, "auth", "status") + check.Stdout = io.Discard + check.Stderr = io.Discard + if check.Run() != nil { + return fmt.Errorf("not authenticated (skipping)") + } + + out, err := exec.Command(gwsPath, "auth", "export", "--unmasked").Output() + if err != nil { + return fmt.Errorf("export failed (skipping)") + } + if err := os.WriteFile(filepath.Join(harnessDir, "credentials.json"), out, 0o600); err != nil { + return err + } + + gwsConfigDir := os.Getenv("GWS_CONFIG_DIR") + if gwsConfigDir == "" { + home, _ := os.UserHomeDir() + gwsConfigDir = filepath.Join(home, ".config", "gws") + } + clientSecret := filepath.Join(gwsConfigDir, "client_secret.json") + if data, err := os.ReadFile(clientSecret); err == nil { + os.WriteFile(filepath.Join(harnessDir, "client_secret.json"), data, 0o600) + } + + fmt.Println(" GWS: exported") + return nil +} diff --git a/internal/profile/profile_test.go b/internal/profile/profile_test.go new file mode 100644 index 0000000..808f548 --- /dev/null +++ b/internal/profile/profile_test.go @@ -0,0 +1,194 @@ +package profile + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/robbycochran/harness-openshell/internal/gateway" +) + +// mockGateway implements gateway.Gateway for testing provider validation. +type mockGateway struct { + providers map[string]bool +} + +func (m *mockGateway) ProviderGet(name string) error { + if m.providers[name] { + return nil + } + return fmt.Errorf("not found") +} + +func (m *mockGateway) InferenceGet() error { return nil } +func (m *mockGateway) ProviderList() ([]string, error) { return nil, nil } +func (m *mockGateway) SandboxCreate(gateway.SandboxCreateOpts) error { return nil } +func (m *mockGateway) SandboxDelete(string) error { return nil } +func (m *mockGateway) SandboxConnect(string) error { return nil } +func (m *mockGateway) SandboxUpload(string, string, string) error { return nil } +func (m *mockGateway) SandboxExec(string, ...string) error { return nil } + +func TestParseFile_Full(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.toml") + os.WriteFile(path, []byte(` +name = "research" +image = "quay.io/test/sandbox:latest" +command = "claude --bare --model opus" +keep = false +providers = ["github", "vertex-local"] + +[env] +ANTHROPIC_BASE_URL = "https://inference.local" +JIRA_URL = "https://example.atlassian.net" +`), 0o644) + + cfg, err := ParseFile(path) + if err != nil { + t.Fatalf("ParseFile: %v", err) + } + if cfg.Name != "research" { + t.Errorf("Name = %q, want %q", cfg.Name, "research") + } + if cfg.Image != "quay.io/test/sandbox:latest" { + t.Errorf("Image = %q", cfg.Image) + } + if cfg.Command != "claude --bare --model opus" { + t.Errorf("Command = %q", cfg.Command) + } + if cfg.KeepSandbox() { + t.Error("KeepSandbox() = true, want false") + } + if len(cfg.Providers) != 2 || cfg.Providers[0] != "github" { + t.Errorf("Providers = %v", cfg.Providers) + } + if cfg.Env["JIRA_URL"] != "https://example.atlassian.net" { + t.Errorf("Env[JIRA_URL] = %q", cfg.Env["JIRA_URL"]) + } +} + +func TestParseFile_Defaults(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.toml") + os.WriteFile(path, []byte(`image = "quay.io/test:latest"`), 0o644) + + cfg, err := ParseFile(path) + if err != nil { + t.Fatalf("ParseFile: %v", err) + } + if cfg.Name != "agent" { + t.Errorf("Name = %q, want default 'agent'", cfg.Name) + } + if cfg.Command != "claude --bare" { + t.Errorf("Command = %q, want default", cfg.Command) + } + if !cfg.KeepSandbox() { + t.Error("KeepSandbox() = false, want true (default)") + } +} + +func TestParseFile_Missing(t *testing.T) { + _, err := ParseFile("/nonexistent.toml") + if err == nil { + t.Error("expected error for missing file") + } +} + +func TestParse_ByName(t *testing.T) { + dir := t.TempDir() + os.MkdirAll(filepath.Join(dir, "profiles"), 0o755) + os.WriteFile(filepath.Join(dir, "profiles", "test.toml"), []byte(` +name = "test-agent" +image = "test:latest" +`), 0o644) + + cfg, err := Parse(dir, "test") + if err != nil { + t.Fatalf("Parse: %v", err) + } + if cfg.Name != "test-agent" { + t.Errorf("Name = %q", cfg.Name) + } +} + +func TestBuildSandboxEnv(t *testing.T) { + cfg := &Config{ + Env: map[string]string{ + "ZEBRA": "z", + "APPLE": "a", + }, + } + env := cfg.BuildSandboxEnv() + lines := strings.Split(strings.TrimSpace(env), "\n") + if len(lines) != 2 { + t.Fatalf("expected 2 lines, got %d: %q", len(lines), env) + } + if lines[0] != "export APPLE=a" { + t.Errorf("first line = %q (should be sorted)", lines[0]) + } + if lines[1] != "export ZEBRA=z" { + t.Errorf("second line = %q", lines[1]) + } +} + +func TestBuildSandboxEnv_Empty(t *testing.T) { + cfg := &Config{} + if env := cfg.BuildSandboxEnv(); env != "" { + t.Errorf("expected empty, got %q", env) + } +} + +func TestStageHarnessDir(t *testing.T) { + dir := t.TempDir() + harnessDir := filepath.Join(dir, "harness") + + cfg := &Config{ + Env: map[string]string{"FOO": "bar"}, + } + if err := StageHarnessDir(cfg, harnessDir); err != nil { + t.Fatalf("StageHarnessDir: %v", err) + } + + data, err := os.ReadFile(filepath.Join(harnessDir, "sandbox.env")) + if err != nil { + t.Fatalf("reading sandbox.env: %v", err) + } + if !strings.Contains(string(data), "export FOO=bar") { + t.Errorf("sandbox.env = %q", string(data)) + } +} + +func TestValidateProviders_AllRegistered(t *testing.T) { + gw := &mockGateway{providers: map[string]bool{"github": true, "vertex-local": true}} + reg, missing := ValidateProviders([]string{"github", "vertex-local"}, gw) + if len(reg) != 2 { + t.Errorf("registered = %v, want 2", reg) + } + if len(missing) != 0 { + t.Errorf("missing = %v, want empty", missing) + } +} + +func TestValidateProviders_SomeMissing(t *testing.T) { + gw := &mockGateway{providers: map[string]bool{"github": true}} + reg, missing := ValidateProviders([]string{"github", "vertex-local", "atlassian"}, gw) + if len(reg) != 1 || reg[0] != "github" { + t.Errorf("registered = %v", reg) + } + if len(missing) != 2 { + t.Errorf("missing = %v, want 2 items", missing) + } +} + +func TestValidateProviders_NoneRegistered(t *testing.T) { + gw := &mockGateway{providers: map[string]bool{}} + reg, missing := ValidateProviders([]string{"github", "vertex-local"}, gw) + if len(reg) != 0 { + t.Errorf("registered = %v, want empty", reg) + } + if len(missing) != 2 { + t.Errorf("missing = %v, want 2 items", missing) + } +} diff --git a/internal/runner/runner.go b/internal/runner/runner.go new file mode 100644 index 0000000..8c78654 --- /dev/null +++ b/internal/runner/runner.go @@ -0,0 +1,18 @@ +package runner + +import ( + "os" + "os/exec" + "path/filepath" +) + +// RunScript executes a bash script from bin/scripts/ with stdout/stderr passthrough. +func RunScript(harnessDir, scriptName string, args ...string) error { + path := filepath.Join(harnessDir, "bin", "scripts", scriptName) + cmd := exec.Command("bash", append([]string{path}, args...)...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Dir = harnessDir + return cmd.Run() +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..30c22e1 --- /dev/null +++ b/main.go @@ -0,0 +1,66 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/robbycochran/harness-openshell/cmd" + "github.com/spf13/cobra" +) + +func main() { + harnessDir := detectHarnessDir() + + root := &cobra.Command{ + Use: "harness", + Short: "OpenShell Harness — deploy and manage AI agent sandboxes", + } + + cli := os.Getenv("OPENSHELL_CLI") + if cli == "" { + cli = "openshell" + } + + root.AddCommand( + cmd.NewNewCmd(harnessDir, cli), + cmd.NewConnectCmd(cli), + cmd.NewDeployCmd(harnessDir), + cmd.NewTeardownCmd(harnessDir), + cmd.NewPreflightCmd(harnessDir), + cmd.NewProvidersCmd(harnessDir), + cmd.NewTestCmd(harnessDir), + ) + + if err := root.Execute(); err != nil { + os.Exit(1) + } +} + +func detectHarnessDir() string { + if d := os.Getenv("HARNESS_DIR"); d != "" { + return d + } + ex, err := os.Executable() + if err == nil { + dir := filepath.Dir(ex) + for range 5 { + if _, err := os.Stat(filepath.Join(dir, "profiles", "default.toml")); err == nil { + return dir + } + dir = filepath.Dir(dir) + } + } + cwd, err := os.Getwd() + if err == nil { + dir := cwd + for range 5 { + if _, err := os.Stat(filepath.Join(dir, "profiles", "default.toml")); err == nil { + return dir + } + dir = filepath.Dir(dir) + } + } + fmt.Fprintf(os.Stderr, "WARNING: could not detect harness directory (set HARNESS_DIR)\n") + return "." +} diff --git a/sandbox/launcher/Dockerfile b/sandbox/launcher/Dockerfile index 5cfece4..a491de6 100644 --- a/sandbox/launcher/Dockerfile +++ b/sandbox/launcher/Dockerfile @@ -2,19 +2,20 @@ # OpenShell in-cluster sandbox launcher. # -# A minimal image containing the Go launcher and openshell CLI. +# A minimal image containing the Go launcher, openshell CLI, and openssh. # Runs as a Kubernetes Job triggered by `kubectl apply -f sandbox.yaml`. # Reads sandbox configuration from a ConfigMap and credentials from # mounted Secrets — no credentials transit the kubectl client. # # Build: -# # Cross-compile the Go launcher first: # GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o launcher . # docker build --platform linux/amd64 -t quay.io/rcochran/openshell:launcher . # # Push: docker push quay.io/rcochran/openshell:launcher -FROM scratch +FROM registry.access.redhat.com/ubi9/ubi-minimal:latest + +RUN microdnf install -y openssh-clients --nodocs && microdnf clean all COPY launcher /usr/local/bin/launcher COPY openshell /usr/local/bin/openshell diff --git a/sandbox/launcher/main.go b/sandbox/launcher/main.go index 4fa425e..96bb997 100644 --- a/sandbox/launcher/main.go +++ b/sandbox/launcher/main.go @@ -43,12 +43,6 @@ func parseConfig(path string) (*Config, error) { return &cfg, nil } -type gatewayMetadata struct { - GatewayEndpoint string `json:"gateway_endpoint"` - AuthMode string `json:"auth_mode"` - Name string `json:"name,omitempty"` -} - func configureGateway(endpoint, mtlsDir, cli string) error { certFile := filepath.Join(mtlsDir, "tls.crt") if _, err := os.Stat(certFile); err != nil { @@ -59,7 +53,13 @@ func configureGateway(endpoint, mtlsDir, cli string) error { } httpEndpoint := strings.Replace(endpoint, "https:", "http:", 1) - run(cli, "gateway", "add", httpEndpoint, "--name", "openshell") + + // Register via http:// (skips cert validation probe), then patch to https + mTLS. + // Let the CLI create the full metadata.json with all required fields. + cmd := exec.Command(cli, "gateway", "add", httpEndpoint, "--name", "openshell") + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stdout + cmd.Run() home := os.Getenv("HOME") gwDir := filepath.Join(home, ".config", "openshell", "gateways", "openshell") @@ -68,17 +68,18 @@ func configureGateway(endpoint, mtlsDir, cli string) error { return fmt.Errorf("creating mtls dir: %w", err) } + // Patch metadata.json — read what gateway add created, update only the + // two fields we need. Preserve all other fields the CLI wrote. metaPath := filepath.Join(gwDir, "metadata.json") - data, err := os.ReadFile(metaPath) - if err != nil { - return fmt.Errorf("reading metadata.json: %w", err) + var meta map[string]any + if data, err := os.ReadFile(metaPath); err == nil { + json.Unmarshal(data, &meta) } - var meta gatewayMetadata - if err := json.Unmarshal(data, &meta); err != nil { - return fmt.Errorf("parsing metadata.json: %w", err) + if meta == nil { + meta = make(map[string]any) } - meta.GatewayEndpoint = endpoint - meta.AuthMode = "mtls" + meta["gateway_endpoint"] = endpoint + meta["auth_mode"] = "mtls" out, err := json.Marshal(meta) if err != nil { return fmt.Errorf("marshaling metadata.json: %w", err) @@ -92,6 +93,8 @@ func configureGateway(endpoint, mtlsDir, cli string) error { return fmt.Errorf("copying %s: %w", name, err) } } + + run(cli, "gateway", "select", "openshell") fmt.Println(" ✓ mTLS gateway configured") return nil } @@ -134,7 +137,7 @@ func stageFiles(cfg *Config, gwsDir, harnessDir string) error { return fmt.Errorf("reading gws dir: %w", err) } for _, e := range entries { - if !e.IsDir() { + if !e.IsDir() && !strings.HasPrefix(e.Name(), "..") { src := filepath.Join(gwsDir, e.Name()) dst := filepath.Join(harnessDir, e.Name()) if err := copyFile(src, dst); err != nil { diff --git a/test/test-flow.sh b/test/test-flow.sh index 506ab63..290c7b8 100755 --- a/test/test-flow.sh +++ b/test/test-flow.sh @@ -1,28 +1,55 @@ #!/usr/bin/env bash # End-to-end validation for podman and OCP flows. # +# Supports both the bash (bin/harness) and Go (./harness) entry points. +# Use --go to test the Go binary. Use --full for sandbox lifecycle tests. +# # Usage: -# ./test-flow.sh podman # quick: deploy + providers + teardown -# ./test-flow.sh podman --full # full: + sandbox + verify integrations -# ./test-flow.sh ocp # quick: deploy + creds + providers + teardown -# ./test-flow.sh ocp --full # full: + sandbox + verify integrations -# ./test-flow.sh all # quick for both -# ./test-flow.sh all --full # full for both +# ./test-flow.sh podman # quick bash: deploy + providers + teardown +# ./test-flow.sh podman --full # full bash: + sandbox + verify integrations +# ./test-flow.sh podman --go # quick Go: same flow via Go binary +# ./test-flow.sh podman --full --go # full Go +# ./test-flow.sh ocp [--full] [--go] # OCP variants +# ./test-flow.sh all [--full] [--go] # both platforms set -uo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)" source "$SCRIPT_DIR/bin/scripts/lib/profile.sh" CLI="${OPENSHELL_CLI:-openshell}" -TARGET="${1:-}" +# ── Parse args ────────────────────────────────────────────────────── +TARGET="" FULL=false -[[ "${2:-}" == "--full" || "${3:-}" == "--full" ]] && FULL=true +USE_GO=false + +for arg in "$@"; do + case "$arg" in + --full) FULL=true ;; + --go) USE_GO=true ;; + -*) ;; + *) [[ -z "$TARGET" ]] && TARGET="$arg" ;; + esac +done if [[ -z "$TARGET" ]]; then - echo "Usage: $0 [--full]" + echo "Usage: $0 [--full] [--go]" exit 1 fi +# ── Entry point: bash or Go ───────────────────────────────────────── +if $USE_GO; then + HARNESS="$SCRIPT_DIR/harness" + if [[ ! -x "$HARNESS" ]]; then + echo "ERROR: Go binary not found at $HARNESS" + echo " Build it first: make cli" + exit 1 + fi + IMPL="go" +else + HARNESS="$SCRIPT_DIR/bin/harness" + IMPL="bash" +fi + # ── Helpers ────────────────────────────────────────────────────────── PASS=0 @@ -43,6 +70,20 @@ step() { fi } +step_fail() { + local label="$1"; shift + local start=$(date +%s) + if ! "$@" &>/dev/null; then + local elapsed=$(( $(date +%s) - start )) + printf " ✓ %-35s (expected failure, %ds)\n" "$label" "$elapsed" + ((PASS++)) + else + local elapsed=$(( $(date +%s) - start )) + printf " ✗ %-35s (should have failed, %ds)\n" "$label" "$elapsed" + ((FAIL++)) + fi +} + step_output() { local label="$1"; shift local start=$(date +%s) @@ -74,7 +115,6 @@ check_providers() { sandbox_verify() { local name="$1" - # Check sandbox is ready local phase phase=$("$CLI" sandbox list 2>/dev/null | strip_ansi | awk -v n="$name" '$1==n {print $NF}') if [[ "$phase" != "Ready" ]]; then @@ -85,16 +125,9 @@ sandbox_verify() { printf " ✓ %-35s\n" "sandbox ready" ((PASS++)) - # Check env vars step "sandbox: env vars" "$CLI" sandbox exec --name "$name" -- bash -c 'test -n "$ANTHROPIC_BASE_URL"' - - # Check GWS credentials step "sandbox: gws creds" "$CLI" sandbox exec --name "$name" -- test -f /sandbox/.config/openshell/credentials.json - - # Check MCP config step "sandbox: mcp config" "$CLI" sandbox exec --name "$name" -- test -f /sandbox/.mcp.json - - # Check Claude responds step_output "sandbox: claude responds" "$CLI" sandbox exec --name "$name" -- bash -c 'echo "respond with ok" | claude --bare --print 2>&1 | head -1' } @@ -103,33 +136,59 @@ summary() { local elapsed=$(( $(date +%s) - TOTAL_START )) echo "" if [[ $FAIL -eq 0 ]]; then - echo "${PASS}/${total} passed (${elapsed}s)" + echo "${PASS}/${total} passed (${elapsed}s) [${IMPL}]" else - echo "${PASS}/${total} passed, ${FAIL} failed (${elapsed}s)" + echo "${PASS}/${total} passed, ${FAIL} failed (${elapsed}s) [${IMPL}]" fi } +# ── Error scenarios (run on both paths) ────────────────────────────── + +test_errors() { + echo "=== test: error scenarios ($IMPL) ===" + + # Bad profile + step_fail "nonexistent profile" "$HARNESS" new --local --profile nonexistent --no-tty + + # No gateway (teardown first, then try new without --local) + "$HARNESS" teardown --sandboxes --providers &>/dev/null || true + step_fail "new without gateway" "$HARNESS" new --name test-nogw --no-tty + + # Teardown idempotency + step "teardown (first)" "$HARNESS" teardown + step "teardown (second)" "$HARNESS" teardown + + echo "" +} + # ── Podman flow ────────────────────────────────────────────────────── test_podman() { local mode="quick" $FULL && mode="full" - echo "=== test-flow: podman ($mode) ===" + echo "=== test-flow: podman ($mode) [$IMPL] ===" - step "teardown" "$SCRIPT_DIR/bin/scripts/teardown.sh" --sandboxes --providers - step "deploy" "$SCRIPT_DIR/bin/scripts/deploy.sh" --local - step "setup providers" "$SCRIPT_DIR/bin/scripts/providers.sh" + step "teardown" "$HARNESS" teardown --sandboxes --providers + step "deploy" "$HARNESS" deploy --local + step "setup providers" "$HARNESS" providers step "gateway reachable" "$CLI" inference get check_providers if $FULL; then local sandbox_name="test-agent" - step_output "sandbox create" "$SCRIPT_DIR/bin/scripts/new.sh" --local --name "$sandbox_name" --no-tty + step_output "sandbox create" "$HARNESS" new --local --name "$sandbox_name" --no-tty sandbox_verify "$sandbox_name" step "sandbox delete" "$CLI" sandbox delete "$sandbox_name" + + # Missing providers scenario + echo "" + echo "=== test: missing providers ($IMPL) ===" + step "teardown providers" "$HARNESS" teardown --providers + step_output "new with no providers" "$HARNESS" new --local --name test-noprov --no-tty + step "cleanup" "$HARNESS" teardown --sandboxes fi - step "teardown (clean)" "$SCRIPT_DIR/bin/scripts/teardown.sh" --sandboxes --providers + step "teardown (clean)" "$HARNESS" teardown --sandboxes --providers } # ── OCP flow ───────────────────────────────────────────────────────── @@ -137,20 +196,19 @@ test_podman() { test_ocp() { local mode="quick" $FULL && mode="full" - echo "=== test-flow: ocp ($mode) ===" + echo "=== test-flow: ocp ($mode) [$IMPL] ===" - step "teardown" "$SCRIPT_DIR/bin/scripts/teardown.sh" - step "deploy" "$SCRIPT_DIR/bin/scripts/deploy.sh" --remote + step "teardown" "$HARNESS" teardown + step "deploy" "$HARNESS" deploy --remote step "setup creds" "$SCRIPT_DIR/bin/scripts/creds.sh" - step "setup providers" "$SCRIPT_DIR/bin/scripts/providers.sh" + step "setup providers" "$HARNESS" providers step "gateway reachable" "$CLI" inference get check_providers if $FULL; then - step_output "sandbox create" "$SCRIPT_DIR/bin/scripts/new.sh" --remote + step_output "sandbox create" "$HARNESS" new --remote local sandbox_name="agent" - # Wait for ready for i in $(seq 1 30); do local phase=$("$CLI" sandbox list 2>/dev/null | strip_ansi | awk -v n="$sandbox_name" '$1==n {print $NF}') [[ "$phase" == "Ready" ]] && break @@ -160,26 +218,20 @@ test_ocp() { step "sandbox delete" "$CLI" sandbox delete "$sandbox_name" fi - step "teardown (clean)" "$SCRIPT_DIR/bin/scripts/teardown.sh" + step "teardown (clean)" "$HARNESS" teardown } # ── Main ───────────────────────────────────────────────────────────── +test_errors + case "$TARGET" in - podman) - test_podman - ;; - ocp) - test_ocp - ;; - all) - test_podman - echo "" - test_ocp - ;; + podman) test_podman ;; + ocp) test_ocp ;; + all) test_podman; echo ""; test_ocp ;; *) echo "Unknown target: $TARGET" - echo "Usage: $0 [--full]" + echo "Usage: $0 [--full] [--go]" exit 1 ;; esac