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
95 changes: 55 additions & 40 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,46 +2,22 @@

## Migration Status

| Command | Go Status | Bash stays? | Notes |
|---------|-----------|-------------|-------|
| `new --local` | Native | Reference only | Profile parsing, provider validation, sandbox create with retry |
| `new --remote` | Bash wrapper | Yes | K8s Job YAML generation, kubectl apply |
| `connect` | Native | Reference only | exec into `openshell sandbox connect` |
| `deploy --local` | Native | Reference only | Podman check, gateway find/select/verify |
| `deploy --remote` | Bash wrapper | Yes | Helm install, route, mTLS, RBAC, SCCs |
| `teardown --sandboxes` | Native | Reference only | SandboxList + SandboxDelete via Gateway |
| `teardown --providers` | Native | Reference only | ProviderList + ProviderDelete + InferenceRemove |
| `teardown --k8s` | Bash wrapper | Yes | Helm uninstall, CRDs, SCCs, secrets, namespace |
| `preflight` | Native | Reference only | All 29 bats tests pass against Go |
| `providers` | Native | Reference only | Eliminates jq dependency |
| `test` | Bash wrapper | Yes | test-flow.sh orchestration |
| **Launcher** | Native | Reference only | In-cluster Go binary, UBI9 + openssh |

**Score: 8/12 paths native Go.** Remaining 4 are kubectl/helm-heavy operations.

## Remaining Bash Wrappers

### `new --remote` — Medium lift
- Generates K8s Job YAML inline (ConfigMap, volumes, env vars)
- Applies via `kubectl apply`, watches Job, tails logs
- Could use Go `text/template` for YAML + `k8s.io/client-go` or `os/exec kubectl`
- Prerequisite: decide whether to depend on client-go or keep shelling out to kubectl

### `deploy --remote` — Largest lift
- ~160 lines: namespace, CRDs, SCCs (oc adm), Helm install, route, mTLS cert copy
- Uses: kubectl, helm, oc (OpenShift-specific)
- Consider: keep as bash or accept client-go + helm SDK dependency
- Low priority — rarely changes, works reliably

### `teardown --k8s` — Medium lift
- Helm uninstall, delete CRDs/SCCs/secrets/namespace, gateway config cleanup
- Mirror of deploy --remote in reverse
- Port together with deploy --remote or not at all

### `test` — Low priority
- test-flow.sh is a bash test harness, not a harness subcommand
- Porting to Go would mean rewriting the test framework
- No value — bash is the right tool for test orchestration
| Command | Go Status | Notes |
|---------|-----------|-------|
| `new --local` | Native | Profile parsing, provider validation, sandbox create with retry |
| `new --remote` | Native | K8s Job YAML via internal/k8s, prerequisite chain (deploy+providers+creds) |
| `connect` | Native | exec into `openshell sandbox connect` |
| `deploy --local` | Native | Podman check, gateway find/select/verify |
| `deploy --remote` | Native | Helm install, Route, mTLS, RBAC, SCCs via internal/k8s |
| `teardown --sandboxes` | Native | SandboxList + SandboxDelete via Gateway |
| `teardown --providers` | Native | ProviderList + ProviderDelete + InferenceRemove |
| `teardown --k8s` | Native | Helm uninstall, CRDs, SCCs, secrets, namespace via internal/k8s |
| `preflight` | Native | All 29 bats tests pass against Go |
| `providers` | Native | Eliminates jq dependency |
| `test` | Bash | test-flow.sh orchestration (intentionally stays bash) |
| **Launcher** | Native | In-cluster Go binary, UBI9 + openssh |

**Score: 11/12 paths native Go.** Only `test` stays bash (test orchestration, not a user command).

## Architecture Improvements

Expand All @@ -64,6 +40,45 @@
- Can't share code (different execution context, separate binary)
- Consider: extract shared types to a third module, or accept duplication

## Profile Schema — Cross-Project Alignment

