diff --git a/.gitignore b/.gitignore index 3efa494..4680f41 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ __pycache__/ # Build artifacts sandbox/launcher/openshell +sandbox/launcher/launcher infracluster/ dashboard.html .claude/ diff --git a/Makefile b/Makefile index eaa76c6..fe0b34a 100644 --- a/Makefile +++ b/Makefile @@ -13,7 +13,7 @@ PLATFORM := linux/amd64 SANDBOX_IMAGE := $(REGISTRY):sandbox LAUNCHER_IMAGE := $(REGISTRY):launcher -.PHONY: sandbox push-sandbox launcher push-launcher \ +.PHONY: sandbox push-sandbox cli-launcher launcher push-launcher \ test test-podman test-ocp clean help ## ── Images ──────────────────────────────────────────────────────────── @@ -27,9 +27,13 @@ sandbox: sandbox/Dockerfile sandbox/startup.sh \ push-sandbox: sandbox @echo "Already pushed by buildx" -## Launcher image (openshell CLI for in-cluster sandbox creation) -launcher: sandbox/launcher/Dockerfile sandbox/launcher/entrypoint.sh \ - sandbox/launcher/openshell +## Cross-compile Go launcher binary for linux/amd64 +cli-launcher: + cd sandbox/launcher && GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o launcher . + @echo "Built: sandbox/launcher/launcher" + +## Launcher image (Go binary + openshell CLI, scratch-based) +launcher: cli-launcher sandbox/launcher/Dockerfile sandbox/launcher/openshell docker build --platform $(PLATFORM) -t $(LAUNCHER_IMAGE) sandbox/launcher/ @echo "Built: $(LAUNCHER_IMAGE)" @@ -54,7 +58,7 @@ test-ocp: sandbox push-launcher ## Clean staged binaries clean: - rm -f sandbox/launcher/openshell + rm -f sandbox/launcher/openshell sandbox/launcher/launcher @echo "Cleaned staged binaries" ## Show available targets diff --git a/sandbox/launcher/Dockerfile b/sandbox/launcher/Dockerfile index 7ba0a83..5cfece4 100644 --- a/sandbox/launcher/Dockerfile +++ b/sandbox/launcher/Dockerfile @@ -2,29 +2,23 @@ # OpenShell in-cluster sandbox launcher. # -# A minimal image containing the openshell CLI and supporting tools. +# A minimal image containing the Go launcher and openshell CLI. # 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: docker build --platform linux/amd64 -t quay.io/rcochran/openshell:launcher . -# Push: docker push quay.io/rcochran/openshell:launcher - -FROM registry.access.redhat.com/ubi9/ubi-minimal:latest AS base +# 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 -# Install bash, python3, and openssh-clients (required by openshell CLI for SSH sessions) -RUN microdnf install -y bash python3 python3-pip openssh-clients --nodocs && \ - pip3 install --no-cache-dir tomli && \ - microdnf clean all +FROM scratch -# Copy the openshell CLI binary (must be pre-built for linux/amd64) -# Build with: cargo zigbuild --release --target x86_64-unknown-linux-gnu -p openshell-cli +COPY launcher /usr/local/bin/launcher COPY openshell /usr/local/bin/openshell -RUN chmod 755 /usr/local/bin/openshell - -COPY entrypoint.sh /entrypoint.sh -RUN chmod 755 /entrypoint.sh USER 1000 -ENTRYPOINT ["/entrypoint.sh"] +ENTRYPOINT ["/usr/local/bin/launcher"] diff --git a/sandbox/launcher/go.mod b/sandbox/launcher/go.mod new file mode 100644 index 0000000..4d9bc5e --- /dev/null +++ b/sandbox/launcher/go.mod @@ -0,0 +1,5 @@ +module github.com/robbycochran/harness-openshell/sandbox/launcher + +go 1.22.4 + +require github.com/BurntSushi/toml v1.6.0 // indirect diff --git a/sandbox/launcher/go.sum b/sandbox/launcher/go.sum new file mode 100644 index 0000000..f74b269 --- /dev/null +++ b/sandbox/launcher/go.sum @@ -0,0 +1,2 @@ +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= diff --git a/sandbox/launcher/main.go b/sandbox/launcher/main.go new file mode 100644 index 0000000..4fa425e --- /dev/null +++ b/sandbox/launcher/main.go @@ -0,0 +1,288 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/BurntSushi/toml" +) + +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 parseConfig(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 +} + +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 { + fmt.Println(" No mTLS certs, using insecure mode") + os.Setenv("OPENSHELL_GATEWAY_ENDPOINT", endpoint) + os.Setenv("OPENSHELL_GATEWAY_INSECURE", "true") + return nil + } + + httpEndpoint := strings.Replace(endpoint, "https:", "http:", 1) + run(cli, "gateway", "add", httpEndpoint, "--name", "openshell") + + home := os.Getenv("HOME") + gwDir := filepath.Join(home, ".config", "openshell", "gateways", "openshell") + mtlsDest := filepath.Join(gwDir, "mtls") + if err := os.MkdirAll(mtlsDest, 0o755); err != nil { + return fmt.Errorf("creating mtls dir: %w", err) + } + + metaPath := filepath.Join(gwDir, "metadata.json") + data, err := os.ReadFile(metaPath) + if err != nil { + return fmt.Errorf("reading metadata.json: %w", err) + } + var meta gatewayMetadata + if err := json.Unmarshal(data, &meta); err != nil { + return fmt.Errorf("parsing metadata.json: %w", err) + } + meta.GatewayEndpoint = endpoint + meta.AuthMode = "mtls" + out, err := json.Marshal(meta) + if err != nil { + return fmt.Errorf("marshaling metadata.json: %w", err) + } + if err := os.WriteFile(metaPath, out, 0o644); err != nil { + return fmt.Errorf("writing metadata.json: %w", err) + } + + for _, name := range []string{"ca.crt", "tls.crt", "tls.key"} { + if err := copyFile(filepath.Join(mtlsDir, name), filepath.Join(mtlsDest, name)); err != nil { + return fmt.Errorf("copying %s: %w", name, err) + } + } + fmt.Println(" ✓ mTLS gateway configured") + return nil +} + +func checkProviders(providers []string, cli string) []string { + var registered []string + for _, name := range providers { + cmd := exec.Command(cli, "provider", "get", name) + cmd.Stdout = io.Discard + cmd.Stderr = io.Discard + if cmd.Run() == nil { + registered = append(registered, name) + fmt.Printf(" Provider %s: attached\n", name) + } else { + fmt.Printf(" Provider %s: not registered (skipping)\n", name) + } + } + return registered +} + +var stageFilesFrom = "/etc/openshell/env/sandbox.env" + +func stageFiles(cfg *Config, gwsDir, harnessDir string) error { + if err := os.MkdirAll(harnessDir, 0o755); err != nil { + return err + } + + if envPath := stageFilesFrom; fileExists(envPath) { + if err := copyFile(envPath, filepath.Join(harnessDir, "sandbox.env")); err != nil { + return fmt.Errorf("copying sandbox.env: %w", err) + } + data, _ := os.ReadFile(envPath) + lines := strings.Count(string(data), "\n") + fmt.Printf(" Env: %d vars\n", lines) + } + + if fileExists(filepath.Join(gwsDir, "credentials.json")) { + entries, err := os.ReadDir(gwsDir) + if err != nil { + return fmt.Errorf("reading gws dir: %w", err) + } + for _, e := range entries { + if !e.IsDir() { + src := filepath.Join(gwsDir, e.Name()) + dst := filepath.Join(harnessDir, e.Name()) + if err := copyFile(src, dst); err != nil { + return fmt.Errorf("copying %s: %w", e.Name(), err) + } + } + } + fmt.Println(" GWS: staged") + } else { + fmt.Println(" GWS: not mounted (skipping)") + } + return nil +} + +func createSandbox(cfg *Config, providers []string, cli string) error { + fmt.Println("\n=== Creating sandbox ===") + for attempt := 1; attempt <= 5; attempt++ { + args := []string{"sandbox", "create", "--name", cfg.Name, "--no-tty"} + if cfg.Image != "" { + args = append(args, "--from", cfg.Image) + } + for _, p := range providers { + args = append(args, "--provider", p) + } + if !cfg.KeepSandbox() { + args = append(args, "--no-keep") + } + args = append(args, "--", "true") + + cmd := exec.Command(cli, args...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if cmd.Run() == nil { + return nil + } + + fmt.Printf(" Attempt %d failed (supervisor race), retrying in 10s...\n", attempt) + del := exec.Command(cli, "sandbox", "delete", cfg.Name) + del.Stdout = io.Discard + del.Stderr = io.Discard + del.Run() + + if attempt == 5 { + return fmt.Errorf("failed after 5 attempts") + } + time.Sleep(10 * time.Second) + } + return nil +} + +func uploadFiles(name, harnessDir, cli string) error { + fmt.Println(" Uploading to /sandbox/.config/openshell/...") + cmd := exec.Command(cli, "sandbox", "upload", name, harnessDir, "/sandbox/.config", "--no-git-ignore") + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +func runStartup(name, cli string) error { + fmt.Println(" Running startup...") + cmd := exec.Command(cli, "sandbox", "exec", "--name", name, "--", "bash", "/sandbox/startup.sh") + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +func run(name string, args ...string) { + cmd := exec.Command(name, args...) + cmd.Stdout = os.Stdout + cmd.Stderr = io.Discard + cmd.Run() +} + +func copyFile(src, dst string) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + out, err := os.Create(dst) + if err != nil { + return err + } + defer out.Close() + _, err = io.Copy(out, in) + return err +} + +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +func main() { + endpoint := os.Getenv("GATEWAY_ENDPOINT") + if endpoint == "" { + endpoint = "https://openshell.openshell.svc.cluster.local:8080" + } + cli := os.Getenv("OPENSHELL_CLI") + if cli == "" { + cli = "openshell" + } + configPath := "/etc/openshell/sandbox/config.toml" + + if err := configureGateway(endpoint, "/secrets/mtls", cli); err != nil { + fmt.Fprintf(os.Stderr, "ERROR: gateway config: %v\n", err) + os.Exit(1) + } + + cfg, err := parseConfig(configPath) + if err != nil { + fmt.Fprintf(os.Stderr, "ERROR: %v\n", err) + os.Exit(1) + } + + fmt.Println("=== Sandbox Launcher ===") + fmt.Printf(" Name: %s\n", cfg.Name) + fmt.Printf(" Image: %s\n", cfg.Image) + fmt.Printf(" Providers: %s\n", strings.Join(cfg.Providers, " ")) + fmt.Printf(" Command: %s\n", cfg.Command) + fmt.Printf(" Gateway: %s\n", endpoint) + fmt.Println() + + providers := checkProviders(cfg.Providers, cli) + + harnessDir := "/tmp/openshell" + if err := stageFiles(cfg, "/secrets/gws", harnessDir); err != nil { + fmt.Fprintf(os.Stderr, "ERROR: staging files: %v\n", err) + os.Exit(1) + } + + if err := createSandbox(cfg, providers, cli); err != nil { + fmt.Fprintf(os.Stderr, "ERROR: %v\n", err) + os.Exit(1) + } + + if err := uploadFiles(cfg.Name, harnessDir, cli); err != nil { + fmt.Fprintf(os.Stderr, "ERROR: upload failed: %v\n", err) + os.Exit(1) + } + + if err := runStartup(cfg.Name, cli); err != nil { + fmt.Fprintf(os.Stderr, "ERROR: startup failed: %v\n", err) + os.Exit(1) + } + + fmt.Printf("\nSandbox '%s' is ready.\n", cfg.Name) + fmt.Printf("Connect with: openshell sandbox connect %s\n", cfg.Name) + fmt.Printf("Or from inside the sandbox: %s\n", cfg.Command) + +} diff --git a/sandbox/launcher/main_test.go b/sandbox/launcher/main_test.go new file mode 100644 index 0000000..c80a027 --- /dev/null +++ b/sandbox/launcher/main_test.go @@ -0,0 +1,181 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +func TestParseConfig_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 := parseConfig(path) + if err != nil { + t.Fatalf("parseConfig: %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, want %q", cfg.Image, "quay.io/test/sandbox:latest") + } + if cfg.Command != "claude --bare --model opus" { + t.Errorf("Command = %q, want %q", cfg.Command, "claude --bare --model opus") + } + if cfg.KeepSandbox() { + t.Error("KeepSandbox() = true, want false") + } + if len(cfg.Providers) != 2 { + t.Fatalf("Providers len = %d, want 2", len(cfg.Providers)) + } + if cfg.Providers[0] != "github" || cfg.Providers[1] != "vertex-local" { + t.Errorf("Providers = %v", cfg.Providers) + } + if cfg.Env["ANTHROPIC_BASE_URL"] != "https://inference.local" { + t.Errorf("Env[ANTHROPIC_BASE_URL] = %q", cfg.Env["ANTHROPIC_BASE_URL"]) + } + if cfg.Env["JIRA_URL"] != "https://example.atlassian.net" { + t.Errorf("Env[JIRA_URL] = %q", cfg.Env["JIRA_URL"]) + } +} + +func TestParseConfig_Defaults(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.toml") + os.WriteFile(path, []byte(` +image = "quay.io/test/sandbox:latest" +`), 0o644) + + cfg, err := parseConfig(path) + if err != nil { + t.Fatalf("parseConfig: %v", err) + } + if cfg.Name != "agent" { + t.Errorf("Name = %q, want %q (default)", cfg.Name, "agent") + } + if cfg.Command != "claude --bare" { + t.Errorf("Command = %q, want %q (default)", cfg.Command, "claude --bare") + } + if !cfg.KeepSandbox() { + t.Error("KeepSandbox() = false, want true (default)") + } + if len(cfg.Providers) != 0 { + t.Errorf("Providers = %v, want empty", cfg.Providers) + } +} + +func TestParseConfig_Missing(t *testing.T) { + _, err := parseConfig("/nonexistent/config.toml") + if err == nil { + t.Error("expected error for missing file") + } +} + +func TestParseConfig_Invalid(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.toml") + os.WriteFile(path, []byte(`not valid toml {{{{`), 0o644) + + _, err := parseConfig(path) + if err == nil { + t.Error("expected error for invalid TOML") + } +} + +func TestStageFiles_EnvFromFile(t *testing.T) { + dir := t.TempDir() + gwsDir := filepath.Join(dir, "gws") + harnessDir := filepath.Join(dir, "harness") + envDir := filepath.Join(dir, "env") + + os.MkdirAll(gwsDir, 0o755) + os.MkdirAll(envDir, 0o755) + + envContent := "export FOO=bar\nexport BAZ=qux\n" + envFile := filepath.Join(envDir, "sandbox.env") + os.WriteFile(envFile, []byte(envContent), 0o644) + + origStageFiles := stageFilesFrom + stageFilesFrom = envFile + defer func() { stageFilesFrom = origStageFiles }() + + cfg := &Config{Env: map[string]string{"FOO": "bar", "BAZ": "qux"}} + if err := stageFiles(cfg, gwsDir, harnessDir); err != nil { + t.Fatalf("stageFiles: %v", err) + } + + data, err := os.ReadFile(filepath.Join(harnessDir, "sandbox.env")) + if err != nil { + t.Fatalf("reading sandbox.env: %v", err) + } + if string(data) != envContent { + t.Errorf("sandbox.env = %q, want %q", string(data), envContent) + } +} + +func TestStageFiles_GWSCreds(t *testing.T) { + dir := t.TempDir() + gwsDir := filepath.Join(dir, "gws") + harnessDir := filepath.Join(dir, "harness") + + os.MkdirAll(gwsDir, 0o755) + os.WriteFile(filepath.Join(gwsDir, "credentials.json"), []byte(`{"token":"abc"}`), 0o644) + os.WriteFile(filepath.Join(gwsDir, "client_secret.json"), []byte(`{"secret":"xyz"}`), 0o644) + + origStageFiles := stageFilesFrom + stageFilesFrom = "/nonexistent/sandbox.env" + defer func() { stageFilesFrom = origStageFiles }() + + cfg := &Config{} + if err := stageFiles(cfg, gwsDir, harnessDir); err != nil { + t.Fatalf("stageFiles: %v", err) + } + + data, err := os.ReadFile(filepath.Join(harnessDir, "credentials.json")) + if err != nil { + t.Fatalf("reading credentials.json: %v", err) + } + if string(data) != `{"token":"abc"}` { + t.Errorf("credentials.json = %q", string(data)) + } + + data, err = os.ReadFile(filepath.Join(harnessDir, "client_secret.json")) + if err != nil { + t.Fatalf("reading client_secret.json: %v", err) + } + if string(data) != `{"secret":"xyz"}` { + t.Errorf("client_secret.json = %q", string(data)) + } +} + +func TestStageFiles_NoGWS(t *testing.T) { + dir := t.TempDir() + gwsDir := filepath.Join(dir, "gws") + harnessDir := filepath.Join(dir, "harness") + os.MkdirAll(gwsDir, 0o755) + + origStageFiles := stageFilesFrom + stageFilesFrom = "/nonexistent/sandbox.env" + defer func() { stageFilesFrom = origStageFiles }() + + cfg := &Config{} + if err := stageFiles(cfg, gwsDir, harnessDir); err != nil { + t.Fatalf("stageFiles: %v", err) + } + + if _, err := os.Stat(filepath.Join(harnessDir, "credentials.json")); err == nil { + t.Error("credentials.json should not exist when GWS not mounted") + } +}