From 5131b393c75f5bfce1794d3221fe43765588f7a6 Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Fri, 5 Jun 2026 01:19:42 -0700 Subject: [PATCH 1/3] X-Smart-Branch-Parent: main From 9f5f8f6e5256d53621bfe1c64896cec7756483c0 Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Fri, 5 Jun 2026 01:25:11 -0700 Subject: [PATCH 2/3] refactor: inject k8s.Runner for testable orchestration (#25) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract k8s.Runner interface from k8s.Client. Orchestration functions (deployRemote, teardownK8s, ensureCreds, newRemote) now accept Runner instead of constructing clients internally. Add k8s.MockRunner — records calls with prefix-matched responses and errors. Enables unit testing orchestration without a live cluster. Add teardown_test.go with 2 tests: - TestTeardownK8s_NamespaceNotFound: skips all deletes - TestTeardownK8s_FullCleanup: verifies helm uninstall, CRD/SCC/ secret/namespace deletes, gateway cleanup in correct order Validated: unit 82+7, bats 29+29, Go+podman 19/19. Closes #25. Co-Authored-By: Claude Opus 4.6 (1M context) --- cmd/creds.go | 5 +- cmd/deploy.go | 23 ++++---- cmd/new.go | 7 ++- cmd/new_test.go | 33 +++++++---- cmd/teardown.go | 16 +++-- cmd/teardown_test.go | 63 ++++++++++++++++++++ internal/k8s/kubectl.go | 15 +++++ internal/k8s/mock.go | 127 ++++++++++++++++++++++++++++++++++++++++ 8 files changed, 250 insertions(+), 39 deletions(-) create mode 100644 cmd/teardown_test.go create mode 100644 internal/k8s/mock.go diff --git a/cmd/creds.go b/cmd/creds.go index 93805e3..ea10205 100644 --- a/cmd/creds.go +++ b/cmd/creds.go @@ -12,9 +12,8 @@ import ( "github.com/robbycochran/harness-openshell/internal/status" ) -func ensureCreds(namespace string, force bool) error { +func ensureCreds(kc k8s.Runner, namespace string, force bool) error { ctx := context.Background() - kc := k8s.New("", namespace) if !kc.NamespaceExists(ctx, namespace) { return fmt.Errorf("namespace '%s' not found — run: harness deploy --remote", namespace) @@ -63,7 +62,7 @@ func ensureCreds(namespace string, force bool) error { return nil } -func createGWSSecret(ctx context.Context, kc *k8s.Client) error { +func createGWSSecret(ctx context.Context, kc k8s.Runner) error { gwsPath, err := exec.LookPath("gws") if err != nil { status.Info("GWS: not installed (skipping)") diff --git a/cmd/deploy.go b/cmd/deploy.go index ba67d0a..1bdd450 100644 --- a/cmd/deploy.go +++ b/cmd/deploy.go @@ -29,7 +29,9 @@ func NewDeployCmd(harnessDir, cli string) *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { if remote { gw := gateway.NewCLI(cli) - return deployRemote(harnessDir, gw, kubeconfig) + kc := k8s.New(kubeconfig, k8s.DefaultNamespace()) + clusterRunner := k8s.New(kubeconfig, "") + return deployRemote(harnessDir, gw, kc, clusterRunner) } if local { gw := gateway.NewCLI(cli) @@ -46,7 +48,7 @@ func NewDeployCmd(harnessDir, cli string) *cobra.Command { return cmd } -func deployRemote(harnessDir string, gw gateway.Gateway, kubeconfig string) error { +func deployRemote(harnessDir string, gw gateway.Gateway, kc, clusterRunner k8s.Runner) error { ctx := context.Background() namespace := k8s.DefaultNamespace() @@ -63,18 +65,15 @@ func deployRemote(harnessDir string, gw gateway.Gateway, kubeconfig string) erro chartOCI := "oci://ghcr.io/nvidia/openshell/helm-chart" fmt.Printf("OpenShell chart: %s\n", chartVersion) - if kubeconfig != "" { - fmt.Printf("KUBECONFIG: %s\n", kubeconfig) + if kbcfg := os.Getenv("KUBECONFIG"); kbcfg != "" { + fmt.Printf("KUBECONFIG: %s\n", kbcfg) } fmt.Println() - kc := k8s.New(kubeconfig, namespace) - kcNoNS := k8s.New(kubeconfig, "") - // Step 1: Namespace (idempotent — ignore AlreadyExists) status.Step(1, "Creating namespace") - kcNoNS.RunKubectl(ctx, "create", "ns", namespace) - if _, err := kcNoNS.RunKubectl(ctx, "label", "ns", namespace, + clusterRunner.RunKubectl(ctx, "create", "ns", namespace) + if _, err := clusterRunner.RunKubectl(ctx, "label", "ns", namespace, "pod-security.kubernetes.io/enforce=privileged", "pod-security.kubernetes.io/warn=privileged", "--overwrite"); err != nil { @@ -83,7 +82,7 @@ func deployRemote(harnessDir string, gw gateway.Gateway, kubeconfig string) erro // Step 2: Sandbox CRD status.Step(2, "Installing Sandbox CRD") - if err := kcNoNS.RunKubectlPassthrough(ctx, "apply", "-f", + if err := clusterRunner.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) } @@ -94,7 +93,7 @@ func deployRemote(harnessDir string, gw gateway.Gateway, kubeconfig string) erro kc.RunOC(ctx, "adm", "policy", "add-scc-to-user", "privileged", "-z", sa, "-n", namespace) } kc.RunOC(ctx, "adm", "policy", "add-scc-to-user", "anyuid", "-z", "openshell", "-n", namespace) - kcNoNS.RunKubectl(ctx, "create", "clusterrolebinding", "agent-sandbox-admin", + clusterRunner.RunKubectl(ctx, "create", "clusterrolebinding", "agent-sandbox-admin", "--clusterrole=cluster-admin", "--serviceaccount=agent-sandbox-system:agent-sandbox-controller") @@ -141,7 +140,7 @@ func deployRemote(harnessDir string, gw gateway.Gateway, kubeconfig string) erro sandboxImage = "quay.io/rcochran/openshell:sandbox" } - appsDomain, err := kcNoNS.GetJSONPath(ctx, "ingresses.config.openshift.io/cluster", "{.spec.domain}") + appsDomain, err := clusterRunner.GetJSONPath(ctx, "ingresses.config.openshift.io/cluster", "{.spec.domain}") if err != nil || appsDomain == "" { return fmt.Errorf("could not determine OpenShift apps domain — is this an OpenShift cluster? (kubectl get ingresses.config.openshift.io cluster)") } diff --git a/cmd/new.go b/cmd/new.go index a10dee0..d3b406d 100644 --- a/cmd/new.go +++ b/cmd/new.go @@ -78,11 +78,13 @@ type newLocalOpts struct { func newRemote(harnessDir string, gw gateway.Gateway, profileName, sandboxName string) error { ctx := context.Background() namespace := k8s.DefaultNamespace() + kc := k8s.New("", namespace) + clusterRunner := k8s.New("", "") // 1. Ensure gateway if err := gw.InferenceGet(); err != nil { status.Section("Deploying gateway") - if err := deployRemote(harnessDir, gw, ""); err != nil { + if err := deployRemote(harnessDir, gw, kc, clusterRunner); err != nil { return fmt.Errorf("deploy failed: %w", err) } } @@ -97,7 +99,7 @@ func newRemote(harnessDir string, gw gateway.Gateway, profileName, sandboxName s } // 3. Ensure credentials - if err := ensureCreds(namespace, false); err != nil { + if err := ensureCreds(kc, namespace, false); err != nil { return fmt.Errorf("credentials setup failed: %w", err) } @@ -110,7 +112,6 @@ func newRemote(harnessDir string, gw gateway.Gateway, profileName, sandboxName s cfg.Name = sandboxName } - kc := k8s.New("", namespace) profilePath := filepath.Join(harnessDir, "profiles", profileName+".toml") // 1. ConfigMap from profile diff --git a/cmd/new_test.go b/cmd/new_test.go index 6cff433..c94a00f 100644 --- a/cmd/new_test.go +++ b/cmd/new_test.go @@ -11,14 +11,16 @@ import ( ) type mockGW struct { - inferenceErr error - providers map[string]bool - providerList []string - providerErr error - createErr error - createCalls int - createOpts []gateway.SandboxCreateOpts - deletedNames []string + inferenceErr error + providers map[string]bool + providerList []string + providerErr error + createErr error + createCalls int + createOpts []gateway.SandboxCreateOpts + deletedNames []string + gatewayListResult []gateway.GatewayInfo + onGatewayRemove func(string) } func (m *mockGW) InferenceGet() error { return m.inferenceErr } @@ -56,10 +58,17 @@ func (m *mockGW) SandboxList() ([]string, error) func (m *mockGW) SandboxConnect(string) error { return nil } func (m *mockGW) SandboxUpload(string, string, string) error { return nil } func (m *mockGW) SandboxExec(string, ...string) error { return nil } -func (m *mockGW) GatewayAdd(string, string, bool) error { return nil } -func (m *mockGW) GatewayRemove(string) error { return nil } -func (m *mockGW) GatewayList() ([]gateway.GatewayInfo, error) { return nil, nil } -func (m *mockGW) GatewaySelect(string) error { return nil } +func (m *mockGW) GatewayAdd(string, string, bool) error { return nil } +func (m *mockGW) GatewayRemove(name string) error { + if m.onGatewayRemove != nil { + m.onGatewayRemove(name) + } + return nil +} +func (m *mockGW) GatewayList() ([]gateway.GatewayInfo, error) { + return m.gatewayListResult, nil +} +func (m *mockGW) GatewaySelect(string) error { return nil } func setupTestProfile(t *testing.T) string { t.Helper() diff --git a/cmd/teardown.go b/cmd/teardown.go index 8d88506..a8405c9 100644 --- a/cmd/teardown.go +++ b/cmd/teardown.go @@ -48,7 +48,8 @@ func NewTeardownCmd(harnessDir, cli string) *cobra.Command { } } if k8sFlag { - teardownK8s(gw) + ns := k8s.DefaultNamespace() + teardownK8s(gw, k8s.New("", ns), k8s.New("", "")) } status.Done("Done.") @@ -136,14 +137,11 @@ func teardownProviders(gw gateway.Gateway, activeGW string) error { return nil } -func teardownK8s(gw gateway.Gateway) { +func teardownK8s(gw gateway.Gateway, kc, clusterRunner k8s.Runner) { ctx := context.Background() namespace := k8s.DefaultNamespace() - kc := k8s.New("", namespace) - kcNoNS := k8s.New("", "") - - if !kcNoNS.NamespaceExists(ctx, namespace) { + if !clusterRunner.NamespaceExists(ctx, namespace) { status.Section("K8s") status.Info("No openshell namespace found, skipping") return @@ -160,7 +158,7 @@ func teardownK8s(gw gateway.Gateway) { // Sandbox CRD namespace fmt.Println() status.Section("Sandbox CRD") - if _, err := kcNoNS.RunKubectl(ctx, "delete", "ns", "agent-sandbox-system"); err == nil { + if _, err := clusterRunner.RunKubectl(ctx, "delete", "ns", "agent-sandbox-system"); err == nil { status.Info("Deleted agent-sandbox-system") } else { status.Info("Not found") @@ -173,7 +171,7 @@ func teardownK8s(gw gateway.Gateway) { kc.RunOC(ctx, "adm", "policy", "remove-scc-from-user", "privileged", "-z", sa, "-n", namespace) } kc.RunOC(ctx, "adm", "policy", "remove-scc-from-user", "anyuid", "-z", "openshell", "-n", namespace) - kcNoNS.RunKubectl(ctx, "delete", "clusterrolebinding", "agent-sandbox-admin") + clusterRunner.RunKubectl(ctx, "delete", "clusterrolebinding", "agent-sandbox-admin") status.Info("Cleared") // Secrets @@ -190,7 +188,7 @@ func teardownK8s(gw gateway.Gateway) { // Namespace fmt.Println() status.Section("Namespace") - if _, err := kcNoNS.RunKubectl(ctx, "delete", "ns", namespace); err == nil { + if _, err := clusterRunner.RunKubectl(ctx, "delete", "ns", namespace); err == nil { status.OKf("Deleted %s", namespace) } else { status.Infof("%s: not found", namespace) diff --git a/cmd/teardown_test.go b/cmd/teardown_test.go new file mode 100644 index 0000000..22ab2dd --- /dev/null +++ b/cmd/teardown_test.go @@ -0,0 +1,63 @@ +package cmd + +import ( + "fmt" + "testing" + + "github.com/robbycochran/harness-openshell/internal/gateway" + "github.com/robbycochran/harness-openshell/internal/k8s" +) + +func TestTeardownK8s_NamespaceNotFound(t *testing.T) { + kc := k8s.NewMockRunner() + clusterRunner := k8s.NewMockRunner() + clusterRunner.Errors["namespace-exists"] = fmt.Errorf("not found") + + gw := &mockGW{} + teardownK8s(gw, kc, clusterRunner) + + if clusterRunner.HasCall("delete") { + t.Error("should not attempt deletes when namespace does not exist") + } +} + +func TestTeardownK8s_FullCleanup(t *testing.T) { + kc := k8s.NewMockRunner() + clusterRunner := k8s.NewMockRunner() + + deletedGateways := []string{} + gw := &mockGW{ + gatewayListResult: []gateway.GatewayInfo{ + {Name: "openshell-remote-ocp", Endpoint: "https://gw.example.com", Active: true}, + {Name: "openshell", Endpoint: "https://127.0.0.1:17670"}, + }, + onGatewayRemove: func(name string) { deletedGateways = append(deletedGateways, name) }, + } + + teardownK8s(gw, kc, clusterRunner) + + // Should helm uninstall + if !kc.HasCall("helm uninstall") { + t.Errorf("expected helm uninstall, calls: %v", kc.Calls) + } + + // Should delete agent-sandbox-system namespace + if !clusterRunner.HasCall("delete ns agent-sandbox-system") { + t.Errorf("expected delete ns agent-sandbox-system, calls: %v", clusterRunner.Calls) + } + + // Should delete secrets + if kc.CallCount("delete secret") != 2 { + t.Errorf("expected 2 secret deletes, got %d: %v", kc.CallCount("delete secret"), kc.Calls) + } + + // Should delete openshell namespace + if !clusterRunner.HasCall("delete ns openshell") { + t.Errorf("expected delete ns openshell, calls: %v", clusterRunner.Calls) + } + + // Should remove non-local gateway only + if len(deletedGateways) != 1 || deletedGateways[0] != "openshell-remote-ocp" { + t.Errorf("deleted gateways = %v, want [openshell-remote-ocp]", deletedGateways) + } +} diff --git a/internal/k8s/kubectl.go b/internal/k8s/kubectl.go index b1f7fae..d1534ed 100644 --- a/internal/k8s/kubectl.go +++ b/internal/k8s/kubectl.go @@ -25,6 +25,21 @@ var transientErrors = []string{ "i/o timeout", } +// Runner abstracts kubectl/helm/oc operations for testing. +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) (string, error) + RunOC(ctx context.Context, args ...string) error + ApplyYAML(ctx context.Context, resources ...map[string]any) error + SecretExists(ctx context.Context, name string) bool + GetSecretField(ctx context.Context, secretName, field string) ([]byte, error) + GetJSONPath(ctx context.Context, resource, jsonpath string) (string, error) + NamespaceExists(ctx context.Context, ns string) bool +} + type Client struct { kubeconfig string namespace string diff --git a/internal/k8s/mock.go b/internal/k8s/mock.go new file mode 100644 index 0000000..ba720b4 --- /dev/null +++ b/internal/k8s/mock.go @@ -0,0 +1,127 @@ +package k8s + +import ( + "context" + "fmt" + "strings" +) + +// MockRunner records calls for testing. Returns preconfigured responses. +type MockRunner struct { + Calls []string + Responses map[string]string // command prefix → stdout response + Errors map[string]error // command prefix → error +} + +func NewMockRunner() *MockRunner { + return &MockRunner{ + Responses: make(map[string]string), + Errors: make(map[string]error), + } +} + +func (m *MockRunner) record(args ...string) string { + call := strings.Join(args, " ") + m.Calls = append(m.Calls, call) + return call +} + +func (m *MockRunner) respond(call string) (string, error) { + for prefix, err := range m.Errors { + if strings.HasPrefix(call, prefix) { + return "", err + } + } + for prefix, resp := range m.Responses { + if strings.HasPrefix(call, prefix) { + return resp, nil + } + } + return "", nil +} + +func (m *MockRunner) RunKubectl(_ context.Context, args ...string) (string, error) { + return m.respond(m.record(args...)) +} + +func (m *MockRunner) RunKubectlOpts(_ context.Context, opts KubectlOpts) (string, error) { + return m.respond(m.record(opts.Args...)) +} + +func (m *MockRunner) RunKubectlQuiet(_ context.Context, args ...string) error { + _, err := m.respond(m.record(args...)) + 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) (string, error) { + return m.respond(m.record(append([]string{"helm"}, args...)...)) +} + +func (m *MockRunner) RunOC(_ context.Context, args ...string) error { + _, err := m.respond(m.record(append([]string{"oc"}, args...)...)) + return err +} + +func (m *MockRunner) ApplyYAML(_ context.Context, resources ...map[string]any) error { + for _, r := range resources { + kind, _ := r["kind"].(string) + m.record("apply-yaml", kind) + } + return nil +} + +func (m *MockRunner) SecretExists(_ context.Context, name string) bool { + call := m.record("secret-exists", name) + _, err := m.respond(call) + return err == nil +} + +func (m *MockRunner) GetSecretField(_ context.Context, secretName, field string) ([]byte, error) { + call := m.record("get-secret-field", secretName, field) + resp, err := m.respond(call) + if err != nil { + return nil, err + } + return []byte(resp), nil +} + +func (m *MockRunner) GetJSONPath(_ context.Context, resource, jsonpath string) (string, error) { + return m.respond(m.record("get-jsonpath", resource, jsonpath)) +} + +func (m *MockRunner) NamespaceExists(_ context.Context, ns string) bool { + call := m.record("namespace-exists", ns) + _, err := m.respond(call) + return err == nil +} + +// HasCall checks if any recorded call starts with the given prefix. +func (m *MockRunner) HasCall(prefix string) bool { + for _, c := range m.Calls { + if strings.HasPrefix(c, prefix) { + return true + } + } + return false +} + +// CallCount returns how many calls start with the given prefix. +func (m *MockRunner) CallCount(prefix string) int { + n := 0 + for _, c := range m.Calls { + if strings.HasPrefix(c, prefix) { + n++ + } + } + return n +} + +// String returns a readable dump of all calls. +func (m *MockRunner) String() string { + return fmt.Sprintf("MockRunner{%d calls: %v}", len(m.Calls), m.Calls) +} From 10d9d20122f993871f26c399bfc24fc98dd76c40 Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Fri, 5 Jun 2026 02:24:05 -0700 Subject: [PATCH 3/3] test: add tests for deployRemote and ensureCreds (#25) 10 new tests using MockRunner: deployRemote (5 tests): - Success: verifies namespace, CRD, RBAC, Helm, rollout, Route in order - Helm failure: stops before rollout - CRD failure: stops before Helm - No apps domain: returns error - deployLocal with no gateway: returns error ensureCreds (5 tests): - Namespace not found: returns error - Secrets already exist: no creation attempted - Force mode: deletes both secrets - Atlassian created: when JIRA_URL set - Atlassian skipped: when JIRA_URL empty Total: 92 Go harness + 7 launcher + 29 bats = 128 tests. Co-Authored-By: Claude Opus 4.6 (1M context) --- cmd/creds_test.go | 77 +++++++++++++++++++++++ cmd/deploy_test.go | 151 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 228 insertions(+) create mode 100644 cmd/creds_test.go create mode 100644 cmd/deploy_test.go diff --git a/cmd/creds_test.go b/cmd/creds_test.go new file mode 100644 index 0000000..8a08426 --- /dev/null +++ b/cmd/creds_test.go @@ -0,0 +1,77 @@ +package cmd + +import ( + "fmt" + "testing" + + "github.com/robbycochran/harness-openshell/internal/k8s" +) + +func TestEnsureCreds_NamespaceNotFound(t *testing.T) { + nsRunner := k8s.NewMockRunner() + nsRunner.Errors["namespace-exists"] = fmt.Errorf("not found") + + err := ensureCreds(nsRunner, "openshell", false) + if err == nil { + t.Fatal("expected error for missing namespace") + } +} + +func TestEnsureCreds_SecretsExist(t *testing.T) { + nsRunner := k8s.NewMockRunner() + // namespace exists (no error), secrets exist (no error) + + err := ensureCreds(nsRunner, "openshell", false) + if err != nil { + t.Fatalf("ensureCreds: %v", err) + } + // Should NOT attempt to create secrets + if nsRunner.HasCall("create secret") { + t.Error("should not create secrets when they already exist") + } +} + +func TestEnsureCreds_ForceDeletesExisting(t *testing.T) { + nsRunner := k8s.NewMockRunner() + + err := ensureCreds(nsRunner, "openshell", true) + if err != nil { + t.Fatalf("ensureCreds: %v", err) + } + // Should attempt to delete both secrets + if nsRunner.CallCount("delete secret") != 2 { + t.Errorf("expected 2 secret deletes, got %d: %v", + nsRunner.CallCount("delete secret"), nsRunner.Calls) + } +} + +func TestEnsureCreds_AtlassianCreated(t *testing.T) { + nsRunner := k8s.NewMockRunner() + nsRunner.Errors["secret-exists openshell-atlassian"] = fmt.Errorf("not found") + + t.Setenv("JIRA_URL", "https://example.atlassian.net") + t.Setenv("JIRA_USERNAME", "user@example.com") + + err := ensureCreds(nsRunner, "openshell", false) + if err != nil { + t.Fatalf("ensureCreds: %v", err) + } + if !nsRunner.HasCall("create secret generic openshell-atlassian") { + t.Errorf("expected atlassian secret creation, calls: %v", nsRunner.Calls) + } +} + +func TestEnsureCreds_AtlassianSkipped(t *testing.T) { + nsRunner := k8s.NewMockRunner() + nsRunner.Errors["secret-exists openshell-atlassian"] = fmt.Errorf("not found") + t.Setenv("JIRA_URL", "") + t.Setenv("JIRA_USERNAME", "") + + err := ensureCreds(nsRunner, "openshell", false) + if err != nil { + t.Fatalf("ensureCreds: %v", err) + } + if nsRunner.HasCall("create secret generic openshell-atlassian") { + t.Error("should not create atlassian secret without JIRA_URL") + } +} diff --git a/cmd/deploy_test.go b/cmd/deploy_test.go new file mode 100644 index 0000000..91e5cae --- /dev/null +++ b/cmd/deploy_test.go @@ -0,0 +1,151 @@ +package cmd + +import ( + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/robbycochran/harness-openshell/internal/gateway" + "github.com/robbycochran/harness-openshell/internal/k8s" +) + +func setupDeployHarnessDir(t *testing.T) string { + t.Helper() + dir := t.TempDir() + os.WriteFile(filepath.Join(dir, "values-ocp.yaml"), []byte("image:\n pullPolicy: Always\n"), 0o644) + os.MkdirAll(filepath.Join(dir, "profiles"), 0o755) + return dir +} + +func TestDeployRemote_Success(t *testing.T) { + dir := setupDeployHarnessDir(t) + t.Setenv("OPENSHELL_CHART_VERSION", "0.0.55") + t.Setenv("OPENSHELL_NAMESPACE", "openshell") + + nsRunner := k8s.NewMockRunner() + clusterRunner := k8s.NewMockRunner() + clusterRunner.Responses["get-jsonpath"] = "apps.example.com" + nsRunner.Responses["get-secret-field"] = "dGVzdA==" // base64 "test" + nsRunner.Errors["get route"] = fmt.Errorf("not found") // trigger Route creation + + gw := &mockGW{} + + err := deployRemote(dir, gw, nsRunner, clusterRunner) + if err != nil { + t.Fatalf("deployRemote: %v", err) + } + + // Verify namespace created + if !clusterRunner.HasCall("create ns openshell") { + t.Errorf("missing create ns, calls: %v", clusterRunner.Calls) + } + + // Verify namespace labeled + if !clusterRunner.HasCall("label ns openshell") { + t.Errorf("missing label ns, calls: %v", clusterRunner.Calls) + } + + // Verify CRD installed + if !clusterRunner.HasCall("apply -f") { + t.Errorf("missing CRD apply, calls: %v", clusterRunner.Calls) + } + + // Verify RBAC applied + if !nsRunner.HasCall("apply-yaml ServiceAccount") { + t.Errorf("missing RBAC ServiceAccount, calls: %v", nsRunner.Calls) + } + if !nsRunner.HasCall("apply-yaml Role") { + t.Errorf("missing RBAC Role, calls: %v", nsRunner.Calls) + } + + // Verify Helm install + if !nsRunner.HasCall("helm upgrade --install") { + t.Errorf("missing helm install, calls: %v", nsRunner.Calls) + } + + // Verify rollout status + if !nsRunner.HasCall("rollout status statefulset/openshell") { + t.Errorf("missing rollout status, calls: %v", nsRunner.Calls) + } + + // Verify Route applied + if !nsRunner.HasCall("apply-yaml Route") { + t.Errorf("missing Route apply, calls: %v", nsRunner.Calls) + } +} + +func TestDeployRemote_HelmFailure(t *testing.T) { + dir := setupDeployHarnessDir(t) + t.Setenv("OPENSHELL_CHART_VERSION", "0.0.55") + t.Setenv("OPENSHELL_NAMESPACE", "openshell") + + nsRunner := k8s.NewMockRunner() + clusterRunner := k8s.NewMockRunner() + clusterRunner.Responses["get-jsonpath"] = "apps.example.com" + nsRunner.Errors["helm upgrade"] = fmt.Errorf("chart not found") + + gw := &mockGW{} + + err := deployRemote(dir, gw, nsRunner, clusterRunner) + if err == nil { + t.Fatal("expected error from helm failure") + } + if !nsRunner.HasCall("helm upgrade") { + t.Errorf("helm should have been called, calls: %v", nsRunner.Calls) + } + // Should NOT have attempted rollout after helm failure + if nsRunner.HasCall("rollout status") { + t.Error("should not attempt rollout after helm failure") + } +} + +func TestDeployRemote_CRDFailure(t *testing.T) { + dir := setupDeployHarnessDir(t) + t.Setenv("OPENSHELL_CHART_VERSION", "0.0.55") + t.Setenv("OPENSHELL_NAMESPACE", "openshell") + + nsRunner := k8s.NewMockRunner() + clusterRunner := k8s.NewMockRunner() + clusterRunner.Errors["apply -f"] = fmt.Errorf("network error") + + gw := &mockGW{} + + err := deployRemote(dir, gw, nsRunner, clusterRunner) + if err == nil { + t.Fatal("expected error from CRD install failure") + } + // Should NOT have attempted helm after CRD failure + if nsRunner.HasCall("helm") { + t.Error("should not attempt helm after CRD failure") + } +} + +func TestDeployRemote_NoAppsDomain(t *testing.T) { + dir := setupDeployHarnessDir(t) + t.Setenv("OPENSHELL_CHART_VERSION", "0.0.55") + t.Setenv("OPENSHELL_NAMESPACE", "openshell") + + nsRunner := k8s.NewMockRunner() + clusterRunner := k8s.NewMockRunner() + clusterRunner.Responses["get-jsonpath"] = "" // empty domain + + gw := &mockGW{} + + err := deployRemote(dir, gw, nsRunner, clusterRunner) + if err == nil { + t.Fatal("expected error for missing apps domain") + } +} + +func TestDeployLocal_NoGateway(t *testing.T) { + gw := &mockGW{inferenceErr: fmt.Errorf("not reachable")} + gw.gatewayListResult = []gateway.GatewayInfo{ + {Name: "local", Endpoint: "https://127.0.0.1:17670"}, + } + + err := deployLocal(gw) + if err == nil { + t.Fatal("expected error for unreachable gateway") + } +}