diff --git a/.github/workflows/images.yml b/.github/workflows/images.yml new file mode 100644 index 0000000..ea7a601 --- /dev/null +++ b/.github/workflows/images.yml @@ -0,0 +1,94 @@ +name: Images + +on: + push: + branches: [main] + paths: + - 'sandbox/**' + - 'profiles/**' + - 'providers.toml' + tags: ["v*"] + pull_request: + paths: + - 'sandbox/**' + - 'profiles/**' + - 'providers.toml' + +permissions: + contents: read + packages: write + +env: + REGISTRY: ghcr.io + IMAGE_BASE: ghcr.io/${{ github.repository }} + +jobs: + sandbox: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: docker/setup-qemu-action@v3 + - uses: docker/setup-buildx-action@v3 + - uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - uses: docker/metadata-action@v5 + id: meta + with: + images: ${{ env.IMAGE_BASE }} + tags: | + type=raw,value=sandbox,enable={{is_default_branch}} + type=semver,pattern=sandbox-v{{version}} + type=ref,event=pr,prefix=sandbox-pr- + - uses: docker/build-push-action@v6 + with: + context: sandbox + platforms: linux/amd64,linux/arm64 + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: | + type=registry,ref=${{ env.IMAGE_BASE }}:sandbox-cache + type=registry,ref=${{ env.IMAGE_BASE }}:sandbox + cache-to: type=registry,ref=${{ env.IMAGE_BASE }}:sandbox-cache,mode=max + + launcher: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - name: Build launcher binary + run: cd sandbox/launcher && GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o launcher . + - name: Install openshell CLI + run: | + curl -LsSf https://raw.githubusercontent.com/NVIDIA/OpenShell/main/install.sh | sh + cp "$(which openshell)" sandbox/launcher/openshell + - uses: docker/setup-buildx-action@v3 + - uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - uses: docker/metadata-action@v5 + id: meta + with: + images: ${{ env.IMAGE_BASE }} + tags: | + type=raw,value=launcher,enable={{is_default_branch}} + type=semver,pattern=launcher-v{{version}} + type=ref,event=pr,prefix=launcher-pr- + - uses: docker/build-push-action@v6 + with: + context: sandbox/launcher + platforms: linux/amd64 + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: | + type=registry,ref=${{ env.IMAGE_BASE }}:launcher-cache + type=registry,ref=${{ env.IMAGE_BASE }}:launcher + cache-to: type=registry,ref=${{ env.IMAGE_BASE }}:launcher-cache,mode=max diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 77d21db..1a56a66 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,7 +17,6 @@ jobs: - uses: actions/setup-go@v5 with: go-version-file: go.mod - # TODO: add `make embed-sync` step when embed package is implemented - uses: goreleaser/goreleaser-action@v6 with: args: release --clean diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..527177f --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,14 @@ +version: 2 + +builds: + - env: + - CGO_ENABLED=0 + goos: + - linux + - darwin + goarch: + - amd64 + - arm64 + +archives: + - format: tar.gz diff --git a/Makefile b/Makefile index 4dcecb2..7c5aa83 100644 --- a/Makefile +++ b/Makefile @@ -7,7 +7,7 @@ ## make test-podman # build + test podman only ## make test-ocp # build + test OCP only -REGISTRY ?= quay.io/rcochran/openshell +REGISTRY ?= ghcr.io/robbycochran/harness-openshell PLATFORM := linux/amd64 SANDBOX_IMAGE := $(REGISTRY):sandbox diff --git a/cmd/deploy.go b/cmd/deploy.go index 4b4de6e..3868910 100644 --- a/cmd/deploy.go +++ b/cmd/deploy.go @@ -114,7 +114,7 @@ func deployRemote(harnessDir string, gw gateway.Gateway, kc, clusterRunner k8s.R // Step 4: Helm install status.Step(4, "Deploying gateway via Helm") - sandboxImage := envOr("SANDBOX_IMAGE", "quay.io/rcochran/openshell:sandbox") + sandboxImage := envOr("SANDBOX_IMAGE", "ghcr.io/robbycochran/harness-openshell:sandbox") appsDomain, err := clusterRunner.GetJSONPath(ctx, "ingresses.config.openshift.io/cluster", "{.spec.domain}") if err != nil || appsDomain == "" { diff --git a/cmd/helpers_test.go b/cmd/helpers_test.go index 6b377c9..8fa0e60 100644 --- a/cmd/helpers_test.go +++ b/cmd/helpers_test.go @@ -73,7 +73,7 @@ func setupTestProfile(t *testing.T) string { os.MkdirAll(filepath.Join(dir, "profiles"), 0o755) os.WriteFile(filepath.Join(dir, "profiles", "default.toml"), []byte(` name = "test-agent" -image = "quay.io/test:latest" +from = "quay.io/test:latest" command = "claude --bare" providers = ["github", "vertex-local", "atlassian"] diff --git a/cmd/new.go b/cmd/new.go index 8914942..9cfec17 100644 --- a/cmd/new.go +++ b/cmd/new.go @@ -111,6 +111,19 @@ func newRemote(harnessDir string, gw gateway.Gateway, profileName, sandboxName s cfg.Name = sandboxName } + // Remote can't build from a local Dockerfile — override with registry image + if cfg.From != "" { + fromPath := cfg.From + if !filepath.IsAbs(fromPath) { + fromPath = filepath.Join(harnessDir, fromPath) + } + if info, err := os.Stat(fromPath); err == nil && info.IsDir() { + sandboxImage := envOr("SANDBOX_IMAGE", "ghcr.io/robbycochran/harness-openshell:sandbox") + status.Infof("Remote: using image %s (profile 'from' is a local path)", sandboxImage) + cfg.From = sandboxImage + } + } + profilePath := filepath.Join(harnessDir, "profiles", profileName+".toml") // 1. ConfigMap from profile @@ -161,7 +174,7 @@ func newRemote(harnessDir string, gw gateway.Gateway, profileName, sandboxName s "restartPolicy": "Never", "containers": []map[string]any{{ "name": "launcher", - "image": "quay.io/rcochran/openshell:launcher", + "image": "ghcr.io/robbycochran/harness-openshell:launcher", "imagePullPolicy": "Always", "env": []map[string]any{ {"name": "GATEWAY_ENDPOINT", "value": "https://openshell.openshell.svc.cluster.local:8080"}, @@ -262,10 +275,18 @@ func newLocal(opts newLocalOpts) error { cfg.Name = opts.sandboxName } + // Resolve Dockerfile path relative to harnessDir + 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 + } + } + fmt.Println() fmt.Println("=== Sandbox ===") fmt.Printf(" Profile: %s\n", opts.profileName) - fmt.Printf(" Image: %s\n", cfg.Image) + fmt.Printf(" From: %s\n", cfg.From) // 4. Validate providers against profile status.Section("Providers") @@ -323,7 +344,7 @@ func newLocal(opts newLocalOpts) error { for attempt := 1; attempt <= 5; attempt++ { err := gw.SandboxCreate(gateway.SandboxCreateOpts{ Name: cfg.Name, - Image: cfg.Image, + From: cfg.From, Providers: registered, TTY: !opts.noTTY, Keep: cfg.KeepSandbox(), diff --git a/cmd/new_test.go b/cmd/new_test.go index 8414140..402dafb 100644 --- a/cmd/new_test.go +++ b/cmd/new_test.go @@ -159,8 +159,8 @@ func TestNewLocal_SandboxCreateOpts(t *testing.T) { 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.From != "quay.io/test:latest" { + t.Errorf("From = %q", opts.From) } if opts.TTY { t.Error("TTY = true, want false (noTTY)") diff --git a/internal/gateway/cli.go b/internal/gateway/cli.go index 8338d92..5775b96 100644 --- a/internal/gateway/cli.go +++ b/internal/gateway/cli.go @@ -186,8 +186,8 @@ func (c *CLI) SandboxCreate(opts SandboxCreateOpts) error { } else { args = append(args, "--no-tty") } - if opts.Image != "" { - args = append(args, "--from", opts.Image) + if opts.From != "" { + args = append(args, "--from", opts.From) } for _, p := range opts.Providers { args = append(args, "--provider", p) diff --git a/internal/gateway/cli_test.go b/internal/gateway/cli_test.go index 2505eb6..5e17fb1 100644 --- a/internal/gateway/cli_test.go +++ b/internal/gateway/cli_test.go @@ -136,7 +136,7 @@ echo "$@" > `+argsFile+` gw := New(bin) gw.SandboxCreate(SandboxCreateOpts{ Name: "my-agent", - Image: "quay.io/test:latest", + From: "quay.io/test:latest", Providers: []string{"github", "vertex-local"}, TTY: true, Keep: false, diff --git a/internal/gateway/gateway.go b/internal/gateway/gateway.go index 26ca80c..3be82e2 100644 --- a/internal/gateway/gateway.go +++ b/internal/gateway/gateway.go @@ -47,7 +47,7 @@ type GatewayInfo struct { type SandboxCreateOpts struct { Name string - Image string + From string Providers []string TTY bool Keep bool diff --git a/internal/profile/profile.go b/internal/profile/profile.go index 8e933a3..9b6c3c4 100644 --- a/internal/profile/profile.go +++ b/internal/profile/profile.go @@ -19,7 +19,7 @@ type ProviderChecker interface { type Config struct { Name string `toml:"name"` - Image string `toml:"image"` + From string `toml:"from"` Command string `toml:"command"` Keep *bool `toml:"keep"` Providers []string `toml:"providers"` diff --git a/internal/profile/profile_test.go b/internal/profile/profile_test.go index 9a28616..894ae3e 100644 --- a/internal/profile/profile_test.go +++ b/internal/profile/profile_test.go @@ -25,7 +25,7 @@ func TestParseFile_Full(t *testing.T) { path := filepath.Join(dir, "config.toml") os.WriteFile(path, []byte(` name = "research" -image = "quay.io/test/sandbox:latest" +from = "quay.io/test/sandbox:latest" command = "claude --bare --model opus" keep = false providers = ["github", "vertex-local"] @@ -42,8 +42,8 @@ JIRA_URL = "https://example.atlassian.net" 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.From != "quay.io/test/sandbox:latest" { + t.Errorf("From = %q", cfg.From) } if cfg.Command != "claude --bare --model opus" { t.Errorf("Command = %q", cfg.Command) diff --git a/profiles/default.toml b/profiles/default.toml index a4a7f3d..cd77d71 100644 --- a/profiles/default.toml +++ b/profiles/default.toml @@ -5,7 +5,7 @@ # harness create --profile research # uses profiles/research.toml name = "agent" -image = "quay.io/rcochran/openshell:sandbox" +from = "quay.io/rcochran/openshell:sandbox" command = "claude --bare" keep = true diff --git a/sandbox/launcher/main.go b/sandbox/launcher/main.go index a0f9479..17f0abb 100644 --- a/sandbox/launcher/main.go +++ b/sandbox/launcher/main.go @@ -15,7 +15,7 @@ import ( type Config struct { Name string `toml:"name"` - Image string `toml:"image"` + From string `toml:"from"` Command string `toml:"command"` Keep *bool `toml:"keep"` Providers []string `toml:"providers"` @@ -160,8 +160,8 @@ 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) + if cfg.From != "" { + args = append(args, "--from", cfg.From) } for _, p := range providers { args = append(args, "--provider", p) @@ -261,7 +261,7 @@ func main() { fmt.Println("=== Sandbox Launcher ===") fmt.Printf(" Name: %s\n", cfg.Name) - fmt.Printf(" Image: %s\n", cfg.Image) + fmt.Printf(" From: %s\n", cfg.From) fmt.Printf(" Providers: %s\n", strings.Join(cfg.Providers, " ")) fmt.Printf(" Command: %s\n", cfg.Command) fmt.Printf(" Gateway: %s\n", endpoint) diff --git a/sandbox/launcher/main_test.go b/sandbox/launcher/main_test.go index c80a027..b9458ab 100644 --- a/sandbox/launcher/main_test.go +++ b/sandbox/launcher/main_test.go @@ -11,7 +11,7 @@ func TestParseConfig_Full(t *testing.T) { path := filepath.Join(dir, "config.toml") os.WriteFile(path, []byte(` name = "research" -image = "quay.io/test/sandbox:latest" +from = "quay.io/test/sandbox:latest" command = "claude --bare --model opus" keep = false providers = ["github", "vertex-local"] @@ -28,8 +28,8 @@ JIRA_URL = "https://example.atlassian.net" 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.From != "quay.io/test/sandbox:latest" { + t.Errorf("From = %q, want %q", cfg.From, "quay.io/test/sandbox:latest") } if cfg.Command != "claude --bare --model opus" { t.Errorf("Command = %q, want %q", cfg.Command, "claude --bare --model opus") @@ -55,7 +55,7 @@ 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" +from = "quay.io/test/sandbox:latest" `), 0o644) cfg, err := parseConfig(path)