Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 46 additions & 11 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,13 @@ If `--name` is not provided, the sandbox name comes from the profile's `name` fi

Reconnect to a running sandbox via `openshell sandbox connect`.

### `harness deploy [--local|--remote]`
### `harness deploy --local|--remote [--kubeconfig PATH]`

Deploy or verify the gateway without creating a sandbox.
Deploy or verify the gateway without creating a sandbox. Requires `--local` or `--remote`.

**Local:** Find a gateway with endpoint `127.0.0.1`, select it, verify it responds.
**Local:** Check podman installed, find a gateway with endpoint `127.0.0.1`, select it, verify it responds.

**Remote:** Create namespace, install CRDs, grant SCCs, deploy Helm chart from OCI registry (`oci://ghcr.io/nvidia/openshell/helm-chart`), create TLS passthrough route, register CLI gateway with mTLS certs from the cluster.
**Remote:** Create namespace, install CRDs, grant SCCs, deploy Helm chart from OCI registry (`oci://ghcr.io/nvidia/openshell/helm-chart`), create TLS passthrough route, register CLI gateway with mTLS certs from the cluster. `--kubeconfig` sets the kubeconfig path (or set `KUBECONFIG` env var).

### `harness teardown [--sandboxes] [--providers] [--k8s]`

Expand All @@ -51,13 +51,29 @@ Tear down resources. Default (no flags) tears down everything applicable.
- `--providers` — delete all providers and inference config (requires no running sandboxes)
- `--k8s` — Helm uninstall, delete CRDs, SCCs, secrets, namespace, and gateway config

### `harness preflight`
### `harness preflight [--strict]`

Read-only environment check. Validates all inputs defined in `providers.toml` for each enabled provider in `openshell.toml`. Reports per-input status with `✓`/`✗` prefixes.

### `harness providers`
With `--strict`, exits non-zero if any `required` provider has missing inputs.

Register credential providers with the gateway. Reads provider types and credentials from environment variables. Supports `--force` to delete and recreate all providers.
Subcommands:
- `harness preflight available` — print space-separated names of openshell-type providers where all inputs pass
- `harness preflight names` — print space-separated names of all enabled openshell-type providers

### `harness providers [--force]`

Register credential providers with the gateway:

