diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 3b58d6b..8eb3ef2 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -30,20 +30,22 @@ jobs: - name: Run local integration run: make test-local + env: + TEST_LOG_FILE: /tmp/test-logs/test-flow-local.log - - name: Export gateway logs + - name: Export logs if: always() run: | - mkdir -p /tmp/gateway-logs - journalctl --user -u openshell-gateway --no-pager > /tmp/gateway-logs/gateway.log 2>/dev/null || true - openshell status > /tmp/gateway-logs/status.txt 2>&1 || true - openshell gateway list > /tmp/gateway-logs/gateways.txt 2>&1 || true + mkdir -p /tmp/test-logs + journalctl --user -u openshell-gateway --no-pager > /tmp/test-logs/gateway.log 2>/dev/null || true + openshell status > /tmp/test-logs/status.txt 2>&1 || true + openshell gateway list > /tmp/test-logs/gateways.txt 2>&1 || true - uses: actions/upload-artifact@v4 if: always() with: - name: gateway-logs-local - path: /tmp/gateway-logs/ + name: test-logs-local + path: /tmp/test-logs/ kind: runs-on: ubuntu-latest @@ -75,17 +77,19 @@ jobs: - name: Run kind integration run: make test-kind + env: + TEST_LOG_FILE: /tmp/test-logs/test-flow-kind.log - name: Export logs if: always() run: | - mkdir -p /tmp/kind-logs - kubectl -n openshell logs statefulset/openshell > /tmp/kind-logs/gateway.log 2>/dev/null || true - kubectl -n openshell get all > /tmp/kind-logs/resources.txt 2>&1 || true - openshell gateway list > /tmp/kind-logs/gateways.txt 2>&1 || true + mkdir -p /tmp/test-logs + kubectl -n openshell logs statefulset/openshell > /tmp/test-logs/gateway.log 2>/dev/null || true + kubectl -n openshell get all > /tmp/test-logs/resources.txt 2>&1 || true + openshell gateway list > /tmp/test-logs/gateways.txt 2>&1 || true - uses: actions/upload-artifact@v4 if: always() with: - name: gateway-logs-kind - path: /tmp/kind-logs/ + name: test-logs-kind + path: /tmp/test-logs/ diff --git a/README.md b/README.md index d92ef68..2750a1f 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,7 @@ and replaces this sequence of `openshell` commands (captured from `harness up -- openshell settings set --global --key providers_v2_enabled --value true --yes # 2. Import custom provider profiles (atlassian, gws) -openshell provider profile import --from agents/providers/profiles/ +openshell provider profile import --from profiles/providers/ # 3. Register GitHub (discovers GITHUB_TOKEN from environment) openshell provider create --name github --type github --from-existing @@ -144,11 +144,11 @@ env: ``` ```bash -harness up -f agents/ocp.yaml # deploys to OCP (reads gateways/ocp/gateway.yaml) +harness up -f agents/ocp.yaml # deploys to OCP (reads profiles/gateways/ocp.yaml) harness up # defaults to local Podman ``` -`--local` and `--remote` flags override the `gateway:` field. +`--gateway local` and `--gateway ocp` flags override the `gateway:` field. For OCP, the harness first deploys the gateway via Helm, then runs the same provider registration and sandbox creation. The full sequence: @@ -160,7 +160,7 @@ kubectl apply -f https://github.com/kubernetes-sigs/agent-sandbox/releases/.../m oc adm policy add-scc-to-user privileged -z openshell -n openshell oc adm policy add-scc-to-user anyuid -z openshell -n openshell helm upgrade --install openshell oci://ghcr.io/nvidia/openshell/helm-chart \ - --version 0.0.59 -n openshell -f gateways/ocp/helm/values.yaml + --version 0.0.59 -n openshell -f kubectl rollout status statefulset/openshell -n openshell --timeout=300s # 2. Register gateway with CLI (mTLS certs extracted from K8s secrets) @@ -170,7 +170,7 @@ openshell gateway select openshell-remote-ocp # 3-9. Same as local: settings, profiles, providers, inference, sandbox create openshell settings set --global --key providers_v2_enabled --value true -openshell provider profile import --from agents/providers/profiles/ +openshell provider profile import --from profiles/providers/ openshell provider create --name github --type github --from-existing openshell provider create --name vertex-local --type google-vertex-ai \ --from-gcloud-adc --config VERTEX_AI_PROJECT_ID=... --config VERTEX_AI_REGION=global @@ -231,7 +231,7 @@ Each provider requires credentials on the host. The harness validates these inli | `atlassian` | `JIRA_API_TOKEN` + `JIRA_URL` + `JIRA_USERNAME` env vars | | `gws` | `gws auth login` (OAuth via [gws CLI](https://github.com/googleworkspace/cli)) | -Provider profiles are defined in `agents/providers/profiles/` and validated inline during registration. +Provider profiles are defined in `profiles/providers/` and validated inline during registration. ### Build from Source @@ -240,14 +240,14 @@ make cli ./harness up ``` -For remote OpenShift: `./harness up --remote` (requires `kubectl`, `helm`, cluster access). +For remote OpenShift: `./harness up --gateway ocp` (requires `kubectl`, `helm`, cluster access). ## How It Works The harness orchestrates three OpenShell components via the `openshell` CLI: - **Gateway** -- OpenShell's credential proxy and L7 network policy engine. Runs as a Podman container (local) or Kubernetes StatefulSet (remote). Manages provider credentials, inference routing, and sandbox lifecycle. -- **Providers** -- Credential registrations on the gateway. Provider profiles are declared in `agents/providers/profiles/`. The harness validates credentials inline during registration -- providers with missing credentials are skipped. +- **Providers** -- Credential registrations on the gateway. Provider profiles are declared in `profiles/providers/`. The harness validates credentials inline during registration -- providers with missing credentials are skipped. - **Sandbox** -- Container running the agent entrypoint (Claude Code or OpenCode), configured by `agents/*.yaml`. The gateway injects credentials at the network boundary -- the sandbox process sees proxy-managed placeholder tokens. Network egress is deny-by-default at L7. ``` @@ -268,8 +268,8 @@ See the [OpenShell docs](https://github.com/NVIDIA/OpenShell) for the full secur | File | Purpose | |------|---------| | `agents/*.yaml` | Agent config: image, entrypoint, providers, env, optional task file | -| `agents/providers/profiles/` | OpenShell provider profiles (imported to gateway on registration) | -| `gateways/*/gateway.yaml` | Deployment target config: `local/` (Podman), `kind/`, `ocp/` (OpenShift) | +| `profiles/providers/` | OpenShell provider profiles (imported to gateway on registration) | +| `profiles/gateways/*.yaml` | Gateway profiles: `local.yaml`, `kind.yaml`, `ocp.yaml` | | `sandbox/Dockerfile` | Sandbox image: OpenShell base + MCP servers + CLI tools | | `sandbox/policy.yaml` | Network egress rules applied to sandboxes | | `sandbox/opencode.json` | MCP server config for OpenCode agent | @@ -277,34 +277,35 @@ See the [OpenShell docs](https://github.com/NVIDIA/OpenShell) for the full secur ## Commands ``` -harness up [--remote] [--agent NAME] [-f FILE] [--name SANDBOX] [--provider-refresh] +harness up [--gateway NAME] [--gateway-profile FILE] [--agent NAME] [--agent-profile|-f FILE] [--name SANDBOX] [--no-tty] [--provider-refresh] Deploy gateway + register providers + create sandbox. - Defaults to local gateway (use --remote for OCP). + Defaults to local gateway (use --gateway ocp for OCP). + --gateway selects a gateway profile by name (local, kind, ocp). + --gateway-profile loads a gateway profile from a file path. --agent defaults to "default" (embedded or agents/default.yaml). - -f renders any agent YAML file directly. + --agent-profile (-f) renders any agent YAML file directly. + --no-tty disables TTY allocation. --provider-refresh deletes and recreates all providers. -harness create [--agent NAME] [-f FILE] [--name SANDBOX] +harness create [--agent NAME] [--agent-profile|-f FILE] [--name SANDBOX] Create a sandbox without deploying the gateway. Assumes gateway is running. Auto-registers missing providers. -harness connect [NAME] - Reconnect to a running sandbox. - harness deploy [local|ocp|kind] Deploy or verify the gateway for a target. harness status Show 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. + +For sandbox connect/logs, use openshell directly: + openshell sandbox connect [NAME] + openshell sandbox logs [NAME] [--follow] ``` ## Documentation Map @@ -316,4 +317,3 @@ harness teardown [--sandboxes] [--providers] [--k8s] | [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 08d59db..8267b3c 100644 --- a/SPEC.md +++ b/SPEC.md @@ -47,18 +47,19 @@ Fields: - `env` -- additional environment variables injected via `--env` on sandbox create (empty values read from host env) - `include` -- extra files to include in the payload - `policy` -- path to a network policy YAML +- `gateway` -- target gateway name (overrides active gateway) -Provider profiles live in `agents/providers/profiles/`. These are imported to the gateway during provider registration. +Provider profiles live in `profiles/providers/`. These are imported to the gateway during provider registration. ## CLI -### `harness up [--local|--remote] [--agent NAME] [-f FILE] [--name SANDBOX] [--no-tty] [--provider-refresh]` +### `harness up [--gateway NAME] [--gateway-profile FILE] [--agent NAME] [--agent-profile|-f FILE] [--name SANDBOX] [--no-tty] [--provider-refresh]` Full flow: deploy gateway, register providers, render agent config, create sandbox. 1. **Check version** -- warn if openshell CLI is below v0.0.59. -2. **Ensure gateway** -- deploy if needed (local: Podman, remote: Helm to K8s/OCP). -3. **Parse agent config** -- read `agents/.yaml` (default: `default`). `-f` overrides with a direct file path. +2. **Ensure gateway** -- deploy if needed (local: Podman, remote: Helm to K8s/OCP). `--gateway` selects a profile by name; `--gateway-profile` loads from a file path. +3. **Parse agent config** -- read `agents/.yaml` (default: `default`). `--agent-profile` (`-f`) overrides with a direct file path. 4. **Ensure providers** -- auto-register missing providers. Three registration flows: - **Standard** (`--from-existing`): GitHub, Atlassian -- OpenShell discovers credentials from local env. - **ADC** (`--from-gcloud-adc`): Vertex AI -- reads ADC file, configures inference routing. @@ -68,26 +69,18 @@ Full flow: deploy gateway, register providers, render agent config, create sandb `--provider-refresh` deletes and recreates all providers (replaces the old `harness providers --force`). -### `harness create [--agent NAME] [-f FILE] [--name SANDBOX]` +### `harness create [--agent NAME] [--agent-profile|-f FILE] [--name SANDBOX]` Create a sandbox without deploying the gateway. Assumes gateway is running. Auto-registers missing providers. -### `harness connect [NAME]` - -Reconnect to a running sandbox via `openshell sandbox connect`. - ### `harness deploy [local|ocp|kind]` -Deploy or verify the gateway for a target. Reads `gateways//gateway.yaml`. +Deploy or verify the gateway for a target. Reads `profiles/gateways/.yaml`. ### `harness status` Show sandbox status. Read-only. -### `harness logs [NAME] [-f|--follow]` - -Stream logs for a sandbox. - ### `harness stop [NAME]` / `harness start [NAME]` Stop or start a sandbox without deleting it. @@ -101,8 +94,8 @@ Tear down resources. At least one flag required. | File | Purpose | |------|---------| | `agents/*.yaml` | Agent config: image, entrypoint, providers, env, task | -| `agents/providers/profiles/` | OpenShell provider profile YAMLs | -| `gateways/*/gateway.yaml` | Deployment target config with Helm, images, RBAC | +| `profiles/providers/` | OpenShell provider profile YAMLs | +| `profiles/gateways/*.yaml` | Gateway profiles: deployment target config with inline Helm values | | `sandbox/Dockerfile` | Sandbox image: OpenShell base + MCP servers + CLI tools | | `sandbox/policy.yaml` | Network egress rules applied to sandboxes | | `sandbox/opencode.json` | MCP server config for OpenCode agent | diff --git a/TODO.md b/TODO.md index f70516a..28dc104 100644 --- a/TODO.md +++ b/TODO.md @@ -11,7 +11,7 @@ ### Image registry as gateway config vs env override - `SANDBOX_IMAGE` env var overrides the version-based image resolution (for dev/CI) -- Consider: gateway.toml uses a `registry` field and images are relative to it +- Consider: gateway.yaml uses a `registry` field and images are relative to it ### registerProviders should filter by agent's provider list - `registerProviders()` in `cmd/providers.go` uses the gateway config's provider @@ -26,10 +26,8 @@ ## 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 +- [ ] Document non-secret provider env vars (what `providers[].env` captures and why it exists alongside secret credentials) ## CLI @@ -47,12 +45,11 @@ ## Testing ### Current coverage -- Go unit tests across cmd/ and all internal/ packages -- 29 bats preflight tests (run in CI via `.github/workflows/ci.yml`) +- Go unit tests across cmd/ and all internal/ packages (run in CI via `.github/workflows/ci.yml`) - Integration: local + kind + OCP via `make test-all` ### Gaps -- [ ] Integration test for `providers --force` +- [ ] Integration test for `harness up --provider-refresh` ## Release @@ -62,7 +59,7 @@ ## Deferred (post-0.1) -- [ ] Gateway-level LLM proxy/logging (gateway.toml `[proxy]` section) +- [ ] Gateway-level LLM proxy/logging (gateway.yaml proxy section) - [ ] Multi-agent workflow support (fleet.yaml / workflow.yaml) - [ ] `harness policy suggest` (DenialEvent stream -> policy proposals) - [ ] Fleet management (multi-gateway kubectl-context style) diff --git a/cmd/connect.go b/cmd/connect.go deleted file mode 100644 index facd52c..0000000 --- a/cmd/connect.go +++ /dev/null @@ -1,22 +0,0 @@ -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.New(cli) - name := "" - if len(args) > 0 { - name = args[0] - } - return gw.SandboxConnect(name) - }, - } -} diff --git a/cmd/create.go b/cmd/create.go index a2159b0..ea94bd9 100644 --- a/cmd/create.go +++ b/cmd/create.go @@ -14,20 +14,20 @@ import ( func NewCreateCmd(harnessDir, cli string) *cobra.Command { var ( agentName string - agentFile string + agentProfile string sandboxName string ) cmd := &cobra.Command{ Use: "create [flags]", Short: "Create a sandbox without attaching", - Long: "Validate gateway readiness, run preflight checks, and deploy a sandbox. Does not attach interactively — use 'harness connect' afterward.", + Long: "Validate gateway readiness, run preflight checks, and deploy a sandbox. Does not attach interactively — use 'openshell sandbox connect' afterward.", RunE: func(cmd *cobra.Command, args []string) error { if len(args) > 0 && sandboxName == "" { sandboxName = args[0] } - agentCfg, err := resolveAgentConfig(harnessDir, agentName, agentFile) + agentCfg, err := resolveAgentConfig(harnessDir, agentName, agentProfile) if err != nil { return err } @@ -54,24 +54,10 @@ func NewCreateCmd(harnessDir, cli string) *cobra.Command { status.Infof("Image: %s", sandboxImage) // 3. Ensure providers are registered - status.Header("Providers") - providerNames := agentCfg.ProviderNames() - registered, missing := gateway.ValidateProviders(providerNames, gw) - if len(missing) > 0 { - if err := registerProviders(harnessDir, gw, false, agentCfg.Providers); err != nil { - status.Warn(fmt.Sprintf("provider registration: %v", err)) - } - registered, missing = gateway.ValidateProviders(providerNames, gw) - } - for _, n := range registered { - status.OKf("%s: attached", n) - } - for _, n := range missing { - status.Failf("%s: not registered", n) - } + registered := ensureProviders(harnessDir, gw, agentCfg, false) // 4. Deploy the sandbox - status.Header("Creating sandbox") + status.Header("Sandbox") payloadDir, err := os.MkdirTemp("", "harness-payload-") if err != nil { return fmt.Errorf("creating payload dir: %w", err) @@ -95,14 +81,14 @@ func NewCreateCmd(harnessDir, cli string) *cobra.Command { env: agentCfg.BuildEnvMap(), onSuccess: func(n string) { fmt.Println() - status.OKf("Sandbox created: %s — connect with: harness connect %s", n, n) + status.OKf("Sandbox created: %s — connect with: openshell sandbox connect %s", n, n) }, }) }, } cmd.Flags().StringVar(&agentName, "agent", "default", "Agent config name (from agents/)") - cmd.Flags().StringVarP(&agentFile, "file", "f", "", "Path to agent YAML file (overrides --agent)") + cmd.Flags().StringVarP(&agentProfile, "agent-profile", "f", "", "Path to agent YAML file (overrides --agent)") cmd.Flags().StringVar(&sandboxName, "name", "", "Sandbox name (overrides agent config)") return cmd diff --git a/cmd/deploy.go b/cmd/deploy.go index 9ffda50..7f03b9a 100644 --- a/cmd/deploy.go +++ b/cmd/deploy.go @@ -25,7 +25,7 @@ func NewDeployCmd(harnessDir, cli string) *cobra.Command { cmd := &cobra.Command{ Use: "deploy [gateway]", Short: "Deploy or verify the gateway", - Long: "Deploy a gateway by name (e.g., local, ocp, kind). Reads configuration from gateways//gateway.yaml.", + Long: "Deploy a gateway by name (e.g., local, ocp, kind). Reads configuration from profiles/gateways/.yaml.", Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { gatewayName, err := resolveGatewayName(args, local, remote) @@ -33,8 +33,7 @@ func NewDeployCmd(harnessDir, cli string) *cobra.Command { return err } - gwDir := filepath.Join(harnessDir, "gateways", gatewayName) - gwCfg, err := gateway.LoadConfig(gwDir) + gwCfg, err := resolveGatewayConfig(harnessDir, gatewayName) if err != nil { return fmt.Errorf("loading gateway config %q: %w", gatewayName, err) } @@ -146,6 +145,12 @@ func deployFromConfig(harnessDir string, gwCfg *gateway.GatewayConfig, gw gatewa ctx := context.Background() namespace := k8s.DefaultNamespace() + tmpDir, err := os.MkdirTemp("", "harness-deploy-") + if err != nil { + return fmt.Errorf("creating temp dir: %w", err) + } + defer os.RemoveAll(tmpDir) + chartVersion := os.Getenv("OPENSHELL_CHART_VERSION") if chartVersion == "" { chartVersion = gwCfg.Chart.Version @@ -187,11 +192,16 @@ func deployFromConfig(harnessDir string, gwCfg *gateway.GatewayConfig, gw gatewa } // Addon manifests (RBAC, etc.) - for _, manifestPath := range gwCfg.ManifestPaths() { + for _, manifestPath := range gwCfg.ManifestFilePaths() { if _, err := kc.RunKubectl(ctx, "apply", "-f", manifestPath); err != nil { return fmt.Errorf("applying %s: %w", filepath.Base(manifestPath), err) } } + for _, manifest := range gwCfg.ManifestInline() { + if err := kc.ApplyYAML(ctx, manifest); err != nil { + return fmt.Errorf("applying inline manifest: %w", err) + } + } // Step 4: Helm install status.Step(4, "Helm install") @@ -211,7 +221,9 @@ func deployFromConfig(harnessDir string, gwCfg *gateway.GatewayConfig, gw gatewa "upgrade", "--install", "openshell", gwCfg.Chart.OCI, "--version", chartVersion, } - if valuesPath := gwCfg.HelmValuesPath(); valuesPath != "" { + if valuesPath, err := gwCfg.HelmValuesFile(tmpDir); err != nil { + return fmt.Errorf("helm values: %w", err) + } else if valuesPath != "" { helmArgs = append(helmArgs, "--values", valuesPath) } if sandboxImage := os.Getenv("SANDBOX_IMAGE"); sandboxImage != "" { diff --git a/cmd/helpers_test.go b/cmd/helpers_test.go index eaa8540..7d8b6d3 100644 --- a/cmd/helpers_test.go +++ b/cmd/helpers_test.go @@ -44,7 +44,6 @@ func (m *mockGW) SandboxDelete(name string) error { } func (m *mockGW) CLIVersion() string { return "openshell v0.0.59" } func (m *mockGW) CLIPath() string { return "/usr/bin/openshell" } -func (m *mockGW) InferenceModel() string { return "" } func (m *mockGW) InferenceSet(string, string) error { return nil } func (m *mockGW) InferenceRemove() error { return nil } func (m *mockGW) ActiveGateway() string { return "" } @@ -55,8 +54,6 @@ func (m *mockGW) ProviderProfileDelete(string) error func (m *mockGW) SettingsSet(string, string) error { return nil } func (m *mockGW) SandboxList() ([]string, error) { return nil, nil } func (m *mockGW) SandboxStatus() ([]gateway.SandboxInfo, error) { return nil, nil } -func (m *mockGW) SandboxConnect(string) error { return nil } -func (m *mockGW) SandboxLogs(string, bool) error { return nil } func (m *mockGW) SandboxStop(string) error { return nil } func (m *mockGW) SandboxStart(string) error { return nil } func (m *mockGW) GatewayAdd(string, string, bool, bool) error { return nil } diff --git a/cmd/logs.go b/cmd/logs.go deleted file mode 100644 index cc8254e..0000000 --- a/cmd/logs.go +++ /dev/null @@ -1,27 +0,0 @@ -package cmd - -import ( - "github.com/robbycochran/harness-openshell/internal/gateway" - "github.com/spf13/cobra" -) - -func NewLogsCmd(harnessDir, cli string) *cobra.Command { - var follow bool - - cmd := &cobra.Command{ - Use: "logs [SANDBOX_NAME]", - Short: "Stream sandbox logs", - Args: cobra.MaximumNArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - gw := gateway.New(cli) - name := "" - if len(args) > 0 { - name = args[0] - } - return gw.SandboxLogs(name, follow) - }, - } - - cmd.Flags().BoolVarP(&follow, "follow", "f", false, "Follow log output") - return cmd -} diff --git a/cmd/providers.go b/cmd/providers.go index 4c6eefa..559a958 100644 --- a/cmd/providers.go +++ b/cmd/providers.go @@ -46,8 +46,10 @@ func registerProviders(harnessDir string, gw gateway.Gateway, force bool, provid return fmt.Errorf("enabling providers v2: %w", err) } - profilesDir := filepath.Join(harnessDir, "agents", "providers", "profiles") - gw.ProviderProfileImport(profilesDir) + profilesDir := filepath.Join(harnessDir, "profiles", "providers") + if err := gw.ProviderProfileImport(profilesDir); err != nil { + status.Warnf("provider profile import: %v", err) + } if _, ok := wanted["github"]; ok { if err := registerStandard("github", "github", gw, nil); err != nil { @@ -83,6 +85,28 @@ func registerProviders(harnessDir string, gw gateway.Gateway, force bool, provid return nil } +func ensureProviders(harnessDir string, gw gateway.Gateway, agentCfg *agent.AgentConfig, forceRefresh bool) []string { + providerNames := agentCfg.ProviderNames() + if len(providerNames) == 0 { + return nil + } + registered, missing := gateway.ValidateProviders(providerNames, gw) + if len(missing) > 0 || forceRefresh { + if err := registerProviders(harnessDir, gw, forceRefresh, agentCfg.Providers); err != nil { + status.Warnf("provider registration: %v", err) + } + registered, missing = gateway.ValidateProviders(providerNames, gw) + } + status.Header("Providers") + for _, name := range registered { + status.OKf("%s: registered", name) + } + for _, name := range missing { + status.Failf("%s: not registered", name) + } + return registered +} + func registerStandard(name, profileType string, gw gateway.Gateway, configs []string) error { if gw.ProviderGet(name) == nil { status.Infof("%s: exists", name) @@ -119,7 +143,7 @@ func registerADC(name, profileType, model string, gw gateway.Gateway, configs [] func registerGWS(harnessDir string, gw gateway.Gateway) error { if gw.ProviderGet("gws") == nil { - status.Info("gws: exists (use --force to recreate)") + status.Info("gws: exists (use --provider-refresh to recreate)") return nil } @@ -189,7 +213,7 @@ func registerGWS(harnessDir string, gw gateway.Gateway) error { return nil } -// gwsProfileScopes reads the refresh.scopes list from agents/providers/profiles/gws.yaml +// gwsProfileScopes reads the refresh.scopes list from profiles/providers/gws.yaml // and returns them as a space-separated string for use as OAuth scope material. func gwsProfileScopes(harnessDir string) string { profilePath := filepath.Join(harnessDir, "agents", "providers", "profiles", "gws.yaml") @@ -211,7 +235,7 @@ func gwsProfileScopes(harnessDir string) string { } func deleteCustomProfiles(harnessDir string, gw gateway.Gateway) { - profilesDir := filepath.Join(harnessDir, "agents", "providers", "profiles") + profilesDir := filepath.Join(harnessDir, "profiles", "providers") entries, err := os.ReadDir(profilesDir) if err != nil { return diff --git a/cmd/providers_test.go b/cmd/providers_test.go index 8506bb3..e01ecc6 100644 --- a/cmd/providers_test.go +++ b/cmd/providers_test.go @@ -12,7 +12,7 @@ import ( func setupProvidersTest(t *testing.T) string { t.Helper() dir := t.TempDir() - os.MkdirAll(filepath.Join(dir, "agents", "providers", "profiles"), 0o755) + os.MkdirAll(filepath.Join(dir, "profiles", "providers"), 0o755) return dir } diff --git a/cmd/resolve.go b/cmd/resolve.go new file mode 100644 index 0000000..4ed7a60 --- /dev/null +++ b/cmd/resolve.go @@ -0,0 +1,97 @@ +package cmd + +import ( + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + + "github.com/robbycochran/harness-openshell/internal/agent" + "github.com/robbycochran/harness-openshell/internal/gateway" +) + +func resolveSandboxName(gw gateway.Gateway, args []string) (string, error) { + if len(args) > 0 { + return args[0], nil + } + names, err := gw.SandboxList() + if err != nil { + return "", fmt.Errorf("listing sandboxes: %w", err) + } + if len(names) == 0 { + return "", fmt.Errorf("no sandboxes running") + } + if len(names) > 1 { + return "", fmt.Errorf("multiple sandboxes running, specify one: %v", names) + } + return names[0], nil +} + +func resolveAgentPath(harnessDir, agentName, agentFile string) string { + if agentFile != "" { + return agentFile + } + return filepath.Join(harnessDir, "agents", agentName+".yaml") +} + +func resolveAgentConfig(harnessDir, agentName, agentFile string) (*agent.AgentConfig, error) { + path := resolveAgentPath(harnessDir, agentName, agentFile) + cfg, err := agent.ParseFile(path) + if err == nil { + return cfg, nil + } + if agentFile != "" || agentName != "default" || len(DefaultAgentConfig) == 0 { + return nil, err + } + if _, statErr := os.Stat(path); !os.IsNotExist(statErr) { + return nil, err + } + return agent.Parse(DefaultAgentConfig) +} + +func versionedImage(name string) string { + base := "ghcr.io/robbycochran/harness-openshell" + if Version == "" || Version == "dev" { + return base + ":" + name + } + return base + ":" + name + "-" + Version +} + +// EmbeddedGatewayProfiles holds embedded gateway profile YAML, set from main.go. +var EmbeddedGatewayProfiles map[string][]byte + +func resolveGatewayConfig(harnessDir, name string) (*gateway.GatewayConfig, error) { + cfg, err := gateway.LoadProfile(harnessDir, name) + if err == nil { + return cfg, nil + } + if !errors.Is(err, fs.ErrNotExist) { + return nil, err + } + gwDir := filepath.Join(harnessDir, "gateways", name) + cfg, err = gateway.LoadConfig(gwDir) + if err == nil { + return cfg, nil + } + if !errors.Is(err, fs.ErrNotExist) { + return nil, err + } + if data, ok := EmbeddedGatewayProfiles[name]; ok { + return gateway.LoadConfigFromBytes(data) + } + return nil, fmt.Errorf("gateway profile %q not found", name) +} + +func resolveGatewayConfigFromFile(path string) (*gateway.GatewayConfig, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("reading gateway profile %s: %w", path, err) + } + cfg, err := gateway.LoadConfigFromBytes(data) + if err != nil { + return nil, err + } + cfg.Dir = filepath.Dir(path) + return cfg, nil +} diff --git a/cmd/sandbox.go b/cmd/sandbox.go index 27c57be..dad6c65 100644 --- a/cmd/sandbox.go +++ b/cmd/sandbox.go @@ -7,6 +7,7 @@ import ( "time" "github.com/robbycochran/harness-openshell/internal/gateway" + "github.com/robbycochran/harness-openshell/internal/status" ) // sandboxOpts holds the parameters that vary between callers of @@ -74,7 +75,7 @@ func createSandbox(opts sandboxOpts) error { return nil } - fmt.Printf(" Attempt %d failed: %v, retrying in 5s...\n", attempt, err) + status.Warnf("attempt %d: %v, retrying in 5s", attempt, err) opts.gw.SandboxDelete(opts.name) // best-effort cleanup if attempt == 5 { diff --git a/cmd/stop.go b/cmd/stop.go index eb5944d..229cc62 100644 --- a/cmd/stop.go +++ b/cmd/stop.go @@ -27,20 +27,3 @@ func NewStopCmd(harnessDir, cli string) *cobra.Command { }, } } - -func resolveSandboxName(gw gateway.Gateway, args []string) (string, error) { - if len(args) > 0 { - return args[0], nil - } - names, err := gw.SandboxList() - if err != nil { - return "", fmt.Errorf("listing sandboxes: %w", err) - } - if len(names) == 0 { - return "", fmt.Errorf("no sandboxes running") - } - if len(names) > 1 { - return "", fmt.Errorf("multiple sandboxes running, specify one: %v", names) - } - return names[0], nil -} diff --git a/cmd/teardown.go b/cmd/teardown.go index 7f35f51..c49188a 100644 --- a/cmd/teardown.go +++ b/cmd/teardown.go @@ -3,7 +3,6 @@ package cmd import ( "context" "fmt" - "path/filepath" "strings" "time" @@ -32,9 +31,9 @@ func NewTeardownCmd(harnessDir, cli string) *cobra.Command { activeGW := gw.ActiveGateway() if activeGW != "" { - fmt.Printf("Active gateway: %s\n", activeGW) + status.Infof("Active gateway: %s", activeGW) } else { - fmt.Println("Active gateway: none") + status.Info("Active gateway: none") } fmt.Println() @@ -49,8 +48,7 @@ func NewTeardownCmd(harnessDir, cli string) *cobra.Command { if k8sFlag { ns := k8s.DefaultNamespace() // Load OCP gateway config for SCC/secret names; fall back to defaults - gwDir := filepath.Join(harnessDir, "gateways", "ocp") - gwCfg, _ := gateway.LoadConfig(gwDir) + gwCfg, _ := resolveGatewayConfig(harnessDir, "ocp") teardownK8s(gw, gwCfg, k8s.New("", ns), k8s.New("", "")) } diff --git a/cmd/up.go b/cmd/up.go index ccba17f..ca72023 100644 --- a/cmd/up.go +++ b/cmd/up.go @@ -15,10 +15,10 @@ import ( func NewUpCmd(harnessDir, cli string) *cobra.Command { var ( - local bool - remote bool + gatewayName string + gatewayProfile string agentName string - agentFile string + agentProfile string sandboxName string noTTY bool providerRefresh bool @@ -29,33 +29,43 @@ func NewUpCmd(harnessDir, cli string) *cobra.Command { Short: "Deploy gateway, register providers, and create a sandbox", Long: "Deploy gateway and register providers if needed, then render an agent config into a running sandbox.", RunE: func(cmd *cobra.Command, args []string) error { - if local && remote { - return fmt.Errorf("--local and --remote are mutually exclusive") + if gatewayName != "" && gatewayProfile != "" { + return fmt.Errorf("--gateway and --gateway-profile are mutually exclusive") } if len(args) > 0 && sandboxName == "" { sandboxName = args[0] } - agentCfg, err := resolveAgentConfig(harnessDir, agentName, agentFile) + agentCfg, err := resolveAgentConfig(harnessDir, agentName, agentProfile) if err != nil { return err } - agentPath := resolveAgentPath(harnessDir, agentName, agentFile) + agentPath := resolveAgentPath(harnessDir, agentName, agentProfile) gw := gateway.New(cli) if err := gw.CheckMinVersion("0.0.59"); err != nil { status.Warn(fmt.Sprintf("OpenShell version: %v", err)) } - gwName := "local" - if remote { - gwName = "ocp" - } else if !local && agentCfg.Gateway != "" { - gwName = agentCfg.Gateway + var gwCfg *gateway.GatewayConfig + gwTarget := gatewayName + if gatewayProfile != "" { + gwCfg, err = resolveGatewayConfigFromFile(gatewayProfile) + if err != nil { + return err + } + gwTarget = gwCfg.Gateway.Type + } else { + if gwTarget == "" { + if agentCfg.Gateway != "" { + gwTarget = agentCfg.Gateway + } else { + gwTarget = "local" + } + } + gwCfg, _ = resolveGatewayConfig(harnessDir, gwTarget) } - isRemote := gwName != "local" - gwDir := filepath.Join(harnessDir, "gateways", gwName) - gwCfg, _ := gateway.LoadConfig(gwDir) + isRemote := gwTarget != "local" return upLocal(upLocalOpts{ harnessDir: harnessDir, @@ -72,10 +82,10 @@ func NewUpCmd(harnessDir, cli string) *cobra.Command { }, } - cmd.Flags().BoolVar(&local, "local", false, "Ensure local podman gateway (default when --remote is not specified)") - cmd.Flags().BoolVar(&remote, "remote", false, "Ensure OCP gateway") + cmd.Flags().StringVar(&gatewayName, "gateway", "", "Gateway profile name (local, kind, ocp)") + cmd.Flags().StringVar(&gatewayProfile, "gateway-profile", "", "Path to gateway profile YAML (overrides --gateway)") cmd.Flags().StringVar(&agentName, "agent", "default", "Agent config name (from agents/)") - cmd.Flags().StringVarP(&agentFile, "file", "f", "", "Path to agent YAML file (overrides --agent)") + cmd.Flags().StringVarP(&agentProfile, "agent-profile", "f", "", "Path to agent YAML file (overrides --agent)") cmd.Flags().StringVar(&sandboxName, "name", "", "Sandbox name (overrides agent config)") cmd.Flags().BoolVar(&noTTY, "no-tty", false, "Non-interactive mode (for testing)") cmd.Flags().BoolVar(&providerRefresh, "provider-refresh", false, "Delete and recreate all providers") @@ -83,30 +93,6 @@ func NewUpCmd(harnessDir, cli string) *cobra.Command { return cmd } -func resolveAgentPath(harnessDir, agentName, agentFile string) string { - if agentFile != "" { - return agentFile - } - return filepath.Join(harnessDir, "agents", agentName+".yaml") -} - -// resolveAgentConfig parses the agent config from disk, falling back to the -// embedded default when the file does not exist and no explicit --file was given. -func resolveAgentConfig(harnessDir, agentName, agentFile string) (*agent.AgentConfig, error) { - path := resolveAgentPath(harnessDir, agentName, agentFile) - cfg, err := agent.ParseFile(path) - if err == nil { - return cfg, nil - } - if agentFile != "" || agentName != "default" || len(DefaultAgentConfig) == 0 { - return nil, err - } - if _, statErr := os.Stat(path); !os.IsNotExist(statErr) { - return nil, err - } - return agent.Parse(DefaultAgentConfig) -} - type upLocalOpts struct { harnessDir string gw gateway.Gateway @@ -154,7 +140,7 @@ func upLocal(opts upLocalOpts) error { } } else if gw.InferenceGet() != nil { if opts.gwCfg == nil { - return fmt.Errorf("no active gateway — use --local or: harness deploy ocp") + return fmt.Errorf("no active gateway — use --gateway local or: harness deploy ocp") } kc := k8s.New("", k8s.DefaultNamespace()) clusterRunner := k8s.New("", "") @@ -164,25 +150,7 @@ func upLocal(opts upLocalOpts) error { } // 3. Ensure providers needed by the agent are registered - providerNames := agentCfg.ProviderNames() - var registered []string - if len(providerNames) > 0 { - var missing []string - registered, missing = gateway.ValidateProviders(providerNames, gw) - if len(missing) > 0 || opts.providerRefresh { - if err := registerProviders(opts.harnessDir, gw, opts.providerRefresh, agentCfg.Providers); err != nil { - status.Warn(fmt.Sprintf("provider registration: %v", err)) - } - registered, missing = gateway.ValidateProviders(providerNames, gw) - } - status.Header("Providers") - for _, name := range registered { - status.OKf("%s", name) - } - for _, name := range missing { - status.Failf("%s (not registered)", name) - } - } + registered := ensureProviders(opts.harnessDir, gw, agentCfg, opts.providerRefresh) // 4. Render payload payloadDir, err := os.MkdirTemp("", "harness-payload-") @@ -223,10 +191,3 @@ var Version = "dev" // DefaultAgentConfig holds the embedded default agent YAML, set from main.go. var DefaultAgentConfig []byte -func versionedImage(name string) string { - base := "ghcr.io/robbycochran/harness-openshell" - if Version == "" || Version == "dev" { - return base + ":" + name - } - return base + ":" + name + "-" + Version -} diff --git a/docs/proto-migration.md b/docs/proto-migration.md deleted file mode 100644 index 109b8e1..0000000 --- a/docs/proto-migration.md +++ /dev/null @@ -1,231 +0,0 @@ -# Proto-Based Profile Format Migration - -Migrate harness-openshell's profile and provider profile formats from -hand-rolled TOML/YAML to OpenShell's proto-generated Go types. The proto -becomes the schema; the serialization format (textproto, JSON, or TOML -with a mapping layer) is a separate decision. - -## Why - -### Upstream alignment - -OpenShell's `SandboxSpec`, `ProviderProfile`, and related messages are the -canonical types that the gateway operates on. Every field harness-openshell -cares about already exists in the proto: - -| harness-openshell concept | Proto message | -|---------------------------|---------------| -| Sandbox profile (`profiles/*.toml`) | `CreateSandboxRequest` → `SandboxSpec` → `SandboxTemplate` | -| Provider profile (`sandbox/profiles/*.yaml`) | `ProviderProfile` | -| Provider credentials | `ProviderProfileCredential` | -| Network endpoints | `NetworkEndpoint` (from `sandbox.proto`) | -| Provider catalog (`providers.toml`) | Partially: `ProviderProfileDiscovery` + `ProviderProfileCredential.env_vars` | - -When OpenShell adds fields (e.g., `user_namespaces`, `volume_claim_templates`, -credential refresh material), re-running `protoc` makes them available -immediately. No manual struct updates, no drift. - -### Compile-time safety - -Today, profile fields are parsed from TOML into a hand-written `Config` -struct. If OpenShell renames or restructures a field, the harness discovers -this at runtime when CLI flags fail. With proto-generated types, a field -change is a compile error. - -### Format for the future - -The proto types are the same ones a gRPC client would use. If/when -harness-openshell moves from CLI exec to direct gRPC, the profile structs -are already the request payloads — no translation step. - -## What changes - -### Sandbox profiles - -**Today:** `profiles/default.toml` → parsed into `profile.Config` struct → -translated to `openshell sandbox create --name X --image Y --provider Z` flags. - -**After:** `profiles/default.textproto` (or `.json`) → unmarshalled into -generated `openshellv1.CreateSandboxRequest` + a `HarnessConfig` wrapper → -same CLI flags, but sourced from the proto struct. - -Harness-only fields (`command`, `keep`) have no proto equivalent. These -live in a wrapper: - -```go -type Profile struct { - Request *openshellv1.CreateSandboxRequest - Command string - Keep bool -} -``` - -### Provider profiles - -**Today:** `sandbox/profiles/atlassian.yaml` → hand-written YAML matching -OpenShell's profile schema → imported via `openshell provider profile import`. - -**After:** `sandbox/profiles/atlassian.textproto` → unmarshalled into -generated `openshellv1.ProviderProfile` → serialized to YAML or JSON for -`openshell provider profile import`, or passed directly if/when using gRPC. - -### Provider catalog (providers.toml) - -`providers.toml` tracks preflight validation inputs and custom provider -workarounds. The proto doesn't cover preflight checks (`kind = "file"`, -`kind = "check"`) or the `upstream` tracking field. This file stays as-is — -it's harness-specific orchestration, not an upstream type. - -Over time, `providers.toml` shrinks as: -- OpenShell's `ProviderProfileDiscovery` handles more credential discovery -- Credential verification on create ships upstream (#896 roadmap) -- Custom providers migrate to native OpenShell provider profiles - -### Bash path - -The bash path (`profile.sh` + `parse-profile.py`) currently reads TOML. -Options when the format changes: - -| Option | Tradeoff | -|--------|----------| -| **Go shim** (`harness profile dump NAME` → shell vars) | One parser, two consumers. Bash calls Go. Cleanest. | -| **Swap Python parser** (protobuf or json module) | Keeps bash self-contained but adds protobuf pip dependency. | -| **Drop bash path** | Simplest, but premature if Go migration isn't complete. | - -**Recommendation:** Go shim. Add a `harness profile dump` subcommand that -reads the proto-format profile and emits `SANDBOX_NAME=...` shell variable -assignments. `parse-profile.py` is replaced by a one-line call to the Go -binary. No new dependencies in the bash path, no duplicated parsing logic. - -## Tradeoffs - -### Serialization format - -| Format | Pros | Cons | -|--------|------|------| -| **Textproto** | Comments. Snake_case field names match proto exactly. `prototext.Unmarshal` in Go. | Map entries are verbose (`key:` / `value:` pairs). Less familiar to most users. | -| **Proto JSON** | Universal. `protojson.Unmarshal` in Go. Easy to generate/consume from other tools. | No comments. camelCase field names (protobuf JSON convention). | -| **TOML (keep, marshal internally)** | Best authoring UX. Users don't need to learn a new format. | Manual mapping layer between TOML keys and proto fields. Defeats some of the alignment benefit. | - -**Recommendation:** Textproto for provider profiles (they read cleanly, -comments are valuable for documenting credential vs. config distinctions). -For sandbox profiles, textproto works but the `environment` map is ugly — -proto JSON may be more readable there. Support both via file extension -detection (`.textproto` → `prototext`, `.json` → `protojson`). - -### Proto stability - -OpenShell is alpha (v0.0.58). The proto could change. However: - -- `SandboxSpec` core fields (`image`, `environment`, `providers`) are stable - — they map directly to the CLI's `sandbox create` flags which have been - stable since v0.0.20+. -- `ProviderProfile` shipped with providers v2 and has a published schema in - the docs. Breaking changes would affect all provider profile YAML users, - not just harness-openshell. -- Proto changes produce compile errors, which is strictly better than - discovering CLI output format changes at runtime. -- If a field is removed or renamed, the fix is: re-generate, update the one - or two references in harness code, done. - -### Complexity budget - -Adding `protoc` to the build introduces: -- Proto files vendored (4 files: `openshell.proto`, `datamodel.proto`, - `sandbox.proto`, `inference.proto`) -- `protoc-gen-go` and `protoc-gen-go-grpc` as build-time dependencies -- A `make proto` (or `buf generate`) target -- Generated `.pb.go` files checked in (or generated in CI) - -This is standard Go infrastructure but it's not zero. The project currently -has no protobuf dependency. - -## Implementation plan - -### Phase 1: Vendor proto, generate types - -1. Create `proto/` directory, vendor the 4 proto files from NVIDIA/OpenShell -2. Add `buf.yaml` + `buf.gen.yaml` (or a `make proto` target with `protoc`) -3. Generate Go types into `internal/openshell/` (or `pkg/openshell/`) -4. Add generated files to the repo (avoid requiring protoc for contributors) -5. Verify: generated `CreateSandboxRequest`, `SandboxSpec`, `ProviderProfile` - types compile and match the upstream schema - -### Phase 2: Migrate provider profiles - -Start here because the provider profile format is the cleanest mapping — -almost 1:1 with the existing `atlassian.yaml`. - -1. Convert `sandbox/profiles/atlassian.yaml` → `sandbox/profiles/atlassian.textproto` -2. Update `cmd/providers.go` to unmarshal via `prototext.Unmarshal` into - `*openshellv1.ProviderProfile` -3. For `openshell provider profile import`: serialize the proto struct to - YAML (or JSON) since the CLI expects that format, or write a temp file -4. Update tests -5. Verify: `harness providers` still registers the atlassian profile correctly - -### Phase 3: Migrate sandbox profiles - -1. Define the `Profile` wrapper struct (proto `CreateSandboxRequest` + - harness-only `Command` and `Keep` fields) -2. Convert `profiles/default.toml` → `profiles/default.textproto` (or `.json`) - with a sidecar section or separate file for harness-only fields -3. Update `internal/profile/` to parse the new format into the wrapper struct -4. Update `internal/profile/` to translate the proto struct into CLI flags - (same logic as today, different source struct) -5. Add `harness profile dump NAME` subcommand for bash path compatibility -6. Replace `parse-profile.py` call in `profile.sh` with `harness profile dump` -7. Update tests (Go unit tests + bats integration) -8. Verify: `make validate` passes on all matrix combinations - -### Phase 4: Cleanup - -1. Remove `parse-profile.py` -2. Remove old `profile.Config` struct (replaced by wrapper + proto types) -3. Update docs and `profile-concepts.md` to reflect the new format -4. Document the format in a comment block or README section - -## Decision: harness-only fields - -Two fields exist in harness profiles that have no proto equivalent: - -| Field | Why it's harness-only | Where it goes | -|-------|----------------------|---------------| -| `command` | Passed at `sandbox connect` / `sandbox exec` time, not at creation | Wrapper struct | -| `keep` | Client-side lifecycle decision (keep sandbox after command exits) | Wrapper struct | - -Options for encoding these alongside the proto: - -1. **Wrapper struct with separate parsing** — proto fields in `.textproto`, - harness fields as Go defaults or a small sidecar `harness.toml`. -2. **Proto extensions** — technically possible but overkill and non-standard. -3. **Comments-as-config** — parse specially formatted comments in the - textproto. Fragile, don't do this. -4. **Custom proto message** — define a `HarnessProfile` message that embeds - `CreateSandboxRequest`. Cleanest if we're already running protoc. - -**Recommendation:** Option 4. Define a small `harness.proto`: - -```protobuf -syntax = "proto3"; -package harness.v1; -import "openshell.proto"; - -message Profile { - openshell.v1.CreateSandboxRequest request = 1; - string command = 2; // default: "claude --bare" - bool keep = 3; // default: true - string description = 4; // human-readable profile description -} -``` - -This gives you one file, one parse call, full type safety, and a natural -place for any future harness-only fields. - -## References - -- [OpenShell proto](https://github.com/NVIDIA/OpenShell/blob/main/proto/openshell.proto) -- [OpenShell providers v2 docs](https://github.com/NVIDIA/OpenShell/blob/main/docs/sandboxes/providers-v2.mdx) -- [OpenShell #896 — Enhanced Provider Management](https://github.com/NVIDIA/OpenShell/issues/896) -- [Kaiden #1272 — Projects management](https://github.com/openkaiden/kaiden/issues/1272) -- [profile-concepts.md](profile-concepts.md) — cross-project profile concept analysis diff --git a/docs/release-plan.md b/docs/release-plan.md index ee7bef4..f6d698a 100644 --- a/docs/release-plan.md +++ b/docs/release-plan.md @@ -43,7 +43,7 @@ var Files embed.FS Create directory `internal/embed/files/` populated by `make embed-sync`. Runtime files to embed: - `sandbox/profiles/atlassian.yaml` - `sandbox/CLAUDE.md`, `settings.json`, `mcp.json`, `policy.yaml`, `startup.sh` -- `profiles/default.toml` +- `agents/default.yaml` - `values-ocp.yaml` **Decision: copy, not symlink.** `go:embed` doesn't follow symlinks. `make embed-sync` copies originals into `internal/embed/files/`. GoReleaser hooks call this automatically. @@ -64,7 +64,7 @@ Add `~/.openshell/harness/` as fallback: 1. $HARNESS_DIR env var 2. Walk up from executable location 3. Walk up from cwd -4. ~/.openshell/harness/ (if profiles/default.toml exists there) +4. ~/.openshell/harness/ (if agents/default.yaml exists there) 5. Error + exit ``` @@ -108,7 +108,7 @@ harness init ```bash git clone ... && cd harness-openshell make cli -./harness up --local +./harness up ``` --- diff --git a/gateways/kind/helm/values.yaml b/gateways/kind/helm/values.yaml deleted file mode 100644 index 4d5b208..0000000 --- a/gateways/kind/helm/values.yaml +++ /dev/null @@ -1,20 +0,0 @@ -# kind gateway Helm values. -# -# NodePort service, plaintext HTTP (no TLS) for local dev. -# The openshell CLI registers the gateway via http:// which skips mTLS and -# browser auth entirely. The PKI init job still runs to create the JWT -# signing key secret the gateway requires. - -service: - type: NodePort - -server: - disableTls: true - auth: - allowUnauthenticatedUsers: true - -# PKI init job creates the JWT signing key (openshell-jwt-keys secret). -# Even with TLS disabled, the gateway needs the JWT signing key to -# issue sandbox tokens. -pkiInitJob: - enabled: true diff --git a/gateways/ocp/addons/route.yaml b/gateways/ocp/addons/route.yaml deleted file mode 100644 index 9ee1f4c..0000000 --- a/gateways/ocp/addons/route.yaml +++ /dev/null @@ -1,13 +0,0 @@ -# OpenShift route — namespace set by kubectl -n flag. -apiVersion: route.openshift.io/v1 -kind: Route -metadata: - name: gateway -spec: - tls: - termination: passthrough - to: - kind: Service - name: openshell - port: - targetPort: grpc diff --git a/gateways/ocp/gateway.yaml b/gateways/ocp/gateway.yaml deleted file mode 100644 index d97d720..0000000 --- a/gateways/ocp/gateway.yaml +++ /dev/null @@ -1,33 +0,0 @@ -# OpenShift gateway — deploys openshell to an OCP cluster. -# -# Prerequisites: -# - oc login to the target cluster -# - KUBECONFIG set or default context pointing to OCP - -gateway: - type: remote - platform: ocp - service: route - name: openshell-remote-ocp - -providers: - enabled: [github, vertex-local, atlassian, gws] - -chart: - oci: oci://ghcr.io/nvidia/openshell/helm-chart - version: "0.0.59" - crd: - url: https://github.com/kubernetes-sigs/agent-sandbox/releases/latest/download/manifest.yaml - -helm: - values: values.yaml - -addons: - manifests: [addons/route.yaml] - -ocp: - scc-privileged: [openshell, openshell-sandbox] - scc-anyuid: [openshell] - -secrets: - mtls: openshell-client-tls diff --git a/gateways/ocp/helm/values.yaml b/gateways/ocp/helm/values.yaml deleted file mode 100644 index 82c08b6..0000000 --- a/gateways/ocp/helm/values.yaml +++ /dev/null @@ -1,34 +0,0 @@ -# OpenShell Helm values for OpenShift deployment. -# -# Override image repos/tags via environment variables in deploy-ocp.sh. -# Only non-default values are listed here. - -# Always pull images so updates are picked up without manual pod restarts. -image: - pullPolicy: Always - -supervisor: - image: - pullPolicy: Always - -# OpenShift security context: let OpenShift assign UIDs via SCC. -# runAsUser and runAsNonRoot are nulled so OpenShift's SCC assigns the UID. -# fsGroup is kept so the PVC at /var/openshell is group-writable. -securityContext: - runAsUser: null - runAsNonRoot: null - -# Always pull sandbox images (development workflow). -server: - sandboxImagePullPolicy: Always - # allowUnauthenticatedUsers: the gateway is not internet-exposed — it is - # only accessible via the OpenShift route with TLS passthrough + mTLS. - # The client cert in ~/.config/openshell/gateways/ocp/mtls/ is the gate. - # To add OIDC on top of mTLS, configure server.oidc.issuer instead. - auth: - allowUnauthenticatedUsers: true - -# PKI: auto-generate mTLS secrets on first install. -# serverDnsNames is patched at deploy time to include the Route hostname. -pkiInitJob: - enabled: true diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 6f71f2b..a62dcc0 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -4,7 +4,6 @@ import ( "fmt" "os" "path/filepath" - "sort" "strings" "gopkg.in/yaml.v3" @@ -99,23 +98,6 @@ func (c *AgentConfig) BuildEnvMap() map[string]string { return env } -func (c *AgentConfig) BuildEnvSh() string { - env := c.BuildEnvMap() - if len(env) == 0 { - return "" - } - keys := make([]string, 0, len(env)) - for k := range env { - keys = append(keys, k) - } - sort.Strings(keys) - var b strings.Builder - for _, k := range keys { - fmt.Fprintf(&b, "export %s=%q\n", k, env[k]) - } - return b.String() -} - func (c *AgentConfig) BuildRunSh() string { var b strings.Builder b.WriteString("#!/usr/bin/env bash\nset -euo pipefail\n\n") diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go index cc3fbe8..ae7635a 100644 --- a/internal/agent/agent_test.go +++ b/internal/agent/agent_test.go @@ -141,44 +141,6 @@ func TestNoTTY(t *testing.T) { } } -func TestBuildEnvSh(t *testing.T) { - cfg := &AgentConfig{ - Env: map[string]string{ - "ANTHROPIC_BASE_URL": "https://inference.local", - "ANTHROPIC_API_KEY": "sk-proxy", - }, - } - env := cfg.BuildEnvSh() - if !strings.Contains(env, `export ANTHROPIC_BASE_URL="https://inference.local"`) { - t.Errorf("missing ANTHROPIC_BASE_URL in:\n%s", env) - } -} - -func TestBuildEnvSh_Empty(t *testing.T) { - cfg := &AgentConfig{Providers: []ProviderRef{{Profile: "github"}}} - if env := cfg.BuildEnvSh(); env != "" { - t.Errorf("expected empty env.sh, got:\n%s", env) - } -} - -func TestBuildEnvSh_IncludesProviderEnv(t *testing.T) { - cfg := &AgentConfig{ - Env: map[string]string{ - "ANTHROPIC_BASE_URL": "https://inference.local", - }, - Providers: []ProviderRef{ - {Profile: "atlassian", Env: map[string]string{"JIRA_URL": "https://jira.example.com"}}, - }, - } - env := cfg.BuildEnvSh() - if !strings.Contains(env, `ANTHROPIC_BASE_URL`) { - t.Errorf("missing top-level env var in:\n%s", env) - } - if !strings.Contains(env, `JIRA_URL`) { - t.Errorf("missing provider env var in:\n%s", env) - } -} - func TestBuildEnvMap_IncludesProviderEnv(t *testing.T) { t.Setenv("JIRA_URL", "https://test.atlassian.net") cfg := &AgentConfig{ @@ -304,18 +266,6 @@ func TestBuildEnvMap_Empty(t *testing.T) { } } -func TestBuildEnvSh_Sorted(t *testing.T) { - cfg := &AgentConfig{ - Env: map[string]string{"Z_VAR": "z", "A_VAR": "a"}, - } - env := cfg.BuildEnvSh() - aIdx := strings.Index(env, "A_VAR") - zIdx := strings.Index(env, "Z_VAR") - if aIdx > zIdx { - t.Error("env.sh should be sorted alphabetically") - } -} - func TestBuildRunSh(t *testing.T) { cfg := &AgentConfig{ Entrypoint: "claude --bare", diff --git a/internal/gateway/cli.go b/internal/gateway/cli.go index aff447d..76d7cf0 100644 --- a/internal/gateway/cli.go +++ b/internal/gateway/cli.go @@ -9,7 +9,6 @@ import ( "sort" "strconv" "strings" - "syscall" "github.com/robbycochran/harness-openshell/internal/status" ) @@ -97,20 +96,6 @@ func (c *CLI) InferenceGet() error { return c.silent("inference", "get") } -func (c *CLI) InferenceModel() string { - out, err := c.output("inference", "get") - if err != nil { - return "" - } - for _, line := range strings.Split(string(out), "\n") { - cleaned := ansiRE.ReplaceAllString(line, "") - if strings.Contains(cleaned, "Model:") { - return strings.TrimSpace(strings.SplitN(cleaned, "Model:", 2)[1]) - } - } - return "" -} - func (c *CLI) ActiveGateway() string { out, err := c.output("gateway", "list") if err != nil { @@ -306,17 +291,6 @@ func (c *CLI) SandboxStatus() ([]SandboxInfo, error) { return infos, nil } -func (c *CLI) SandboxLogs(name string, follow bool) error { - args := []string{"sandbox", "logs"} - if name != "" { - args = append(args, name) - } - if follow { - args = append(args, "--follow") - } - return c.passthrough(args...) -} - func (c *CLI) SandboxStop(name string) error { return c.silent("sandbox", "stop", name) } @@ -329,18 +303,6 @@ 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 parseFirstColumn(out []byte) []string { var names []string for i, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { diff --git a/internal/gateway/cli_test.go b/internal/gateway/cli_test.go index c6f7ace..9307a90 100644 --- a/internal/gateway/cli_test.go +++ b/internal/gateway/cli_test.go @@ -284,37 +284,6 @@ exit 0 } } -func TestSandboxLogs_PassesFollow(t *testing.T) { - argsFile := filepath.Join(t.TempDir(), "args") - bin := writeStub(t, `#!/bin/bash -echo "$@" > `+argsFile+` -`) - gw := New(bin) - gw.SandboxLogs("my-agent", true) - data, _ := os.ReadFile(argsFile) - args := strings.TrimSpace(string(data)) - if !strings.Contains(args, "sandbox logs my-agent --follow") { - t.Errorf("expected sandbox logs with --follow, got: %s", args) - } -} - -func TestSandboxLogs_NoFollow(t *testing.T) { - argsFile := filepath.Join(t.TempDir(), "args") - bin := writeStub(t, `#!/bin/bash -echo "$@" > `+argsFile+` -`) - gw := New(bin) - gw.SandboxLogs("my-agent", false) - data, _ := os.ReadFile(argsFile) - args := strings.TrimSpace(string(data)) - if strings.Contains(args, "--follow") { - t.Errorf("should not have --follow: %s", args) - } - if !strings.Contains(args, "sandbox logs my-agent") { - t.Errorf("missing sandbox logs: %s", args) - } -} - func TestSandboxList_Empty(t *testing.T) { bin := writeStub(t, `#!/bin/bash printf "NAME\tPHASE\n" @@ -354,18 +323,6 @@ printf " local\thttps://127.0.0.1:17670\n" } } -func TestInferenceModel_ParsesModel(t *testing.T) { - bin := writeStub(t, `#!/bin/bash -echo "Provider: vertex-local" -echo "Model: claude-sonnet-4-6" -`) - gw := New(bin) - model := gw.InferenceModel() - if model != "claude-sonnet-4-6" { - t.Errorf("InferenceModel = %q, want claude-sonnet-4-6", model) - } -} - func TestCLIVersion(t *testing.T) { bin := writeStub(t, `#!/bin/bash echo "openshell v0.0.58" diff --git a/internal/gateway/config.go b/internal/gateway/config.go index ab6efc5..fac18bf 100644 --- a/internal/gateway/config.go +++ b/internal/gateway/config.go @@ -44,11 +44,64 @@ type CRDConfig struct { } type HelmSection struct { - Values string `yaml:"values"` + ValuesPath string `yaml:"-"` + ValuesInline map[string]any `yaml:"-"` +} + +func (h *HelmSection) UnmarshalYAML(value *yaml.Node) error { + var raw struct { + Values yaml.Node `yaml:"values"` + } + if err := value.Decode(&raw); err != nil { + return err + } + if raw.Values.Kind == 0 { + return nil + } + switch raw.Values.Kind { + case yaml.ScalarNode: + h.ValuesPath = raw.Values.Value + case yaml.MappingNode: + var m map[string]any + if err := raw.Values.Decode(&m); err != nil { + return fmt.Errorf("decoding inline helm values: %w", err) + } + h.ValuesInline = m + } + return nil +} + +type ManifestRef struct { + Path string `yaml:"-"` + Inline map[string]any `yaml:"-"` } type AddonsSection struct { - Manifests []string `yaml:"manifests"` + Manifests []ManifestRef +} + +func (a *AddonsSection) UnmarshalYAML(value *yaml.Node) error { + var raw struct { + Manifests []yaml.Node `yaml:"manifests"` + } + if err := value.Decode(&raw); err != nil { + return err + } + for _, node := range raw.Manifests { + var ref ManifestRef + switch node.Kind { + case yaml.ScalarNode: + ref.Path = node.Value + case yaml.MappingNode: + var m map[string]any + if err := node.Decode(&m); err != nil { + return fmt.Errorf("decoding inline manifest: %w", err) + } + ref.Inline = m + } + a.Manifests = append(a.Manifests, ref) + } + return nil } type OCPSection struct { @@ -62,21 +115,37 @@ type SecretsSection struct { } func LoadConfig(dir string) (*GatewayConfig, error) { - path := filepath.Join(dir, "gateway.yaml") - data, err := os.ReadFile(path) + data, err := os.ReadFile(filepath.Join(dir, "gateway.yaml")) + if err != nil { + return nil, fmt.Errorf("reading gateway config: %w", err) + } + cfg, err := LoadConfigFromBytes(data) if err != nil { - return nil, fmt.Errorf("reading %s: %w", path, err) + return nil, err } + cfg.Dir = dir + return cfg, nil +} + +func LoadConfigFromBytes(data []byte) (*GatewayConfig, error) { var cfg GatewayConfig if err := yaml.Unmarshal(data, &cfg); err != nil { - return nil, fmt.Errorf("parsing %s: %w", path, err) + return nil, fmt.Errorf("parsing gateway config: %w", err) } - cfg.Dir = dir cfg.applyDefaults() cfg.applyEnvOverrides() return &cfg, nil } +func LoadProfile(harnessDir, name string) (*GatewayConfig, error) { + path := filepath.Join(harnessDir, "profiles", "gateways", name+".yaml") + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + return LoadConfigFromBytes(data) +} + func (c *GatewayConfig) applyDefaults() { if c.Chart.OCI == "" { c.Chart.OCI = "oci://ghcr.io/nvidia/openshell/helm-chart" @@ -100,28 +169,40 @@ func (c *GatewayConfig) IsOCP() bool { return c.Gateway.Platform == "ocp" } -func (c *GatewayConfig) HasProviders() bool { - return len(c.Providers.Enabled) > 0 || len(c.Providers.Custom) > 0 -} - -func (c *GatewayConfig) AllProviders() []string { - all := make([]string, 0, len(c.Providers.Enabled)+len(c.Providers.Custom)) - all = append(all, c.Providers.Enabled...) - all = append(all, c.Providers.Custom...) - return all +func (c *GatewayConfig) HelmValuesFile(tmpDir string) (string, error) { + if c.Helm.ValuesInline != nil { + data, err := yaml.Marshal(c.Helm.ValuesInline) + if err != nil { + return "", fmt.Errorf("marshaling inline helm values: %w", err) + } + path := filepath.Join(tmpDir, "values.yaml") + if err := os.WriteFile(path, data, 0o644); err != nil { + return "", err + } + return path, nil + } + if c.Helm.ValuesPath == "" { + return "", nil + } + return filepath.Join(c.Dir, "helm", c.Helm.ValuesPath), nil } -func (c *GatewayConfig) HelmValuesPath() string { - if c.Helm.Values == "" { - return "" +func (c *GatewayConfig) ManifestFilePaths() []string { + var paths []string + for _, m := range c.Addons.Manifests { + if m.Path != "" { + paths = append(paths, filepath.Join(c.Dir, m.Path)) + } } - return filepath.Join(c.Dir, "helm", c.Helm.Values) + return paths } -func (c *GatewayConfig) ManifestPaths() []string { - paths := make([]string, len(c.Addons.Manifests)) - for i, m := range c.Addons.Manifests { - paths[i] = filepath.Join(c.Dir, m) +func (c *GatewayConfig) ManifestInline() []map[string]any { + var manifests []map[string]any + for _, m := range c.Addons.Manifests { + if m.Inline != nil { + manifests = append(manifests, m.Inline) + } } - return paths + return manifests } diff --git a/internal/gateway/config_test.go b/internal/gateway/config_test.go index ee07380..cd70e7a 100644 --- a/internal/gateway/config_test.go +++ b/internal/gateway/config_test.go @@ -3,6 +3,7 @@ package gateway import ( "os" "path/filepath" + "strings" "testing" ) @@ -198,7 +199,7 @@ gateway: } } -func TestHelmValuesPath(t *testing.T) { +func TestHelmValuesFile_Path(t *testing.T) { dir := t.TempDir() writeGatewayYAML(t, dir, ` gateway: @@ -212,13 +213,53 @@ helm: t.Fatal(err) } + tmpDir := t.TempDir() want := filepath.Join(dir, "helm", "values.yaml") - if got := cfg.HelmValuesPath(); got != want { - t.Errorf("HelmValuesPath() = %q, want %q", got, want) + got, err := cfg.HelmValuesFile(tmpDir) + if err != nil { + t.Fatal(err) + } + if got != want { + t.Errorf("HelmValuesFile() = %q, want %q", got, want) + } +} + +func TestHelmValuesFile_Inline(t *testing.T) { + dir := t.TempDir() + writeGatewayYAML(t, dir, ` +gateway: + type: remote +helm: + values: + service: + type: NodePort + server: + disableTls: true +`) + + cfg, err := LoadConfig(dir) + if err != nil { + t.Fatal(err) + } + + tmpDir := t.TempDir() + got, err := cfg.HelmValuesFile(tmpDir) + if err != nil { + t.Fatal(err) + } + if got == "" { + t.Fatal("HelmValuesFile() returned empty path for inline values") + } + data, err := os.ReadFile(got) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(data), "NodePort") { + t.Errorf("inline values file missing NodePort: %s", data) } } -func TestHelmValuesPath_Empty(t *testing.T) { +func TestHelmValuesFile_Empty(t *testing.T) { dir := t.TempDir() writeGatewayYAML(t, dir, ` gateway: @@ -230,12 +271,17 @@ gateway: t.Fatal(err) } - if got := cfg.HelmValuesPath(); got != "" { - t.Errorf("HelmValuesPath() = %q, want empty", got) + tmpDir := t.TempDir() + got, err := cfg.HelmValuesFile(tmpDir) + if err != nil { + t.Fatal(err) + } + if got != "" { + t.Errorf("HelmValuesFile() = %q, want empty", got) } } -func TestManifestPaths(t *testing.T) { +func TestManifestFilePaths(t *testing.T) { dir := t.TempDir() writeGatewayYAML(t, dir, ` gateway: @@ -251,9 +297,9 @@ addons: t.Fatal(err) } - paths := cfg.ManifestPaths() + paths := cfg.ManifestFilePaths() if len(paths) != 2 { - t.Fatalf("ManifestPaths() returned %d paths, want 2", len(paths)) + t.Fatalf("ManifestFilePaths() returned %d paths, want 2", len(paths)) } if paths[0] != filepath.Join(dir, "addons", "rbac.yaml") { t.Errorf("paths[0] = %q", paths[0]) @@ -263,11 +309,17 @@ addons: } } -func TestManifestPaths_Empty(t *testing.T) { +func TestManifestInline(t *testing.T) { dir := t.TempDir() writeGatewayYAML(t, dir, ` gateway: type: remote +addons: + manifests: + - apiVersion: route.openshift.io/v1 + kind: Route + metadata: + name: gateway `) cfg, err := LoadConfig(dir) @@ -275,8 +327,52 @@ gateway: t.Fatal(err) } - if len(cfg.ManifestPaths()) != 0 { - t.Errorf("ManifestPaths() should be empty, got %v", cfg.ManifestPaths()) + inline := cfg.ManifestInline() + if len(inline) != 1 { + t.Fatalf("ManifestInline() returned %d, want 1", len(inline)) + } + if inline[0]["kind"] != "Route" { + t.Errorf("expected kind=Route, got %v", inline[0]["kind"]) + } +} + +func TestManifests_Empty(t *testing.T) { + dir := t.TempDir() + writeGatewayYAML(t, dir, ` +gateway: + type: remote +`) + + cfg, err := LoadConfig(dir) + if err != nil { + t.Fatal(err) + } + + if len(cfg.ManifestFilePaths()) != 0 { + t.Errorf("ManifestFilePaths() should be empty") + } + if len(cfg.ManifestInline()) != 0 { + t.Errorf("ManifestInline() should be empty") + } +} + +func TestLoadConfigFromBytes(t *testing.T) { + data := []byte(` +gateway: + type: remote + platform: ocp +chart: + version: "0.0.59" +`) + cfg, err := LoadConfigFromBytes(data) + if err != nil { + t.Fatal(err) + } + if cfg.Gateway.Platform != "ocp" { + t.Errorf("platform = %q, want ocp", cfg.Gateway.Platform) + } + if cfg.Chart.OCI == "" { + t.Error("defaults not applied") } } @@ -327,44 +423,3 @@ func TestPredicates(t *testing.T) { } } -func TestHasProviders(t *testing.T) { - dir := t.TempDir() - writeGatewayYAML(t, dir, ` -gateway: - type: local -providers: - enabled: [github] - custom: [gws] -`) - - cfg, err := LoadConfig(dir) - if err != nil { - t.Fatal(err) - } - - if !cfg.HasProviders() { - t.Error("HasProviders() = false, want true") - } - - all := cfg.AllProviders() - if len(all) != 2 { - t.Errorf("AllProviders() = %v, want [github gws]", all) - } -} - -func TestHasProviders_Empty(t *testing.T) { - dir := t.TempDir() - writeGatewayYAML(t, dir, ` -gateway: - type: local -`) - - cfg, err := LoadConfig(dir) - if err != nil { - t.Fatal(err) - } - - if cfg.HasProviders() { - t.Error("HasProviders() = true with no providers section") - } -} diff --git a/internal/gateway/gateway.go b/internal/gateway/gateway.go index e65ecc8..6848a16 100644 --- a/internal/gateway/gateway.go +++ b/internal/gateway/gateway.go @@ -17,14 +17,11 @@ type Gateway interface { SandboxStatus() ([]SandboxInfo, error) SandboxCreate(opts SandboxCreateOpts) error SandboxDelete(name string) error - SandboxConnect(name string) error - SandboxLogs(name string, follow bool) error SandboxStop(name string) error SandboxStart(name string) error // Inference InferenceGet() error - InferenceModel() string InferenceSet(provider, model string) error InferenceRemove() error diff --git a/internal/k8s/kubectl.go b/internal/k8s/kubectl.go index d6433b5..ed45093 100644 --- a/internal/k8s/kubectl.go +++ b/internal/k8s/kubectl.go @@ -31,7 +31,6 @@ type Runner interface { RunKubectl(ctx context.Context, args ...string) (string, error) RunKubectlOpts(ctx context.Context, opts KubectlOpts) (string, error) RunKubectlQuiet(ctx context.Context, args ...string) error - RunKubectlPassthrough(ctx context.Context, args ...string) error RunHelm(ctx context.Context, args ...string) error RunOC(ctx context.Context, args ...string) error ApplyYAML(ctx context.Context, resources ...map[string]any) error @@ -118,21 +117,6 @@ func (c *Client) RunKubectlQuiet(ctx context.Context, args ...string) error { return err } -func (c *Client) RunKubectlPassthrough(ctx context.Context, args ...string) error { - if c.namespace != "" && !containsFlag(args, "-n", "--namespace") { - args = append([]string{"-n", c.namespace}, args...) - } - if c.kubeconfig != "" { - args = append([]string{"--kubeconfig", c.kubeconfig}, args...) - } - status.Cmd("kubectl", args...) - cmd := exec.CommandContext(ctx, "kubectl", args...) - cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - return cmd.Run() -} - func (c *Client) RunHelm(ctx context.Context, args ...string) error { if c.namespace != "" && !containsFlag(args, "-n", "--namespace") { args = append(args, "-n", c.namespace) diff --git a/internal/k8s/mock.go b/internal/k8s/mock.go index b594f9e..cc89ca6 100644 --- a/internal/k8s/mock.go +++ b/internal/k8s/mock.go @@ -53,11 +53,6 @@ func (m *MockRunner) RunKubectlQuiet(_ context.Context, args ...string) error { return err } -func (m *MockRunner) RunKubectlPassthrough(_ context.Context, args ...string) error { - _, err := m.respond(m.record(args...)) - return err -} - func (m *MockRunner) RunHelm(_ context.Context, args ...string) error { _, err := m.respond(m.record(append([]string{"helm"}, args...)...)) return err diff --git a/internal/status/status.go b/internal/status/status.go index e1f1bff..ad18b12 100644 --- a/internal/status/status.go +++ b/internal/status/status.go @@ -22,16 +22,6 @@ func Cmd(name string, args ...string) { } } -// ShowEquivalentCmd displays the equivalent openshell CLI command for -// an operation, regardless of how it was actually executed. Use this -// in gRPC Gateway implementations to show the CLI equivalent. -func ShowEquivalentCmd(name string, args ...string) { - if !ShowCommands { - return - } - fmt.Printf(" %s\n", formatCmdLine(name, args)) -} - func formatCmdLine(name string, args []string) string { var b strings.Builder b.WriteString("$ ") @@ -93,10 +83,10 @@ func OKf(format string, a ...any) { fmt.Printf(" ✓ "+format+"\n", a...) } func Fail(msg string) { fmt.Println(" ✗ " + msg) } func Failf(format string, a ...any) { fmt.Printf(" ✗ "+format+"\n", a...) } func Warn(msg string) { fmt.Println(" ! " + msg) } +func Warnf(format string, a ...any) { fmt.Printf(" ! "+format+"\n", a...) } func Info(msg string) { fmt.Println(" - " + msg) } func Infof(format string, a ...any) { fmt.Printf(" - "+format+"\n", a...) } func Detail(msg string) { fmt.Println(" " + msg) } -func Detailf(format string, a ...any){ fmt.Printf(" "+format+"\n", a...) } func Sub(msg string) { fmt.Println(" " + msg) } func Step(n int, msg string) { fmt.Printf("\n=== Step %d: %s ===\n", n, msg) } func Section(title string) { fmt.Printf("\n=== %s ===\n", title) } diff --git a/internal/status/status_test.go b/internal/status/status_test.go index 1a6f421..112a039 100644 --- a/internal/status/status_test.go +++ b/internal/status/status_test.go @@ -139,27 +139,6 @@ func TestCmdShowCommands_RedactsCredentials(t *testing.T) { } } -func TestShowEquivalentCmd_OnlyWhenEnabled(t *testing.T) { - ShowCommands = false - out := captureStdout(func() { - ShowEquivalentCmd("openshell", "sandbox", "list") - }) - if out != "" { - t.Errorf("expected no output when ShowCommands=false, got: %q", out) - } -} - -func TestShowEquivalentCmd_Prints(t *testing.T) { - ShowCommands = true - defer func() { ShowCommands = false }() - out := captureStdout(func() { - ShowEquivalentCmd("openshell", "sandbox", "list") - }) - if !strings.Contains(out, "$ openshell sandbox list") { - t.Errorf("expected equivalent command, got: %q", out) - } -} - func captureStdout(fn func()) string { old := os.Stdout r, w, _ := os.Pipe() diff --git a/main.go b/main.go index 93a2b30..e6b7675 100644 --- a/main.go +++ b/main.go @@ -16,6 +16,15 @@ var version = "dev" //go:embed agents/builtin.yaml var defaultAgentConfig []byte +//go:embed profiles/gateways/local.yaml +var localGatewayProfile []byte + +//go:embed profiles/gateways/kind.yaml +var kindGatewayProfile []byte + +//go:embed profiles/gateways/ocp.yaml +var ocpGatewayProfile []byte + func main() { harnessDir := detectHarnessDir() @@ -44,16 +53,19 @@ func main() { cmd.Version = version cmd.DefaultAgentConfig = defaultAgentConfig + cmd.EmbeddedGatewayProfiles = map[string][]byte{ + "local": localGatewayProfile, + "kind": kindGatewayProfile, + "ocp": ocpGatewayProfile, + } root.CompletionOptions.HiddenDefaultCmd = true root.AddCommand( cmd.NewUpCmd(harnessDir, cli), cmd.NewCreateCmd(harnessDir, cli), - cmd.NewConnectCmd(cli), cmd.NewDeployCmd(harnessDir, cli), cmd.NewTeardownCmd(harnessDir, cli), cmd.NewStatusCmd(harnessDir, cli), - cmd.NewLogsCmd(harnessDir, cli), cmd.NewStopCmd(harnessDir, cli), cmd.NewStartCmd(harnessDir, cli), ) diff --git a/gateways/kind/gateway.yaml b/profiles/gateways/kind.yaml similarity index 70% rename from gateways/kind/gateway.yaml rename to profiles/gateways/kind.yaml index 730a4f5..a69a2e5 100644 --- a/gateways/kind/gateway.yaml +++ b/profiles/gateways/kind.yaml @@ -16,14 +16,16 @@ gateway: name: openshell-kind mode: direct -providers: - enabled: [github, vertex-local, atlassian, gws] - chart: - oci: oci://ghcr.io/nvidia/openshell/helm-chart version: "0.0.59" - crd: - url: https://github.com/kubernetes-sigs/agent-sandbox/releases/latest/download/manifest.yaml helm: - values: values.yaml + values: + service: + type: NodePort + server: + disableTls: true + auth: + allowUnauthenticatedUsers: true + pkiInitJob: + enabled: true diff --git a/gateways/local/gateway.yaml b/profiles/gateways/local.yaml similarity index 66% rename from gateways/local/gateway.yaml rename to profiles/gateways/local.yaml index ab0494f..26763b1 100644 --- a/gateways/local/gateway.yaml +++ b/profiles/gateways/local.yaml @@ -3,12 +3,6 @@ # The openshell gateway must be installed and running: # macOS: brew install openshell && brew services start openshell # Linux: curl -LsSf https://raw.githubusercontent.com/NVIDIA/OpenShell/main/install.sh | sh -# -# The harness verifies the gateway is reachable but does not manage its lifecycle. gateway: type: local - -providers: - enabled: [github, vertex-local, atlassian] - custom: [gws] diff --git a/profiles/gateways/ocp.yaml b/profiles/gateways/ocp.yaml new file mode 100644 index 0000000..4806ffd --- /dev/null +++ b/profiles/gateways/ocp.yaml @@ -0,0 +1,53 @@ +# OpenShift gateway — deploys openshell to an OCP cluster. +# +# Prerequisites: +# - oc login to the target cluster +# - KUBECONFIG set or default context pointing to OCP + +gateway: + type: remote + platform: ocp + service: route + name: openshell-remote-ocp + +chart: + version: "0.0.59" + +helm: + values: + image: + pullPolicy: Always + supervisor: + image: + pullPolicy: Always + securityContext: + runAsUser: null + runAsNonRoot: null + server: + sandboxImagePullPolicy: Always + auth: + allowUnauthenticatedUsers: true + pkiInitJob: + enabled: true + +addons: + manifests: + - apiVersion: route.openshift.io/v1 + kind: Route + metadata: + name: gateway + spec: + tls: + termination: passthrough + to: + kind: Service + name: openshell + port: + targetPort: grpc + +ocp: + scc-privileged: [openshell, openshell-sandbox] + scc-anyuid: [openshell] + +secrets: + mtls: openshell-client-tls diff --git a/agents/providers/profiles/atlassian-user.yaml b/profiles/providers/atlassian-user.yaml similarity index 100% rename from agents/providers/profiles/atlassian-user.yaml rename to profiles/providers/atlassian-user.yaml diff --git a/agents/providers/profiles/atlassian.yaml b/profiles/providers/atlassian.yaml similarity index 100% rename from agents/providers/profiles/atlassian.yaml rename to profiles/providers/atlassian.yaml diff --git a/agents/providers/profiles/gws.yaml b/profiles/providers/gws.yaml similarity index 100% rename from agents/providers/profiles/gws.yaml rename to profiles/providers/gws.yaml diff --git a/test/test-flow.sh b/test/test-flow.sh index c5e333d..01cb0f5 100755 --- a/test/test-flow.sh +++ b/test/test-flow.sh @@ -52,10 +52,21 @@ if [[ -z "$TARGET" ]]; then exit 1 fi +HARNESS_FLAGS=(--verbose) if $DEBUG; then - HARNESS="$HARNESS --show-commands" + HARNESS_FLAGS+=(--show-commands) fi +LOG_FILE="${TEST_LOG_FILE:-}" +if [[ -n "$LOG_FILE" ]]; then + mkdir -p "$(dirname "$LOG_FILE")" + exec > >(tee -a "$LOG_FILE") 2>&1 +fi + +harness() { + "$HARNESS" "${HARNESS_FLAGS[@]}" "$@" +} + # ── Helpers ────────────────────────────────────────────────────────── strip_ansi() { @@ -69,33 +80,21 @@ TOTAL_START=$(date +%s) step() { local label="$1"; shift local start=$(date +%s) - if $DEBUG; then - if "$@"; then - local elapsed=$(( $(date +%s) - start )) - printf " ✓ %-35s (%ds)\n" "$label" "$elapsed" - ((PASS++)) - else - local elapsed=$(( $(date +%s) - start )) - printf " ✗ %-35s (%ds)\n" "$label" "$elapsed" - ((FAIL++)) - fi + if "$@"; then + local elapsed=$(( $(date +%s) - start )) + printf " ✓ %-35s (%ds)\n" "$label" "$elapsed" + ((PASS++)) else - if "$@" &>/dev/null; then - local elapsed=$(( $(date +%s) - start )) - printf " ✓ %-35s (%ds)\n" "$label" "$elapsed" - ((PASS++)) - else - local elapsed=$(( $(date +%s) - start )) - printf " ✗ %-35s (%ds)\n" "$label" "$elapsed" - ((FAIL++)) - fi + local elapsed=$(( $(date +%s) - start )) + printf " ✗ %-35s (%ds)\n" "$label" "$elapsed" + ((FAIL++)) fi } step_fail() { local label="$1"; shift local start=$(date +%s) - if ! "$@" &>/dev/null; then + if ! "$@"; then local elapsed=$(( $(date +%s) - start )) printf " ✓ %-35s (expected failure, %ds)\n" "$label" "$elapsed" ((PASS++)) @@ -106,23 +105,6 @@ step_fail() { fi } -step_output() { - local label="$1"; shift - local start=$(date +%s) - local out - out=$("$@" 2>&1) - local rc=$? - local elapsed=$(( $(date +%s) - start )) - if [[ $rc -eq 0 ]]; then - printf " ✓ %-35s (%ds)\n" "$label" "$elapsed" - ((PASS++)) - else - printf " ✗ %-35s (%ds)\n" "$label" "$elapsed" - echo " $out" | head -3 - ((FAIL++)) - fi -} - check_providers() { local count count=$("$CLI" provider list 2>/dev/null | awk 'NR>1' | wc -l | tr -d ' ') @@ -176,7 +158,7 @@ sandbox_verify() { step "sandbox: gws token placeholder" "$CLI" sandbox exec --name "$name" -- bash -c 'echo "$GOOGLE_WORKSPACE_CLI_TOKEN" | grep -q "openshell:resolve:env"' step "sandbox: gws api call" "$CLI" sandbox exec --name "$name" -- bash -c 'for i in 1 2 3; do curl -sf https://gmail.googleapis.com/gmail/v1/users/me/profile -H "Authorization: Bearer $GOOGLE_WORKSPACE_CLI_TOKEN" -o /dev/null && exit 0; sleep 3; done; exit 1' step "sandbox: mcp config" "$CLI" sandbox exec --name "$name" -- test -f /sandbox/.mcp.json - step_output "sandbox: claude responds" "$CLI" sandbox exec --name "$name" -- bash -c 'echo "respond with ok" | claude --bare --print 2>&1 | head -1' + step "sandbox: claude responds" "$CLI" sandbox exec --name "$name" -- bash -c 'echo "respond with ok" | claude --bare --print 2>&1 | head -1' } summary() { @@ -195,14 +177,14 @@ summary() { test_errors() { echo "=== test: error scenarios ===" - step_fail "nonexistent profile" "$HARNESS" up --local --agent nonexistent --no-tty + step_fail "nonexistent profile" harness up --gateway local --agent nonexistent --no-tty if $REUSE_GATEWAY; then - step "teardown (first)" "$HARNESS" teardown --sandboxes --providers - step "teardown (second)" "$HARNESS" teardown --sandboxes --providers + step "teardown (first)" harness teardown --sandboxes --providers + step "teardown (second)" harness teardown --sandboxes --providers else - step "teardown (first)" "$HARNESS" teardown --sandboxes --providers --k8s - step "teardown (second)" "$HARNESS" teardown --sandboxes --providers --k8s + step "teardown (first)" harness teardown --sandboxes --providers --k8s + step "teardown (second)" harness teardown --sandboxes --providers --k8s fi echo "" @@ -215,30 +197,30 @@ test_local() { $NO_PROVIDERS && mode="$mode, no-providers" echo "=== test-flow: local ($mode) ===" - step "teardown" "$HARNESS" teardown --sandboxes --providers - step "deploy" "$HARNESS" deploy --local + step "teardown" harness teardown --sandboxes --providers + step "deploy" harness deploy local step "gateway reachable" "$CLI" inference get # up auto-registers providers when missing local sandbox_name="test-agent" - step_output "sandbox create (up)" "$HARNESS" up --local --name "$sandbox_name" --agent "$PROFILE" --no-tty + step "sandbox create (up)" harness up --gateway local --name "$sandbox_name" --agent "$PROFILE" --no-tty sandbox_verify "$sandbox_name" step "sandbox delete" "$CLI" sandbox delete "$sandbox_name" local create_name="test-create" - step_output "sandbox create (create)" "$HARNESS" create --name "$create_name" --agent ci + step "sandbox create (create)" harness create --name "$create_name" --agent ci step "sandbox verify (create)" "$CLI" sandbox exec --name "$create_name" -- echo "hello" step "sandbox delete (create)" "$CLI" sandbox delete "$create_name" if ! $NO_PROVIDERS; then echo "" echo "=== test: missing providers ===" - step "teardown providers" "$HARNESS" teardown --providers - step_output "up with no providers" "$HARNESS" up --local --name test-noprov --no-tty - step "cleanup" "$HARNESS" teardown --sandboxes + step "teardown providers" harness teardown --providers + step "up with no providers" harness up --gateway local --name test-noprov --no-tty + step "cleanup" harness teardown --sandboxes fi - step "teardown (clean)" "$HARNESS" teardown --sandboxes --providers + step "teardown (clean)" harness teardown --sandboxes --providers } # ── GWS lifecycle test ─────────────────────────────────────────────── @@ -275,12 +257,12 @@ test_kind() { return fi - step "teardown" "$HARNESS" teardown --sandboxes --providers --k8s - step "deploy" "$HARNESS" deploy kind + step "teardown" harness teardown --sandboxes --providers --k8s + step "deploy" harness deploy kind step "gateway reachable" "$CLI" inference get local sandbox_name="test-kind" - step_output "sandbox create" "$HARNESS" up --name "$sandbox_name" --agent "$PROFILE" --no-tty + step "sandbox create" harness up --gateway kind --name "$sandbox_name" --agent "$PROFILE" --no-tty sandbox_verify "$sandbox_name" if ! $NO_PROVIDERS; then @@ -289,7 +271,7 @@ test_kind() { step "sandbox delete" "$CLI" sandbox delete "$sandbox_name" - step "teardown (clean)" "$HARNESS" teardown --sandboxes --providers --k8s + step "teardown (clean)" harness teardown --sandboxes --providers --k8s echo "" } @@ -304,33 +286,33 @@ test_ocp() { OCP_GW=$("$CLI" gateway list 2>/dev/null | strip_ansi | awk '/-remote-/ {gsub(/^\*/, ""); print $1; exit}') [[ -n "$OCP_GW" ]] && "$CLI" gateway select "$OCP_GW" 2>/dev/null || true - step "teardown sandboxes+providers" "$HARNESS" teardown --sandboxes --providers + step "teardown sandboxes+providers" harness teardown --sandboxes --providers if ! "$CLI" inference get &>/dev/null; then - step "deploy" "$HARNESS" deploy --remote + step "deploy" harness deploy ocp else step "gateway reachable" "$CLI" inference get fi else - step "teardown" "$HARNESS" teardown --sandboxes --providers --k8s - step "deploy" "$HARNESS" deploy --remote + step "teardown" harness teardown --sandboxes --providers --k8s + step "deploy" harness deploy ocp fi local sandbox_name if $NO_PROVIDERS; then sandbox_name="test-ocp" - step_output "sandbox create" "$HARNESS" create --agent=ci --name "$sandbox_name" + step "sandbox create" harness create --agent=ci --name "$sandbox_name" else sandbox_name="agent" - step_output "sandbox create (up)" "$HARNESS" up --remote --name "$sandbox_name" --no-tty + step "sandbox create (up)" harness up --gateway ocp --name "$sandbox_name" --no-tty fi sandbox_verify "$sandbox_name" step "sandbox delete" "$CLI" sandbox delete "$sandbox_name" if $REUSE_GATEWAY; then - step "teardown (sandboxes+providers)" "$HARNESS" teardown --sandboxes --providers + step "teardown (sandboxes+providers)" harness teardown --sandboxes --providers else - step "teardown (clean)" "$HARNESS" teardown --sandboxes --providers --k8s + step "teardown (clean)" harness teardown --sandboxes --providers --k8s fi }