diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6acd464..1b09155 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,8 +16,13 @@ jobs: - uses: actions/setup-go@v5 with: go-version-file: go.mod + - name: Install bats + run: sudo apt-get install -y bats - run: go vet ./... - run: CGO_ENABLED=0 go test ./... + - name: Build binary for bats + run: CGO_ENABLED=0 go build -o harness . + - run: bats test/preflight.bats lint: runs-on: ubuntu-latest diff --git a/.github/workflows/images.yml b/.github/workflows/images.yml index df6dc87..e065449 100644 --- a/.github/workflows/images.yml +++ b/.github/workflows/images.yml @@ -33,15 +33,17 @@ jobs: tags: | type=semver,pattern=sandbox-v{{version}} type=sha,prefix=sandbox-,format=short + # Fork PRs can't write to the registry; same-repo PRs push so the + # integration (kind) job can pull the image without a local preload. - uses: docker/build-push-action@v6 with: context: sandbox platforms: linux/amd64,linux/arm64 - push: true + push: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=registry,ref=${{ env.IMAGE_BASE }}:sandbox-cache - cache-to: type=registry,ref=${{ env.IMAGE_BASE }}:sandbox-cache,mode=max + cache-to: ${{ (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && format('type=registry,ref={0}:sandbox-cache,mode=max', env.IMAGE_BASE) || '' }} runner: runs-on: ubuntu-latest @@ -73,8 +75,8 @@ jobs: with: context: build/runner platforms: linux/amd64 - push: true + push: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=registry,ref=${{ env.IMAGE_BASE }}:runner-cache - cache-to: type=registry,ref=${{ env.IMAGE_BASE }}:runner-cache,mode=max + cache-to: ${{ (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && format('type=registry,ref={0}:runner-cache,mode=max', env.IMAGE_BASE) || '' }} diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 600c5db..3b58d6b 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -17,9 +17,6 @@ jobs: with: go-version-file: go.mod - - name: Install bats - run: sudo apt-get install -y bats - - name: Install openshell run: curl -LsSf https://raw.githubusercontent.com/NVIDIA/OpenShell/main/install.sh | sh @@ -56,15 +53,22 @@ jobs: with: go-version-file: go.mod - - name: Install bats - run: sudo apt-get install -y bats - - name: Install openshell run: curl -LsSf https://raw.githubusercontent.com/NVIDIA/OpenShell/main/install.sh | sh + - name: Wait for gateway + run: | + for i in $(seq 1 30); do + openshell inference get &>/dev/null && break + sleep 1 + done + openshell gateway list + + # install_only: kind-lifecycle.sh creates its own cluster with an + # isolated kubeconfig — only the kind binary is needed here. - uses: helm/kind-action@v1 with: - cluster_name: openshell + install_only: true - name: Install helm uses: azure/setup-helm@v4 diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 069e13a..4a5ee41 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -1,5 +1,9 @@ version: 2 +# Asset names match the README install instructions: +# releases/latest/download/harness_darwin_arm64 +project_name: harness + builds: - binary: harness env: @@ -14,4 +18,5 @@ builds: - -s -w -X main.version={{.Version}} archives: - - format: tar.gz + - formats: ["binary"] + name_template: "{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}" diff --git a/AGENTS.md b/AGENTS.md index 93fd9da..1805b0b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -99,14 +99,19 @@ Runs in GitHub Actions on every PR. --ci flag = --no-providers --agent=ci ``` +CI mode also auto-activates when the `CI` env var is `true` (set by GitHub Actions). + ### Make targets +See `make help` for the full list. The test entry points: + | Target | Gateway | Mode | |--------|---------|------| -| `make validate-local` | local Podman | default (needs creds) | -| `make validate-local-ci` | local Podman | ci (no creds) | -| `make validate-kind` | kind cluster | default (needs creds) | -| `make validate-kind-ci` | kind cluster | ci (no creds) | +| `make test` | none | vet + unit tests + bats | +| `make test-local` | local Podman | default locally, ci on GHA | +| `make test-kind` | kind cluster | default locally, ci on GHA | +| `make test-remote` | OCP | default (needs KUBECONFIG + creds) | +| `make test-all` | all of the above | | Or directly: ```bash diff --git a/Makefile b/Makefile index dcdc521..5e2864c 100644 --- a/Makefile +++ b/Makefile @@ -1,15 +1,17 @@ ## OpenShell Harness — build, push, and test ## ## Tests (CI mode auto-detects from CI env var): -## make test # unit + bats + lint (~2min) -## make test-local # test + local gateway integration -## make test-kind # test + kind (self-contained cluster) -## make test-remote # test + OCP (needs KUBECONFIG) -## make test-all # all of the above +## make test # vet + unit + bats (~2min) +## make test-local # local gateway integration +## make test-kind # kind integration (self-contained cluster) +## make test-remote # OCP integration (needs KUBECONFIG) +## make test-all # unit + all integrations ## -## Images: -## make sandbox # build + push sandbox (multi-arch) -## make runner # build + push runner +## Images (dev builds, tagged from git describe): +## make dev-sandbox # build sandbox image (native arch) +## make dev-runner # build runner image +## make dev-push # build + push both (sandbox multi-arch) +## Release images are built and pushed by .github/workflows/images.yml. REGISTRY ?= ghcr.io/robbycochran/harness-openshell CONTAINER_CLI ?= podman @@ -20,7 +22,7 @@ LDFLAGS := -s -w -X main.version=$(VERSION) DEV_SANDBOX_IMAGE := $(REGISTRY):sandbox-$(VERSION) DEV_RUNNER_IMAGE := $(REGISTRY):runner-$(VERSION) -.PHONY: all cli sandbox push-sandbox cli-runner runner push-runner \ +.PHONY: all cli cli-runner \ vet lint test test-local test-kind test-remote test-all \ dev-sandbox dev-runner dev-push clean help @@ -36,30 +38,11 @@ cli: ## ── Images ──────────────────────────────────────────────────────────── -## Sandbox image (Claude Code + mcp-atlassian + gws, multi-arch) -sandbox: sandbox/Dockerfile sandbox/startup.sh \ - sandbox/policy.yaml sandbox/CLAUDE.md sandbox/settings.json - @$(CONTAINER_CLI) manifest rm $(SANDBOX_IMAGE) 2>/dev/null || true - $(CONTAINER_CLI) build --platform linux/amd64 --manifest $(SANDBOX_IMAGE) sandbox/ - $(CONTAINER_CLI) build --platform linux/arm64 --manifest $(SANDBOX_IMAGE) sandbox/ - @echo "Built: $(SANDBOX_IMAGE) (multi-arch)" - -push-sandbox: sandbox - $(CONTAINER_CLI) manifest push $(SANDBOX_IMAGE) - ## Cross-compile harness binary for the runner image cli-runner: CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags '$(LDFLAGS)' -o build/runner/harness . @echo "Built: build/runner/harness ($(VERSION))" -## Runner image (harness binary + openshell CLI) -runner: cli-runner build/runner/Dockerfile - $(CONTAINER_CLI) build --platform $(PLATFORM) -t $(RUNNER_IMAGE) build/runner/ - @echo "Built: $(RUNNER_IMAGE)" - -push-runner: runner - $(CONTAINER_CLI) push $(RUNNER_IMAGE) - ## ── Lint targets ───────────────────────────────────────────────────── ## Run go vet @@ -79,31 +62,31 @@ lint: ## CI mode auto-detects from the CI env var (set by GitHub Actions). ## Locally: full tests with credentials. On GHA: no-credential mode. -## Unit tests + bats + lint (fast, ~2min, no gateway needed) +## Vet + unit tests + bats (fast, ~2min, no gateway needed) test: vet CGO_ENABLED=0 go test ./... bats test/preflight.bats -## Local gateway integration -test-local: cli test +## Local gateway integration (unit tests run separately via 'make test') +test-local: cli ./test/test-flow.sh local ## Kind: self-contained cluster lifecycle ## Builds sandbox image locally and pre-loads into kind (no registry push needed). ## Use KEEP=1 to keep the cluster after tests (for debugging). -test-kind: cli test +test-kind: cli $(CONTAINER_CLI) build -t $(DEV_SANDBOX_IMAGE) sandbox/ @echo "" - SANDBOX_IMAGE=$(DEV_SANDBOX_IMAGE) ./test/kind-lifecycle.sh $(if $(KEEP),--keep) + SANDBOX_IMAGE=$(DEV_SANDBOX_IMAGE) CONTAINER_CLI=$(CONTAINER_CLI) ./test/kind-lifecycle.sh $(if $(KEEP),--keep) ## Remote (OCP): requires KUBECONFIG set -test-remote: cli test dev-sandbox dev-runner +test-remote: cli dev-sandbox dev-runner @test -n "$${KUBECONFIG}" || { echo "ERROR: Set KUBECONFIG for OCP (e.g. export KUBECONFIG=infracluster/kubeconfig)"; exit 1; } @echo "" SANDBOX_IMAGE=$(DEV_SANDBOX_IMAGE) RUNNER_IMAGE=$(DEV_RUNNER_IMAGE) ./test/test-flow.sh ocp -## All: local + kind + remote -test-all: test-local test-kind test-remote +## All: unit + local + kind + remote +test-all: test test-local test-kind test-remote ## ── Dev image builds ───────────────────────────────────────────────── diff --git a/README.md b/README.md index e2c9328..be8c2c4 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,7 @@ tty: false ### Prerequisites -- [OpenShell CLI](https://github.com/NVIDIA/OpenShell) (`brew install openshell && brew services start openshell` on macOS) +- [OpenShell CLI](https://github.com/NVIDIA/OpenShell) (`brew tap nvidia/openshell && brew install openshell && brew services start openshell` on macOS) - Podman/Docker - Go 1.23+ (only needed for building from source) @@ -136,6 +136,7 @@ See the [OpenShell docs](https://github.com/NVIDIA/OpenShell) for the full secur | `agents/providers/profiles/` | OpenShell provider profiles (imported to gateway on registration) | | `providers.toml` | Provider catalog: required inputs and health checks per provider | | `gateways/*/gateway.toml` | Deployment target config: `local/` (Podman), `kind/`, `ocp/` (OpenShift) | +| `openshell.toml` | Deployment-level overrides (enabled providers, inference model, chart version) | | `sandbox/Dockerfile` | Sandbox image: OpenShell base + MCP servers + CLI tools | | `build/runner/Dockerfile` | Runner image: harness binary for in-cluster sandbox creation | | `sandbox/policy.yaml` | Network egress rules applied to sandboxes | @@ -164,12 +165,26 @@ harness providers [--force] harness preflight [--strict] Validate local credentials and prerequisites. +harness status + Show gateway, provider, and sandbox status. + +harness logs [NAME] [-f] + Stream sandbox logs (-f to follow). + +harness stop [NAME] / harness start [NAME] + Stop or start a sandbox without deleting it. + harness teardown [--sandboxes] [--providers] [--k8s] Tear down resources. At least one flag required. ``` -## References +## Documentation Map -- [AGENTS.md](AGENTS.md) -- coding guidelines, project principles, workaround tracking -- [SPEC.md](SPEC.md) -- full CLI specification -- [TODO.md](TODO.md) -- roadmap and known gaps +| Document | What it is | +|----------|------------| +| [SPEC.md](SPEC.md) | **Authoritative** behavior spec for the CLI — commands, configs, payload | +| [AGENTS.md](AGENTS.md) | Contributor guide: coding principles, workaround tracking, validation modes | +| [TODO.md](TODO.md) | Roadmap and known gaps | +| [docs/archive/](docs/archive/README.md) | Historical design docs (e.g. the June 2026 design-v1 proposal) — outdated, kept for context | +| [docs/release-plan.md](docs/release-plan.md) | Release phases: CI (done), embed + `harness init`, GoReleaser | +| [docs/proto-migration.md](docs/proto-migration.md) | Deferred plan to adopt proto-generated config types | diff --git a/SPEC.md b/SPEC.md index 53ef23b..071cbe6 100644 --- a/SPEC.md +++ b/SPEC.md @@ -83,6 +83,18 @@ Register providers with the gateway. Reads `providers.toml` for the catalog, imp Validate local credentials and prerequisites against `providers.toml`. +### `harness status` + +Show gateway, provider, and sandbox status. Read-only. + +### `harness logs [NAME] [-f|--follow]` + +Stream logs for a sandbox (name resolution delegated to `openshell sandbox logs` when NAME is omitted). + +### `harness stop [NAME]` / `harness start [NAME]` + +Stop or start a sandbox without deleting it. When NAME is omitted and exactly one sandbox is running, it is used; otherwise the command errors. + ### `harness teardown [--sandboxes] [--providers] [--k8s]` Tear down resources. At least one flag required. @@ -106,7 +118,7 @@ In-cluster command for the runner Job. Reads agent config from `/etc/openshell/s ## Image Tags -All images are published to `ghcr.io/robbycochran/harness-openshell`. No floating tags (`:latest`, `:sandbox`, `:runner`) are used. +All images are published to `ghcr.io/robbycochran/harness-openshell`. CI never publishes floating tags (`:latest`, `:sandbox`, `:runner`); the bare `:sandbox` fallback below exists only for local `go build` binaries without version ldflags. | Trigger | Sandbox | Runner | |---------|---------|--------| @@ -130,8 +142,14 @@ The CLI resolves images from its embedded version (set via `-ldflags` at build t | `HARNESS_DIR` | Override harness directory detection | | `OPENSHELL_NAMESPACE` | Override K8s namespace (default: `openshell`) | | `OPENSHELL_CLI` | Override openshell binary path | +| `OPENSHELL_MODEL` | Inference model for provider registration (default: `claude-sonnet-4-6`) | +| `OPENSHELL_CHART_VERSION` | Override Helm chart version (beats `openshell.toml` and `gateway.toml`) | +| `PULL_SECRET` / `SANDBOX_PULL_SECRET` | Image pull secret names passed to the Helm install | +| `CONFIG_TOML` / `PROVIDERS_TOML` | Override paths to `openshell.toml` / `providers.toml` (preflight) | | `KUBECONFIG` | K8s cluster config for remote targets | +`GATEWAY_ENDPOINT` and `GATEWAY_NAME` are internal — set on the in-cluster runner Job, not by users. + ## Payload The harness renders agent config into a self-contained payload uploaded to `/sandbox/.config/openshell/`: diff --git a/TODO.md b/TODO.md index ee980eb..6cc9953 100644 --- a/TODO.md +++ b/TODO.md @@ -15,11 +15,29 @@ - Two sources of truth: gateway.toml hardcodes a registry, env vars override it - Consider: gateway.toml uses a `registry` field and images are relative to it -### Consolidate internal/profile into internal/agent -- ✅ Removed dead code: `Parse()`, `ParseFile()`, `BuildSandboxEnv()` -- Only `Config` struct, `ValidateProviders`, and `StageHarnessDir` remain -- TODO: Move `ValidateProviders` to agent or gateway package, inline `Config` into sandbox.go -- TODO: Remove TOML dependency (github.com/BurntSushi/toml) if no longer used +### registerProviders should filter by agent's provider list +- `registerProviders()` in `cmd/providers.go` uses the gateway config's provider + list, not the agent config's. When `gwCfg` is nil (common case), it tries to + register all providers regardless of what the agent needs. +- Why: confusing output — users see "skipped" messages for providers their + agent doesn't reference. No functional impact (missing credentials are + silently handled). +- Fix: pass the agent's provider names to `registerProviders` and use them as + a filter alongside (or instead of) the gateway config's list. +- Files: `cmd/providers.go` (registerProviders signature), `cmd/up.go` (call site) + +## Config Format + +- [ ] Remove `providers.toml`; add provider profile validation in its place +- [ ] Convert gateway configs from TOML to YAML +- [ ] Specify/document the YAML formats (agent config, provider profiles) +- [ ] Document non-secret provider env vars (what `providers[].config` captures + and why it exists alongside secret credentials) + +## CLI + +- [ ] Flows that support agent.yaml (`create`, `up`) should also support + `--provider-profile` and provider config overrides ## Agent Config @@ -31,15 +49,14 @@ ## Testing ### Current coverage -- Go unit tests across cmd/, internal/agent, internal/gateway, internal/k8s -- 29 bats preflight tests -- Integration: local + kind + OCP via `make dev-test-all` +- Go unit tests across cmd/ (including launch.go) and all internal/ packages +- 29 bats preflight tests (run in CI via `.github/workflows/ci.yml`) +- Integration: local + kind + OCP via `make test-all` ### Gaps -- [ ] Tests for cmd/launch.go (configureGateway mTLS, in-cluster flow) - [ ] Integration test for `providers --force` -- [ ] Preflight Go unit tests (internal/preflight/ has no _test.go) -- [ ] Add bats step to `.github/workflows/ci.yml` (Makefile ci runs bats, GHA doesn't) +- [ ] Unit test for the full `runLaunch` orchestration (currently only its + helpers — configureGateway, checkProviders, launchCreateSandbox — are tested) ## Release diff --git a/TODOS.md b/TODOS.md deleted file mode 100644 index 5377c87..0000000 --- a/TODOS.md +++ /dev/null @@ -1,25 +0,0 @@ -# TODOs - -## remove providers.tooml, add a todo for provider profile validation in thefture -## convert toml to yaml for gateways -## specify the yaml formats - -## for flows that supports agent.yaml (create and up..) should also support --provider-profile and config - -## document that we need a way to specify non secret env vars in providers to capture like secrets, thats what provider config captures - -## registerProviders should filter by agent's provider list - -**What:** `registerProviders()` in `cmd/providers.go` uses the gateway config's provider -list, not the agent config's. When `gwCfg` is nil (common case), it tries to register -all providers regardless of what the agent needs. - -**Why:** Confusing output -- users see "skipped" messages for providers their agent -doesn't reference. No functional impact (missing credentials are silently handled). - -**Fix:** Pass the agent's provider names to `registerProviders` and use them as a -filter alongside (or instead of) the gateway config's list. - -**Files:** `cmd/providers.go` (registerProviders signature), `cmd/up.go` (call site) - -**Depends on:** Nothing. Can be done independently. diff --git a/cmd/create.go b/cmd/create.go index bd6d306..408eac8 100644 --- a/cmd/create.go +++ b/cmd/create.go @@ -11,7 +11,6 @@ import ( "github.com/robbycochran/harness-openshell/internal/agent" "github.com/robbycochran/harness-openshell/internal/gateway" "github.com/robbycochran/harness-openshell/internal/preflight" - "github.com/robbycochran/harness-openshell/internal/profile" "github.com/robbycochran/harness-openshell/internal/status" "github.com/spf13/cobra" ) @@ -62,7 +61,7 @@ func NewCreateCmd(harnessDir, cli string) *cobra.Command { // 3. Validate providers are registered status.Header("Providers") providerNames := agentCfg.ProviderNames() - registered, missing := profile.ValidateProviders(providerNames, gw) + registered, missing := gateway.ValidateProviders(providerNames, gw) for _, n := range registered { status.OKf("%s: attached", n) } @@ -128,15 +127,11 @@ func NewCreateCmd(harnessDir, cli string) *cobra.Command { return fmt.Errorf("rendering payload: %w", err) } - cfg := &profile.Config{ - Name: name, - From: sandboxImage, - } - return createSandbox(sandboxOpts{ harnessDir: harnessDir, gw: gw, - cfg: cfg, + name: name, + image: sandboxImage, providers: registered, noTTY: true, retrySleep: 5 * time.Second, diff --git a/cmd/deploy.go b/cmd/deploy.go index d4b4bb7..19c080e 100644 --- a/cmd/deploy.go +++ b/cmd/deploy.go @@ -74,6 +74,10 @@ func resolveGatewayName(args []string, local, remote bool) (string, error) { return "", fmt.Errorf("specify a gateway: harness deploy ") } +// lookPath is exec.LookPath, overridable in tests to avoid a host +// dependency on podman. +var lookPath = exec.LookPath + func deployLocal(gw gateway.Gateway) error { cliPath := gw.CLIPath() if cliPath == "" { @@ -81,7 +85,7 @@ func deployLocal(gw gateway.Gateway) error { } status.Header("Deploy") - if _, err := exec.LookPath("podman"); err != nil { + if _, err := lookPath("podman"); err != nil { status.Fail("Podman not found") return fmt.Errorf("podman is required") } @@ -111,7 +115,18 @@ func deployLocal(gw gateway.Gateway) error { return fmt.Errorf("selecting gateway %s: %w", localGW, err) } - if gw.InferenceGet() == nil { + // Retry InferenceGet a few times: the openshell daemon can briefly reload + // its config after a gateway add/select and take a few seconds to respond. + var inferErr error + for i := range 5 { + if inferErr = gw.InferenceGet(); inferErr == nil { + break + } + if i < 4 { + time.Sleep(3 * time.Second) + } + } + if inferErr == nil { status.OKf("%s (active, reachable)", localGW) } else { status.Failf("%s (not responding)", localGW) diff --git a/cmd/launch.go b/cmd/launch.go index 1624c4b..f781f1e 100644 --- a/cmd/launch.go +++ b/cmd/launch.go @@ -187,7 +187,7 @@ func launchCreateSandbox(cfg *agent.AgentConfig, providers []string, payloadDir, } time.Sleep(10 * time.Second) } - return nil + return nil // unreachable but required by compiler } diff --git a/cmd/launch_test.go b/cmd/launch_test.go new file mode 100644 index 0000000..707fc0c --- /dev/null +++ b/cmd/launch_test.go @@ -0,0 +1,139 @@ +package cmd + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/robbycochran/harness-openshell/internal/agent" +) + +// writeStubCLI writes an executable shell script that records its arguments +// to argsFile (one invocation per line) and exits 0. +func writeStubCLI(t *testing.T, argsFile string) string { + t.Helper() + dir := t.TempDir() + bin := filepath.Join(dir, "openshell") + script := "#!/bin/bash\necho \"$@\" >> " + argsFile + "\nexit 0\n" + if err := os.WriteFile(bin, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + return bin +} + +func TestConfigureGateway_MissingCertsFallsBackInsecure(t *testing.T) { + t.Setenv("OPENSHELL_GATEWAY_ENDPOINT", "") + t.Setenv("OPENSHELL_GATEWAY_INSECURE", "") + mtlsDir := t.TempDir() // no certs inside + + if err := configureGateway("https://gw.example:8080", mtlsDir, "openshell"); err != nil { + t.Fatalf("configureGateway: %v", err) + } + if got := os.Getenv("OPENSHELL_GATEWAY_ENDPOINT"); got != "https://gw.example:8080" { + t.Errorf("OPENSHELL_GATEWAY_ENDPOINT = %q", got) + } + if got := os.Getenv("OPENSHELL_GATEWAY_INSECURE"); got != "true" { + t.Errorf("OPENSHELL_GATEWAY_INSECURE = %q, want true", got) + } +} + +func TestConfigureGateway_MTLS(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + mtlsDir := t.TempDir() + for _, name := range []string{"ca.crt", "tls.crt", "tls.key"} { + if err := os.WriteFile(filepath.Join(mtlsDir, name), []byte("cert-"+name), 0o600); err != nil { + t.Fatal(err) + } + } + + argsFile := filepath.Join(t.TempDir(), "args.log") + cli := writeStubCLI(t, argsFile) + + if err := configureGateway("https://gw.example:8080", mtlsDir, cli); err != nil { + t.Fatalf("configureGateway: %v", err) + } + + // gateway add must use the http endpoint (mTLS is configured via metadata). + argsData, err := os.ReadFile(argsFile) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(argsData), "gateway add http://gw.example:8080") { + t.Errorf("cli args = %q, want 'gateway add http://...'", argsData) + } + + gwDir := filepath.Join(home, ".config", "openshell", "gateways", "openshell") + metaData, err := os.ReadFile(filepath.Join(gwDir, "metadata.json")) + if err != nil { + t.Fatalf("metadata.json not written: %v", err) + } + var meta map[string]any + if err := json.Unmarshal(metaData, &meta); err != nil { + t.Fatalf("metadata.json invalid: %v", err) + } + if meta["gateway_endpoint"] != "https://gw.example:8080" { + t.Errorf("gateway_endpoint = %v", meta["gateway_endpoint"]) + } + if meta["auth_mode"] != "mtls" { + t.Errorf("auth_mode = %v, want mtls", meta["auth_mode"]) + } + + for _, name := range []string{"ca.crt", "tls.crt", "tls.key"} { + data, err := os.ReadFile(filepath.Join(gwDir, "mtls", name)) + if err != nil { + t.Fatalf("cert %s not copied: %v", name, err) + } + if string(data) != "cert-"+name { + t.Errorf("cert %s content = %q", name, data) + } + } +} + +func TestCheckProviders(t *testing.T) { + dir := t.TempDir() + bin := filepath.Join(dir, "openshell") + // `provider get NAME` succeeds only for github. + script := "#!/bin/bash\n[ \"$3\" = \"github\" ] && exit 0\nexit 1\n" + if err := os.WriteFile(bin, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + + registered := checkProviders([]string{"github", "atlassian"}, bin) + if len(registered) != 1 || registered[0] != "github" { + t.Errorf("registered = %v, want [github]", registered) + } +} + +func TestLaunchCreateSandbox_PassesAgentConfig(t *testing.T) { + argsFile := filepath.Join(t.TempDir(), "args.log") + cli := writeStubCLI(t, argsFile) + + cfg := &agent.AgentConfig{ + Name: "test-agent", + Image: "ghcr.io/test:sandbox", + } + if err := launchCreateSandbox(cfg, []string{"github"}, "/tmp/payload", cli); err != nil { + t.Fatalf("launchCreateSandbox: %v", err) + } + + argsData, err := os.ReadFile(argsFile) + if err != nil { + t.Fatal(err) + } + args := string(argsData) + for _, want := range []string{ + "sandbox create", + "--name test-agent", + "--from ghcr.io/test:sandbox", + "--provider github", + "--upload /tmp/payload:/sandbox/.config", + } { + if !strings.Contains(args, want) { + t.Errorf("cli args missing %q in %q", want, args) + } + } +} diff --git a/cmd/sandbox.go b/cmd/sandbox.go index ec5a30a..de68b03 100644 --- a/cmd/sandbox.go +++ b/cmd/sandbox.go @@ -7,7 +7,6 @@ import ( "time" "github.com/robbycochran/harness-openshell/internal/gateway" - "github.com/robbycochran/harness-openshell/internal/profile" ) // sandboxOpts holds the parameters that vary between callers of @@ -16,30 +15,27 @@ import ( type sandboxOpts struct { harnessDir string gw gateway.Gateway - cfg *profile.Config + name string // sandbox name + image string // sandbox image ref or relative Dockerfile dir providers []string // registered providers to attach noTTY bool // true → TTY=false for the sandbox retrySleep time.Duration // pause between retry attempts sandboxCmd []string // command to run inside the sandbox - payloadDir string // pre-rendered payload dir; skips StageHarnessDir when set + payloadDir string // pre-rendered payload dir to upload onSuccess func(name string) // called after successful creation (optional) } -// createSandbox resolves the image path, stages the harness directory, +// createSandbox resolves the image path, stages the payload directory, // creates the sandbox with up to 5 retries, and cleans up on failure. -// Both upLocal and createDirect delegate to this function after -// preparing their caller-specific sandboxOpts. +// Both upLocal and create delegate to this function after preparing +// their caller-specific sandboxOpts. func createSandbox(opts sandboxOpts) error { - cfg := opts.cfg - - // Resolve image path: SANDBOX_IMAGE env overrides profile; relative - // Dockerfile dirs are resolved against harnessDir. - if envImage := os.Getenv("SANDBOX_IMAGE"); envImage != "" { - cfg.From = envImage - } else if cfg.From != "" && !filepath.IsAbs(cfg.From) { - candidate := filepath.Join(opts.harnessDir, cfg.From) + // Relative Dockerfile dirs are resolved against harnessDir. + image := opts.image + if image != "" && !filepath.IsAbs(image) { + candidate := filepath.Join(opts.harnessDir, image) if info, err := os.Stat(candidate); err == nil && info.IsDir() { - cfg.From = candidate + image = candidate } } @@ -53,37 +49,31 @@ func createSandbox(opts sandboxOpts) error { defer os.RemoveAll(tmpParent) uploadDir := filepath.Join(tmpParent, "openshell") - if opts.payloadDir != "" { - if err := os.Rename(opts.payloadDir, uploadDir); err != nil { - return fmt.Errorf("staging payload: %w", err) - } - } else { - if err := profile.StageHarnessDir(uploadDir); err != nil { - return fmt.Errorf("staging files: %w", err) - } + if err := os.Rename(opts.payloadDir, uploadDir); err != nil { + return fmt.Errorf("staging payload: %w", err) } // Create sandbox with retry loop (up to 5 attempts). for attempt := 1; attempt <= 5; attempt++ { err := opts.gw.SandboxCreate(gateway.SandboxCreateOpts{ - Name: cfg.Name, - From: cfg.From, + Name: opts.name, + From: image, Providers: opts.providers, TTY: !opts.noTTY, - Keep: cfg.KeepSandbox(), + Keep: true, UploadSrc: uploadDir, UploadDst: "/sandbox/.config", Command: opts.sandboxCmd, }) if err == nil { if opts.onSuccess != nil { - opts.onSuccess(cfg.Name) + opts.onSuccess(opts.name) } return nil } fmt.Printf(" Attempt %d failed: %v, retrying in 5s...\n", attempt, err) - opts.gw.SandboxDelete(cfg.Name) // best-effort cleanup + opts.gw.SandboxDelete(opts.name) // best-effort cleanup if attempt == 5 { return fmt.Errorf("sandbox create failed after 5 attempts: %w", err) diff --git a/cmd/sandbox_image.go b/cmd/sandbox_image.go index bb11085..90a315e 100644 --- a/cmd/sandbox_image.go +++ b/cmd/sandbox_image.go @@ -3,7 +3,7 @@ package cmd import "os" // resolveSandboxImage returns the final sandbox image path following -// the precedence: SANDBOX_IMAGE env var > agentImage > defaultSandboxImage(). +// the precedence: SANDBOX_IMAGE env var > agentImage > version-derived default. func resolveSandboxImage(agentImage string) string { if envImage := os.Getenv("SANDBOX_IMAGE"); envImage != "" { return envImage @@ -11,5 +11,5 @@ func resolveSandboxImage(agentImage string) string { if agentImage != "" { return agentImage } - return defaultSandboxImage() + return versionedImage("sandbox") } diff --git a/cmd/up.go b/cmd/up.go index a98913d..8315559 100644 --- a/cmd/up.go +++ b/cmd/up.go @@ -12,7 +12,6 @@ import ( "github.com/robbycochran/harness-openshell/internal/agent" "github.com/robbycochran/harness-openshell/internal/gateway" "github.com/robbycochran/harness-openshell/internal/k8s" - "github.com/robbycochran/harness-openshell/internal/profile" "github.com/robbycochran/harness-openshell/internal/status" "github.com/spf13/cobra" ) @@ -155,7 +154,7 @@ func upRemote(harnessDir string, gwCfg *gateway.GatewayConfig, gw gateway.Gatewa // 2. Ensure providers needed by the agent providerNames := agentCfg.ProviderNames() if len(providerNames) > 0 { - _, missing := profile.ValidateProviders(providerNames, gw) + _, missing := gateway.ValidateProviders(providerNames, gw) if len(missing) > 0 { if err := registerProviders(harnessDir, gw, false, gwCfg, false); err != nil { return fmt.Errorf("provider registration failed: %w", err) @@ -163,7 +162,7 @@ func upRemote(harnessDir string, gwCfg *gateway.GatewayConfig, gw gateway.Gatewa } } - // 4. ConfigMap from agent.yaml + // 3. ConfigMap from agent.yaml out, err := kc.RunKubectl(ctx, "create", "configmap", "sandbox-"+name, "--from-file=agent.yaml="+agentPath, "--dry-run=client", "-o", "yaml") @@ -177,12 +176,12 @@ func upRemote(harnessDir string, gwCfg *gateway.GatewayConfig, gw gateway.Gatewa return fmt.Errorf("applying config configmap: %w", err) } - // 5. Clean up old job + // 4. Clean up old job jobName := "sandbox-" + name kc.RunKubectl(ctx, "delete", "job", jobName, "--grace-period=30") kc.RunKubectl(ctx, "delete", "pod", "-l", "job-name="+jobName, "--grace-period=30") - // 6. Apply runner Job (gwCfg provides defaults + RUNNER_IMAGE env override) + // 5. Apply runner Job (gwCfg provides defaults + RUNNER_IMAGE env override) runnerImage := defaultRunnerImage() runnerSA := "openshell-launcher" gatewayEndpoint := "https://openshell.openshell.svc.cluster.local:8080" @@ -229,20 +228,20 @@ func upRemote(harnessDir string, gwCfg *gateway.GatewayConfig, gw gateway.Gatewa return fmt.Errorf("applying runner job: %w", err) } - // 7. Wait for runner pod + // 6. Wait for runner pod status.Header("Sandbox") status.Info("Waiting for runner...") kc.RunKubectl(ctx, "wait", "--for=condition=ready", "pod", "-l", "job-name="+jobName, "--timeout=120s") - // 8. Tail logs in background + // 7. Tail logs in background logCmd := exec.CommandContext(ctx, "kubectl", "-n", namespace, "logs", "-f", "-l", "job-name="+jobName) logCmd.Stdout = os.Stdout logCmd.Stderr = os.Stderr logCmd.Start() - // 9. Poll job status (10 min timeout) + // 8. Poll job status (15 min timeout) var jobStatus string deadline := time.Now().Add(15 * time.Minute) for time.Now().Before(deadline) { @@ -316,12 +315,12 @@ func upLocal(opts upLocalOpts) error { var registered []string if len(providerNames) > 0 { var missing []string - registered, missing = profile.ValidateProviders(providerNames, gw) + registered, missing = gateway.ValidateProviders(providerNames, gw) if len(missing) > 0 { if err := registerProviders(opts.harnessDir, gw, false, opts.gwCfg, false); err != nil { status.Warn(fmt.Sprintf("provider registration: %v", err)) } - registered, missing = profile.ValidateProviders(providerNames, gw) + registered, missing = gateway.ValidateProviders(providerNames, gw) } status.Header("Providers") for _, name := range registered { @@ -343,11 +342,6 @@ func upLocal(opts upLocalOpts) error { return fmt.Errorf("rendering payload: %w", err) } - cfg := &profile.Config{ - Name: sandboxName, - From: sandboxImage, - } - // 5. Create sandbox status.Header("Sandbox") envInit := ". /sandbox/.config/openshell/sandbox.env 2>/dev/null && " + @@ -362,7 +356,8 @@ func upLocal(opts upLocalOpts) error { return createSandbox(sandboxOpts{ harnessDir: opts.harnessDir, gw: gw, - cfg: cfg, + name: sandboxName, + image: sandboxImage, providers: registered, noTTY: noTTY, retrySleep: opts.retrySleep, @@ -376,13 +371,6 @@ var Version = "dev" // DefaultAgentConfig holds the embedded default agent YAML, set from main.go. var DefaultAgentConfig []byte -func defaultSandboxImage() string { - if v := os.Getenv("SANDBOX_IMAGE"); v != "" { - return v - } - return versionedImage("sandbox") -} - func defaultRunnerImage() string { if v := os.Getenv("RUNNER_IMAGE"); v != "" { return v diff --git a/cmd/up_test.go b/cmd/up_test.go index 0302446..4b3f308 100644 --- a/cmd/up_test.go +++ b/cmd/up_test.go @@ -3,6 +3,7 @@ package cmd import ( "fmt" "os" + "os/exec" "path/filepath" "strings" "testing" @@ -215,6 +216,9 @@ func TestUpLocal_SandboxCreateOpts(t *testing.T) { } func TestUpLocal_EnsureLocal_DeploysGateway(t *testing.T) { + lookPath = func(string) (string, error) { return "/usr/bin/podman", nil } + t.Cleanup(func() { lookPath = exec.LookPath }) + dir := setupTestAgent(t) gw := &mockGW{ providerList: []string{"github"}, diff --git a/dev-harness.sh b/dev-harness.sh index 6aec3b8..0da8187 100755 --- a/dev-harness.sh +++ b/dev-harness.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -euo pipefail -# Build CLI + dev images, then run harness with all args. +# Build CLI + dev images via make, then run harness with all args. # Pushes images only when --remote is passed (OCP pulls from registry). # Image layers are cached by podman/docker so rebuilds are fast when # only code changes. First run pulls base layers (~2min); subsequent @@ -19,58 +19,13 @@ set -euo pipefail REPO_ROOT="$(cd "$(dirname "$0")" && pwd)" VERSION=$(git -C "$REPO_ROOT" describe --tags --always 2>/dev/null || echo dev) REGISTRY=${REGISTRY:-ghcr.io/robbycochran/harness-openshell} -CONTAINER_CLI=${CONTAINER_CLI:-podman} -DEV_SANDBOX_IMAGE="${REGISTRY}:sandbox-${VERSION}" -DEV_RUNNER_IMAGE="${REGISTRY}:runner-${VERSION}" -# 1. Build CLI -if ! CGO_ENABLED=0 go build -ldflags "-s -w -X main.version=${VERSION}" \ - -o "$REPO_ROOT/harness" "$REPO_ROOT" 2>/dev/null; then - echo "ERROR: CLI build failed" >&2 - CGO_ENABLED=0 go build -ldflags "-s -w -X main.version=${VERSION}" \ - -o "$REPO_ROOT/harness" "$REPO_ROOT" - exit 1 +if [[ " $* " == *" --remote "* ]]; then + make -C "$REPO_ROOT" cli dev-push +else + make -C "$REPO_ROOT" cli dev-sandbox dev-runner fi -# 2. Build dev sandbox image (native arch, layer-cached) -if ! $CONTAINER_CLI build -t "$DEV_SANDBOX_IMAGE" "$REPO_ROOT/sandbox/" >/dev/null 2>&1; then - echo "ERROR: sandbox image build failed:" >&2 - $CONTAINER_CLI build -t "$DEV_SANDBOX_IMAGE" "$REPO_ROOT/sandbox/" - exit 1 -fi - -# 3. Cross-compile CLI for runner image + build runner -if ! CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "-s -w -X main.version=${VERSION}" \ - -o "$REPO_ROOT/build/runner/harness" "$REPO_ROOT" 2>/dev/null; then - echo "ERROR: runner CLI build failed" >&2 - exit 1 -fi -if ! $CONTAINER_CLI build --platform linux/amd64 -t "$DEV_RUNNER_IMAGE" \ - "$REPO_ROOT/build/runner/" >/dev/null 2>&1; then - echo "ERROR: runner image build failed:" >&2 - $CONTAINER_CLI build --platform linux/amd64 -t "$DEV_RUNNER_IMAGE" "$REPO_ROOT/build/runner/" - exit 1 -fi - -# 4. Push images (only when --remote is passed -- OCP pulls from registry) -NEEDS_PUSH=0 -for arg in "$@"; do - [ "$arg" = "--remote" ] && NEEDS_PUSH=1 && break -done -if [ "$NEEDS_PUSH" = "1" ]; then - $CONTAINER_CLI push "$DEV_SANDBOX_IMAGE" >/dev/null 2>&1 || { - echo "ERROR: sandbox image push failed" >&2 - $CONTAINER_CLI push "$DEV_SANDBOX_IMAGE" - exit 1 - } - $CONTAINER_CLI push "$DEV_RUNNER_IMAGE" >/dev/null 2>&1 || { - echo "ERROR: runner image push failed" >&2 - $CONTAINER_CLI push "$DEV_RUNNER_IMAGE" - exit 1 - } -fi - -# 5. Run harness with dev images -export SANDBOX_IMAGE="$DEV_SANDBOX_IMAGE" -export RUNNER_IMAGE="$DEV_RUNNER_IMAGE" +export SANDBOX_IMAGE="${REGISTRY}:sandbox-${VERSION}" +export RUNNER_IMAGE="${REGISTRY}:runner-${VERSION}" exec "$REPO_ROOT/harness" "$@" diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 5f7d73b..ad98572 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -167,14 +167,17 @@ func RenderPayload(cfg *AgentConfig, baseDir, destDir string) error { } for _, inc := range cfg.Include { + // Absolute includes are allowed as-is (the user authors the config); + // relative includes must stay within the base directory. src := inc if !filepath.IsAbs(src) { src = filepath.Join(baseDir, src) + rel, err := filepath.Rel(filepath.Clean(baseDir), filepath.Clean(src)) + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return fmt.Errorf("include path %q escapes base directory", inc) + } } clean := filepath.Clean(src) - if !strings.HasPrefix(clean, filepath.Clean(baseDir)) && !filepath.IsAbs(inc) { - return fmt.Errorf("include path %q escapes base directory", inc) - } data, err := os.ReadFile(clean) if err != nil { return fmt.Errorf("reading include %s: %w", inc, err) diff --git a/internal/gateway/cli.go b/internal/gateway/cli.go index a58b175..4dcadfa 100644 --- a/internal/gateway/cli.go +++ b/internal/gateway/cli.go @@ -159,21 +159,13 @@ func (c *CLI) GatewayList() ([]GatewayInfo, error) { if i == 0 || strings.TrimSpace(line) == "" { continue } - cleaned := ansiRE.ReplaceAllString(line, "") + cleaned := strings.TrimSpace(ansiRE.ReplaceAllString(line, "")) active := strings.HasPrefix(cleaned, "*") - fields := strings.Fields(cleaned) + fields := strings.Fields(strings.TrimPrefix(cleaned, "*")) if len(fields) >= 2 { - name := fields[0] - if name == "*" && len(fields) >= 3 { - name = fields[1] - } - endpoint := fields[1] - if active && len(fields) >= 3 { - endpoint = fields[2] - } gateways = append(gateways, GatewayInfo{ - Name: name, - Endpoint: endpoint, + Name: fields[0], + Endpoint: fields[1], Active: active, }) } diff --git a/internal/gateway/gateway.go b/internal/gateway/gateway.go index d02a2cc..babfbce 100644 --- a/internal/gateway/gateway.go +++ b/internal/gateway/gateway.go @@ -39,6 +39,24 @@ type Gateway interface { SettingsSet(key, value string) error } +// ProviderChecker is the subset of Gateway needed to check provider registration. +type ProviderChecker interface { + ProviderGet(name string) error +} + +// ValidateProviders checks which providers are registered on the gateway. +// Returns the list of registered providers and a list of missing ones. +func ValidateProviders(providers []string, gw ProviderChecker) (registered, missing []string) { + for _, name := range providers { + if gw.ProviderGet(name) == nil { + registered = append(registered, name) + } else { + missing = append(missing, name) + } + } + return +} + type ProviderCreateOpts struct { Credentials []string Configs []string diff --git a/internal/gateway/validate_test.go b/internal/gateway/validate_test.go new file mode 100644 index 0000000..3089630 --- /dev/null +++ b/internal/gateway/validate_test.go @@ -0,0 +1,40 @@ +package gateway + +import ( + "fmt" + "testing" +) + +type stubProviderChecker struct { + providers map[string]bool +} + +func (s *stubProviderChecker) ProviderGet(name string) error { + if s.providers[name] { + return nil + } + return fmt.Errorf("not found") +} + +func TestValidateProviders(t *testing.T) { + gw := &stubProviderChecker{providers: map[string]bool{ + "github": true, + "vertex-local": true, + }} + + registered, missing := ValidateProviders([]string{"github", "vertex-local", "atlassian"}, gw) + if len(registered) != 2 || registered[0] != "github" || registered[1] != "vertex-local" { + t.Errorf("registered = %v, want [github vertex-local]", registered) + } + if len(missing) != 1 || missing[0] != "atlassian" { + t.Errorf("missing = %v, want [atlassian]", missing) + } +} + +func TestValidateProviders_Empty(t *testing.T) { + gw := &stubProviderChecker{} + registered, missing := ValidateProviders(nil, gw) + if registered != nil || missing != nil { + t.Errorf("registered = %v, missing = %v, want nil/nil", registered, missing) + } +} diff --git a/internal/profile/profile.go b/internal/profile/profile.go deleted file mode 100644 index 34655d9..0000000 --- a/internal/profile/profile.go +++ /dev/null @@ -1,46 +0,0 @@ -package profile - -import ( - "os" -) - -// ProviderChecker checks if a provider is registered. -type ProviderChecker interface { - ProviderGet(name string) error -} - -type Config struct { - Name string `toml:"name"` - From string `toml:"from"` - Command string `toml:"command"` - Startup string `toml:"startup"` - 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 -} - - -// 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 ProviderChecker) (registered, missing []string) { - for _, name := range providers { - if gw.ProviderGet(name) == nil { - registered = append(registered, name) - } else { - missing = append(missing, name) - } - } - return -} - -// StageHarnessDir creates the harness directory. -func StageHarnessDir(harnessDir string) error { - return os.MkdirAll(harnessDir, 0o755) -} diff --git a/internal/profile/profile_test.go b/internal/profile/profile_test.go deleted file mode 100644 index 6f2810c..0000000 --- a/internal/profile/profile_test.go +++ /dev/null @@ -1,85 +0,0 @@ -package profile - -import ( - "fmt" - "os" - "path/filepath" - "testing" -) - -// mockProviderChecker implements profile.ProviderChecker for testing. -type mockProviderChecker struct { - providers map[string]bool -} - -func (m *mockProviderChecker) ProviderGet(name string) error { - if m.providers[name] { - return nil - } - return fmt.Errorf("not found") -} - -func TestKeepSandbox_Default(t *testing.T) { - cfg := &Config{} - if !cfg.KeepSandbox() { - t.Error("KeepSandbox() = false, want true (default)") - } -} - -func TestKeepSandbox_Explicit(t *testing.T) { - keepFalse := false - cfg := &Config{Keep: &keepFalse} - if cfg.KeepSandbox() { - t.Error("KeepSandbox() = true, want false") - } -} - -func TestStageHarnessDir(t *testing.T) { - dir := t.TempDir() - harnessDir := filepath.Join(dir, "harness") - - if err := StageHarnessDir(harnessDir); err != nil { - t.Fatalf("StageHarnessDir: %v", err) - } - - stat, err := os.Stat(harnessDir) - if err != nil { - t.Fatalf("stat harness dir: %v", err) - } - if !stat.IsDir() { - t.Error("expected directory to be created") - } -} - -func TestValidateProviders_AllRegistered(t *testing.T) { - gw := &mockProviderChecker{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 := &mockProviderChecker{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 := &mockProviderChecker{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/providers.toml b/providers.toml index 88429fa..9001b94 100644 --- a/providers.toml +++ b/providers.toml @@ -1,5 +1,9 @@ # Provider definitions for the OpenShell harness. -# See PROVIDERS-SPEC.md for format documentation. +# +# Each [[providers]] entry declares preflight inputs validated on the host +# before registration: kind = "env" (env var), "file" (path exists), or +# "check" (command exits 0). secret = true marks values that must never be +# echoed. See SPEC.md "Config Files" for where this fits. [[providers]] name = "github" diff --git a/test/kind-lifecycle.sh b/test/kind-lifecycle.sh index 3449b03..da96995 100755 --- a/test/kind-lifecycle.sh +++ b/test/kind-lifecycle.sh @@ -70,32 +70,29 @@ echo "" kubectl create namespace openshell --dry-run=client -o yaml | kubectl apply -f - 2>/dev/null || true -# Pre-load dev sandbox image into kind (avoids pull secrets and ImagePullBackOff). -# SANDBOX_IMAGE is set by the Makefile for dev builds; CI uses the public community image. -if [[ -n "${SANDBOX_IMAGE:-}" ]]; then - echo " Pre-loading image: $SANDBOX_IMAGE" - if docker image inspect "$SANDBOX_IMAGE" &>/dev/null; then - kind load docker-image "$SANDBOX_IMAGE" --name "$CLUSTER_NAME" 2>/dev/null || true - else - echo " Image not in local docker — kind will pull at sandbox creation time" - # Fall back to pull secret for private registries - if [[ -f "$HOME/.docker/config.json" ]]; then - QUAY_PASS=$(python3 -c " -import json, base64, pathlib, sys -try: - d = json.loads(pathlib.Path('$HOME/.docker/config.json').read_text()) - print(base64.b64decode(d['auths']['quay.io']['auth']).decode().split(':',1)[1]) -except Exception: - sys.exit(1) -" 2>/dev/null) || true - if [[ -n "${QUAY_PASS:-}" ]]; then - kubectl create secret docker-registry quay-pull \ - --docker-server=quay.io --docker-username=rcochran \ - --docker-password="$QUAY_PASS" \ - -n openshell --dry-run=client -o yaml | kubectl apply -f - 2>/dev/null || true - fi +# Pre-load dev sandbox image into kind. +# In CI (CI=true), images.yml has already pushed the image to the registry so +# kind can pull it directly — skip the 60-90s local preload that disrupts the +# local openshell service. Locally, preload from the container daemon so +# tests work without a registry push. +CONTAINER_CLI=${CONTAINER_CLI:-podman} +if [[ -n "${SANDBOX_IMAGE:-}" ]] && [[ -z "${CI:-}" ]]; then + if "$CONTAINER_CLI" image inspect "$SANDBOX_IMAGE" &>/dev/null; then + echo " Pre-loading image: $SANDBOX_IMAGE" + if [[ "$CONTAINER_CLI" == "docker" ]]; then + kind load docker-image "$SANDBOX_IMAGE" --name "$CLUSTER_NAME" + else + # kind only loads from the docker daemon directly; podman goes via archive + IMAGE_ARCHIVE=$(mktemp /tmp/kind-sandbox-image-XXXXXX.tar) + "$CONTAINER_CLI" save "$SANDBOX_IMAGE" -o "$IMAGE_ARCHIVE" + kind load image-archive "$IMAGE_ARCHIVE" --name "$CLUSTER_NAME" + rm -f "$IMAGE_ARCHIVE" fi + else + echo " Image not found locally — kind will pull from registry at sandbox create time" fi +elif [[ -n "${SANDBOX_IMAGE:-}" ]]; then + echo " CI mode — kind will pull image from registry at sandbox create time" fi # ── Run tests ─────────────────────────────────────────────────────── diff --git a/test/test-flow.sh b/test/test-flow.sh index ffbbb3e..ae2e877 100755 --- a/test/test-flow.sh +++ b/test/test-flow.sh @@ -40,7 +40,7 @@ for arg in "$@"; do --reuse-gateway) REUSE_GATEWAY=true ;; --no-providers) NO_PROVIDERS=true ;; --agent=*) PROFILE="${arg#--agent=}" ;; - -*) ;; + -*) echo "Unknown flag: $arg"; exit 1 ;; *) [[ -z "$TARGET" ]] && TARGET="$arg" ;; esac done @@ -144,12 +144,6 @@ sandbox_verify() { ((FAIL++)) return fi - phase="Ready" - if [[ "$phase" != "Ready" ]]; then - printf " ✗ %-35s\n" "sandbox ready" - ((FAIL++)) - return - fi printf " ✓ %-35s\n" "sandbox ready" ((PASS++))