1. Enables providers v2 via `openshell settings set`
2. Imports custom provider profiles from `sandbox/profiles/`
3. Registers each provider (github, vertex-local, atlassian) if the required env vars are set:
- `github` — requires `GITHUB_TOKEN`
- `vertex-local` — requires ADC file + project ID (`ANTHROPIC_VERTEX_PROJECT_ID` or fallback from ADC's `quota_project_id`). Sets inference model from `OPENSHELL_MODEL` (default: `claude-sonnet-4-6`).
- `atlassian` — requires `JIRA_API_TOKEN`
4. Skips providers that already exist

With `--force`: deletes existing providers and custom profiles before recreating. Requires no running sandboxes.

### `harness test [podman|ocp|all] [--full]`

Expand Down Expand Up @@ -230,11 +246,22 @@ The launcher connects to the gateway at `https://openshell.openshell.svc.cluster

---

## Environment Variables

| Variable | Default | Description |
|----------|---------|-------------|
| `OPENSHELL_CLI` | `openshell` | Path or name of the openshell CLI binary |
| `OPENSHELL_MODEL` | `claude-sonnet-4-6` | Inference model for `harness providers` |
| `HARNESS_DIR` | auto-detected | Root directory of the harness project |
| `OPENSHELL_NAMESPACE` | `openshell` | Kubernetes namespace for OCP deployments |

---

## Testing

### Unit Tests (`test/preflight.bats`)
### Bats Tests (`test/preflight.bats`)

29 bats tests covering the preflight check engine (`lib/providers.py`). Uses stubbed CLI, isolated temp dirs, and no gateway dependency. Tests:
29 bats tests covering the preflight check engine. Runs against both the Python (`lib/providers.py`) and Go (`harness preflight`) implementations via `USE_GO=true`. Uses stubbed CLI, isolated temp dirs, and no gateway dependency. Tests:
- env inputs (set/missing/secret/masked)
- file inputs (exists/missing/metadata extraction)
- check inputs (pass/fail/env expansion)
Expand All @@ -243,10 +270,18 @@ The launcher connects to the gateway at `https://openshell.openshell.svc.cluster
- CLI detection (present/missing)
- Gateway detection (podman/k8s)

### Go Unit Tests

- `internal/gateway/cli_test.go` — stub-based tests for CLI output parsing and argument building
- `internal/profile/profile_test.go` — TOML parsing, env generation, provider validation with mock gateway
- `cmd/new_test.go` — orchestration tests: no gateway, missing providers, retry logic, create opts
- `sandbox/launcher/main_test.go` — launcher config parsing and file staging

### Integration Tests (`test/test-flow.sh`)

End-to-end validation requiring a live gateway:
End-to-end validation requiring a live gateway. Supports `--go` flag to test the Go binary instead of bash scripts:
- Quick mode: deploy → providers → gateway check → teardown
- Full mode: + sandbox create → verify env/GWS/MCP/Claude → delete → teardown
- Error scenarios: bad profile, teardown idempotency, missing providers
- Targets: `podman`, `ocp`, `all`
- Strips ANSI codes from CLI output for reliable parsing
- Test matrix: `{bash, go}` × `{podman, ocp}` via `make validate`
16 changes: 12 additions & 4 deletions cmd/deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,21 @@ import (

func NewDeployCmd(harnessDir, cli string) *cobra.Command {
var (
local bool
remote bool
local bool
remote bool
kubeconfig string
)

cmd := &cobra.Command{
Use: "deploy [--local|--remote]",
Short: "Deploy or verify the gateway",
RunE: func(cmd *cobra.Command, args []string) error {
if remote {
return runner.RunScript(harnessDir, "deploy.sh", "--remote")
scriptArgs := []string{"--remote"}
if kubeconfig != "" {
scriptArgs = append(scriptArgs, "--kubeconfig", kubeconfig)
}
return runner.RunScript(harnessDir, "deploy.sh", scriptArgs...)
}
if local {
gw := gateway.NewCLI(cli)
Expand All @@ -34,6 +39,7 @@ func NewDeployCmd(harnessDir, cli string) *cobra.Command {

cmd.Flags().BoolVar(&local, "local", false, "Verify local podman gateway")
cmd.Flags().BoolVar(&remote, "remote", false, "Deploy to OpenShift cluster")
cmd.Flags().StringVar(&kubeconfig, "kubeconfig", "", "Path to kubeconfig (remote only)")

return cmd
}
Expand Down Expand Up @@ -79,7 +85,9 @@ func deployLocal(gw gateway.Gateway) error {
return fmt.Errorf("no local gateway")
}

gw.GatewaySelect(localGW)
if err := gw.GatewaySelect(localGW); err != nil {
return fmt.Errorf("selecting gateway %s: %w", localGW, err)
}

if gw.InferenceGet() == nil {
status.OKf("%s (active, reachable)", localGW)
Expand Down
1 change: 1 addition & 0 deletions cmd/new_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ func (m *mockGW) ActiveGateway() string
func (m *mockGW) ProviderCreate(string, string, gateway.ProviderCreateOpts) error { return nil }
func (m *mockGW) ProviderDelete(string) error { return nil }
func (m *mockGW) ProviderProfileImport(string) error { return nil }
func (m *mockGW) ProviderProfileDelete(string) error { return nil }
func (m *mockGW) SettingsSet(string, string) error { return nil }
func (m *mockGW) SandboxList() ([]string, error) { return nil, nil }
func (m *mockGW) SandboxConnect(string) error { return nil }
Expand Down
48 changes: 45 additions & 3 deletions cmd/providers.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
package cmd

import (
"bufio"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"

"github.com/robbycochran/harness-openshell/internal/gateway"
"github.com/robbycochran/harness-openshell/internal/status"
Expand Down Expand Up @@ -43,6 +45,7 @@ func registerProviders(harnessDir string, gw gateway.Gateway, force bool) error
for _, name := range []string{"github", "vertex-local", "atlassian"} {
gw.ProviderDelete(name)
}
deleteCustomProfiles(harnessDir, gw)
fmt.Println("Deleted existing providers.")
}

Expand Down Expand Up @@ -137,16 +140,55 @@ func registerProviders(harnessDir string, gw gateway.Gateway, force bool) error

// Show results
status.Section("Providers")
gw.ProviderList()
names, _ := gw.ProviderList()
for _, n := range names {
status.OK(n)
}

status.Section("Inference")
gw.InferenceGet()
m := gw.InferenceModel()
if m != "" {
status.OKf("Model: %s", m)
}

fmt.Println()
fmt.Println("Done.")
fmt.Println("Done. Launch a sandbox with: harness new --local")
return nil
}

func deleteCustomProfiles(harnessDir string, gw gateway.Gateway) {
profilesDir := filepath.Join(harnessDir, "sandbox", "profiles")
entries, err := os.ReadDir(profilesDir)
if err != nil {
return
}
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".yaml") {
continue
}
id := extractYAMLID(filepath.Join(profilesDir, e.Name()))
if id != "" {
gw.ProviderProfileDelete(id)
}
}
}

func extractYAMLID(path string) string {
f, err := os.Open(path)
if err != nil {
return ""
}
defer f.Close()
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, "id:") {
return strings.TrimSpace(strings.TrimPrefix(line, "id:"))
}
}
return ""
}

func readADCProject(path string) string {
data, err := os.ReadFile(path)
if err != nil {
Expand Down
25 changes: 20 additions & 5 deletions cmd/teardown.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,13 +69,20 @@ func teardownSandboxes(gw gateway.Gateway, activeGW string) {
return
}

names, _ := gw.SandboxList()
names, err := gw.SandboxList()
if err != nil {
status.Fail(fmt.Sprintf("could not list sandboxes: %v", err))
fmt.Println()
return
}
if len(names) == 0 {
status.Info("None running")
} else {
for _, name := range names {
fmt.Printf(" Deleting %s\n", name)
gw.SandboxDelete(name)
if err := gw.SandboxDelete(name); err != nil {
status.Failf("failed to delete %s: %v", name, err)
}
}
}
fmt.Println()
Expand All @@ -89,18 +96,26 @@ func teardownProviders(gw gateway.Gateway, activeGW string) error {
return nil
}

remaining, _ := gw.SandboxList()
remaining, err := gw.SandboxList()
if err != nil {
return fmt.Errorf("could not check for running sandboxes: %w", err)
}
if len(remaining) > 0 {
return fmt.Errorf("cannot delete providers with running sandboxes — run: harness teardown --sandboxes")
}

names, _ := gw.ProviderList()
names, err := gw.ProviderList()
if err != nil {
return fmt.Errorf("could not list providers: %w", err)
}
if len(names) == 0 {
status.Info("None registered")
} else {
for _, name := range names {
fmt.Printf(" Deleting %s\n", name)
gw.ProviderDelete(name)
if err := gw.ProviderDelete(name); err != nil {
status.Failf("failed to delete %s: %v", name, err)
}
}
}

Expand Down
7 changes: 6 additions & 1 deletion internal/gateway/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,10 @@ func (c *CLI) ProviderProfileImport(dir string) error {
return c.silent("provider", "profile", "import", "--from", dir)
}

func (c *CLI) ProviderProfileDelete(id string) error {
return c.silent("provider", "profile", "delete", id)
}

func (c *CLI) ProviderList() ([]string, error) {
out, err := c.output("provider", "list")
if err != nil {
Expand All @@ -107,7 +111,8 @@ func (c *CLI) ProviderList() ([]string, error) {
if i == 0 || strings.TrimSpace(line) == "" {
continue // skip header
}
fields := strings.Fields(line)
cleaned := ansiRE.ReplaceAllString(line, "")
fields := strings.Fields(cleaned)
if len(fields) > 0 {
names = append(names, fields[0])
}
Expand Down
Loading