Analysis of field naming and structure against OpenShell provider profiles (#896)
and Kaiden projects (#1272). See `profile.md` for full comparison.

### Current schema (Config struct)

```
name, image, command, keep, providers, [env]
```

### Changes

- [ ] Add `description` field — one line of human-readable context per profile.
Makes multi-profile use cases (`harness new --profile research`) self-documenting.
Both OpenShell and Kaiden include this.

- [ ] Split `[env]` by purpose — the current `[env]` map mixes inference config
(`ANTHROPIC_*`), agent config (`CLAUDE_CODE_*`), provider metadata
(`JIRA_URL`, `JIRA_USERNAME`), and custom provider workaround paths
(`GOOGLE_WORKSPACE_*`). At minimum, add grouping comments in `default.toml`.
Longer term, consider a `[provider-config]` section for non-secret provider
metadata that belongs with the provider, not the sandbox. The `ANTHROPIC_*`
vars should eventually drop entirely when OpenShell inference automation ships.

### No changes needed

- **`name`** — correct term, maps to `openshell sandbox create --name`
- **`image`** — harness-openshell differentiator (Kaiden is folder-first, we're image-first)
- **`command`** — maps to `openshell sandbox create --command`
- **`providers`** — correct term, matches OpenShell's provider naming throughout
- **`keep`** — unique to harness-openshell lifecycle, neither upstream has it, that's fine
- **TOML format** — trivially convertible to YAML (OpenShell) or JSON (Kaiden), no reason to change

### Future fields (not now)

- `repo` — git URL to clone into the sandbox at start (Kaiden supports this via `folder`)
- `secrets` — non-provider secrets to inject, cleaner than stuffing credentials into `[env]`

## Low-Priority Cleanup (from audit)

- [ ] `copyFile` in launcher: check Close error (`defer out.Close()` discards it)
Expand Down
33 changes: 16 additions & 17 deletions cmd/creds.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"path/filepath"

"github.com/robbycochran/harness-openshell/internal/k8s"
"github.com/robbycochran/harness-openshell/internal/status"
)

func ensureCreds(namespace string, force bool) error {
Expand All @@ -19,35 +20,33 @@ func ensureCreds(namespace string, force bool) error {
return fmt.Errorf("namespace '%s' not found — run: harness deploy --remote", namespace)
}

fmt.Println("=== Setting up cluster credentials ===")
fmt.Printf(" Namespace: %s\n", namespace)
fmt.Println()
status.Section("Setting up cluster credentials")
status.Detailf("Namespace: %s", namespace)

// GWS credentials
fmt.Println("=== GWS ===")
status.Section("GWS")
if force && kc.SecretExists(ctx, "openshell-gws") {
kc.RunKubectl(ctx, "delete", "secret", "openshell-gws")
fmt.Println(" Deleted existing secret.")
status.Info("Deleted existing secret")
}

if kc.SecretExists(ctx, "openshell-gws") {
fmt.Println(" openshell-gws: exists (use --force to recreate)")
status.Info("openshell-gws: exists (use --force to recreate)")
} else {
if err := createGWSSecret(ctx, kc); err != nil {
fmt.Printf(" GWS: %v\n", err)
status.Failf("GWS: %v", err)
}
}

// Atlassian credentials
fmt.Println()
fmt.Println("=== Atlassian ===")
status.Section("Atlassian")
if force && kc.SecretExists(ctx, "openshell-atlassian") {
kc.RunKubectl(ctx, "delete", "secret", "openshell-atlassian")
fmt.Println(" Deleted existing secret.")
status.Info("Deleted existing secret")
}

if kc.SecretExists(ctx, "openshell-atlassian") {
fmt.Println(" openshell-atlassian: exists (use --force to recreate)")
status.Info("openshell-atlassian: exists (use --force to recreate)")
} else if jiraURL := os.Getenv("JIRA_URL"); jiraURL != "" {
jiraUser := os.Getenv("JIRA_USERNAME")
_, err := kc.RunKubectl(ctx, "create", "secret", "generic", "openshell-atlassian",
Expand All @@ -56,9 +55,9 @@ func ensureCreds(namespace string, force bool) error {
if err != nil {
return fmt.Errorf("creating atlassian secret: %w", err)
}
fmt.Printf(" openshell-atlassian: created (%s)\n", jiraURL)
status.OKf("openshell-atlassian: created (%s)", jiraURL)
} else {
fmt.Println(" Atlassian: JIRA_URL not set (skipping)")
status.Info("Atlassian: JIRA_URL not set (skipping)")
}

return nil
Expand All @@ -67,15 +66,15 @@ func ensureCreds(namespace string, force bool) error {
func createGWSSecret(ctx context.Context, kc *k8s.Client) error {
gwsPath, err := exec.LookPath("gws")
if err != nil {
fmt.Println(" GWS: not installed (skipping)")
status.Info("GWS: not installed (skipping)")
return nil
}

check := exec.Command(gwsPath, "auth", "status")
check.Stdout = io.Discard
check.Stderr = io.Discard
if check.Run() != nil {
fmt.Println(" GWS: not authenticated (run 'gws auth login')")
status.Info("GWS: not authenticated (run 'gws auth login')")
return nil
}

Expand All @@ -88,7 +87,7 @@ func createGWSSecret(ctx context.Context, kc *k8s.Client) error {
credFile := filepath.Join(tmpDir, "credentials.json")
out, err := exec.Command(gwsPath, "auth", "export", "--unmasked").Output()
if err != nil {
fmt.Println(" GWS: export failed (skipping)")
status.Info("GWS: export failed (skipping)")
return nil
}
if err := os.WriteFile(credFile, out, 0o600); err != nil {
Expand All @@ -111,6 +110,6 @@ func createGWSSecret(ctx context.Context, kc *k8s.Client) error {
if _, err := kc.RunKubectl(ctx, args...); err != nil {
return fmt.Errorf("creating gws secret: %w", err)
}
fmt.Println(" openshell-gws: created")
status.OK("openshell-gws: created")
return nil
}
27 changes: 12 additions & 15 deletions cmd/deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,7 @@ func NewDeployCmd(harnessDir, cli string) *cobra.Command {

func deployRemote(harnessDir string, gw gateway.Gateway, kubeconfig string) error {
ctx := context.Background()
namespace := os.Getenv("OPENSHELL_NAMESPACE")
if namespace == "" {
namespace = "openshell"
}
namespace := k8s.DefaultNamespace()

chartVersion := os.Getenv("OPENSHELL_CHART_VERSION")
if chartVersion == "" {
Expand All @@ -75,7 +72,7 @@ func deployRemote(harnessDir string, gw gateway.Gateway, kubeconfig string) erro
kcNoNS := k8s.New(kubeconfig, "")

// Step 1: Namespace (idempotent — ignore AlreadyExists)
fmt.Println("=== Step 1: Creating namespace ===")
status.Step(1, "Creating namespace")
kcNoNS.RunKubectl(ctx, "create", "ns", namespace)
if _, err := kcNoNS.RunKubectl(ctx, "label", "ns", namespace,
"pod-security.kubernetes.io/enforce=privileged",
Expand All @@ -85,14 +82,14 @@ func deployRemote(harnessDir string, gw gateway.Gateway, kubeconfig string) erro
}

// Step 2: Sandbox CRD
fmt.Println("=== Step 2: Installing Sandbox CRD ===")
status.Step(2, "Installing Sandbox CRD")
if err := kcNoNS.RunKubectlPassthrough(ctx, "apply", "-f",
"https://github.com/kubernetes-sigs/agent-sandbox/releases/latest/download/manifest.yaml"); err != nil {
return fmt.Errorf("installing sandbox CRD: %w", err)
}

// Step 3: OpenShift SCCs (best-effort — oc may not exist on non-OpenShift)
fmt.Println("=== Step 3: Granting OpenShift SCCs ===")
status.Step(3, "Granting OpenShift SCCs")
for _, sa := range []string{"openshell", "openshell-sandbox", "default"} {
kc.RunOC(ctx, "adm", "policy", "add-scc-to-user", "privileged", "-z", sa, "-n", namespace)
}
Expand Down Expand Up @@ -138,7 +135,7 @@ func deployRemote(harnessDir string, gw gateway.Gateway, kubeconfig string) erro
}

// Step 4: Helm install
fmt.Println("=== Step 4: Deploying gateway via Helm ===")
status.Step(4, "Deploying gateway via Helm")
sandboxImage := os.Getenv("SANDBOX_IMAGE")
if sandboxImage == "" {
sandboxImage = "quay.io/rcochran/openshell:sandbox"
Expand Down Expand Up @@ -168,13 +165,13 @@ func deployRemote(harnessDir string, gw gateway.Gateway, kubeconfig string) erro
}

// Wait for gateway
fmt.Println("=== Waiting for gateway ===")
status.Section("Waiting for gateway")
if err := kc.RunKubectlPassthrough(ctx, "rollout", "status", "statefulset/openshell", "--timeout=300s"); err != nil {
return fmt.Errorf("gateway rollout failed: %w", err)
}

// Step 5: Route
fmt.Println("=== Step 5: Creating OpenShift route ===")
status.Step(5, "Creating OpenShift route")
if err := kc.RunKubectlQuiet(ctx, "get", "route", "gateway"); err != nil {
kc.ApplyYAML(ctx, map[string]any{
"apiVersion": "route.openshift.io/v1",
Expand All @@ -190,7 +187,7 @@ func deployRemote(harnessDir string, gw gateway.Gateway, kubeconfig string) erro
fmt.Printf(" Route: %s\n", routeHost)

// Step 6: CLI gateway config
fmt.Println("=== Step 6: Configuring CLI gateway ===")
status.Step(6, "Configuring CLI gateway")
gatewayName := os.Getenv("GATEWAY_NAME")
if gatewayName == "" {
gatewayName = "openshell-remote-ocp"
Expand Down Expand Up @@ -229,18 +226,18 @@ func deployRemote(harnessDir string, gw gateway.Gateway, kubeconfig string) erro
fmt.Print(" Waiting for gateway...")
for i := range 30 {
if gw.InferenceGet() == nil {
fmt.Println(" ✓ reachable")
status.OK("reachable")
break
}
time.Sleep(2 * time.Second)
fmt.Print(".")
if i == 29 {
fmt.Println(" ✗ timed out (try: openshell inference get)")
status.Fail("timed out (try: openshell inference get)")
}
}

fmt.Println()
fmt.Println("Done.")
status.Done("Done.")
return nil
}

Expand Down Expand Up @@ -298,6 +295,6 @@ func deployLocal(gw gateway.Gateway) error {
}

fmt.Println()
fmt.Println("Done.")
status.Done("Done.")
return nil
}
17 changes: 7 additions & 10 deletions cmd/new.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,14 +77,11 @@ type newLocalOpts struct {

func newRemote(harnessDir string, gw gateway.Gateway, profileName, sandboxName string) error {
ctx := context.Background()
namespace := os.Getenv("OPENSHELL_NAMESPACE")
if namespace == "" {
namespace = "openshell"
}
namespace := k8s.DefaultNamespace()

// 1. Ensure gateway
if err := gw.InferenceGet(); err != nil {
fmt.Println("=== Deploying gateway ===")
status.Section("Deploying gateway")
if err := deployRemote(harnessDir, gw, ""); err != nil {
return fmt.Errorf("deploy failed: %w", err)
}
Expand All @@ -93,7 +90,7 @@ func newRemote(harnessDir string, gw gateway.Gateway, profileName, sandboxName s
// 2. Ensure providers
providers, _ := gw.ProviderList()
if len(providers) == 0 {
fmt.Println("\n=== Registering providers ===")
status.Section("Registering providers")
if err := registerProviders(harnessDir, gw, false); err != nil {
return fmt.Errorf("provider registration failed: %w", err)
}
Expand Down Expand Up @@ -193,7 +190,7 @@ func newRemote(harnessDir string, gw gateway.Gateway, profileName, sandboxName s

// 5. Wait for launcher pod
fmt.Println()
fmt.Println("Waiting for launcher...")
status.Info("Waiting for launcher...")
kc.RunKubectl(ctx, "wait", "--for=condition=ready", "pod",
"-l", "job-name="+jobName, "--timeout=120s")

Expand Down Expand Up @@ -223,7 +220,7 @@ func newRemote(harnessDir string, gw gateway.Gateway, profileName, sandboxName s

fmt.Println()
if jobStatus == "Complete" || jobStatus == "SuccessCriteriaMet" {
fmt.Printf("Sandbox ready. Connect with: harness connect %s\n", cfg.Name)
status.OKf("Sandbox ready. Connect with: harness connect %s", cfg.Name)
return nil
}
if jobStatus == "" {
Expand All @@ -250,7 +247,7 @@ func newLocal(opts newLocalOpts) error {
// 2. Ensure providers
providers, _ := gw.ProviderList()
if len(providers) == 0 {
fmt.Println("\n=== Registering providers ===")
status.Section("Registering providers")
if err := opts.runScript("providers.sh"); err != nil {
return fmt.Errorf("provider registration failed: %w", err)
}
Expand Down Expand Up @@ -281,7 +278,7 @@ func newLocal(opts newLocalOpts) error {
}
if len(missing) > 0 && len(registered) == 0 {
fmt.Println()
fmt.Println("WARNING: no providers available. Run: harness providers")
status.Warn("no providers available — run: harness providers")
}

// 5. Stage files
Expand Down
Loading