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 @@ -15,6 +15,7 @@ __pycache__/

# Build artifacts
sandbox/launcher/openshell
sandbox/launcher/launcher
infracluster/
dashboard.html
.claude/
14 changes: 9 additions & 5 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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 ────────────────────────────────────────────────────────────
Expand All @@ -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)"

Expand All @@ -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
Expand Down
26 changes: 10 additions & 16 deletions sandbox/launcher/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
5 changes: 5 additions & 0 deletions sandbox/launcher/go.mod
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions sandbox/launcher/go.sum
Original file line number Diff line number Diff line change
@@ -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=
288 changes: 288 additions & 0 deletions sandbox/launcher/main.go
Original file line number Diff line number Diff line change
@@ -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)

}
Loading