diff --git a/cmd/creds.go b/cmd/creds.go new file mode 100644 index 0000000..10bac0a --- /dev/null +++ b/cmd/creds.go @@ -0,0 +1,114 @@ +package cmd + +import ( + "context" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + + "github.com/robbycochran/harness-openshell/internal/k8s" +) + +func ensureCreds(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) + } + + fmt.Println("=== Setting up cluster credentials ===") + fmt.Printf(" Namespace: %s\n", namespace) + fmt.Println() + + // GWS credentials + fmt.Println("=== GWS ===") + if force && kc.SecretExists(ctx, "openshell-gws") { + kc.RunKubectl(ctx, "delete", "secret", "openshell-gws") + fmt.Println(" Deleted existing secret.") + } + + if kc.SecretExists(ctx, "openshell-gws") { + fmt.Println(" openshell-gws: exists (use --force to recreate)") + } else { + if err := createGWSSecret(ctx, kc); err != nil { + fmt.Printf(" GWS: %v\n", err) + } + } + + // Atlassian credentials + fmt.Println() + fmt.Println("=== Atlassian ===") + if force && kc.SecretExists(ctx, "openshell-atlassian") { + kc.RunKubectl(ctx, "delete", "secret", "openshell-atlassian") + fmt.Println(" Deleted existing secret.") + } + + if kc.SecretExists(ctx, "openshell-atlassian") { + fmt.Println(" 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", + "--from-literal=JIRA_URL="+jiraURL, + "--from-literal=JIRA_USERNAME="+jiraUser) + if err != nil { + return fmt.Errorf("creating atlassian secret: %w", err) + } + fmt.Printf(" openshell-atlassian: created (%s)\n", jiraURL) + } else { + fmt.Println(" Atlassian: JIRA_URL not set (skipping)") + } + + return nil +} + +func createGWSSecret(ctx context.Context, kc *k8s.Client) error { + gwsPath, err := exec.LookPath("gws") + if err != nil { + fmt.Println(" 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')") + return nil + } + + tmpDir, err := os.MkdirTemp("", "harness-gws-") + if err != nil { + return err + } + defer os.RemoveAll(tmpDir) + + credFile := filepath.Join(tmpDir, "credentials.json") + out, err := exec.Command(gwsPath, "auth", "export", "--unmasked").Output() + if err != nil { + fmt.Println(" GWS: export failed (skipping)") + return nil + } + os.WriteFile(credFile, out, 0o600) + + args := []string{"create", "secret", "generic", "openshell-gws", + "--from-file=credentials.json=" + credFile} + + gwsConfigDir := os.Getenv("GWS_CONFIG_DIR") + if gwsConfigDir == "" { + home, _ := os.UserHomeDir() + gwsConfigDir = filepath.Join(home, ".config", "gws") + } + clientSecret := filepath.Join(gwsConfigDir, "client_secret.json") + if _, err := os.Stat(clientSecret); err == nil { + args = append(args, "--from-file=client_secret.json="+clientSecret) + } + + if _, err := kc.RunKubectl(ctx, args...); err != nil { + return fmt.Errorf("creating gws secret: %w", err) + } + fmt.Println(" openshell-gws: created") + return nil +} diff --git a/cmd/deploy.go b/cmd/deploy.go index d093346..d60b271 100644 --- a/cmd/deploy.go +++ b/cmd/deploy.go @@ -1,12 +1,17 @@ package cmd import ( + "context" "fmt" + "os" "os/exec" + "path/filepath" "strings" + "time" "github.com/robbycochran/harness-openshell/internal/gateway" - "github.com/robbycochran/harness-openshell/internal/runner" + "github.com/robbycochran/harness-openshell/internal/k8s" + "github.com/robbycochran/harness-openshell/internal/preflight" "github.com/robbycochran/harness-openshell/internal/status" "github.com/spf13/cobra" ) @@ -23,11 +28,8 @@ func NewDeployCmd(harnessDir, cli string) *cobra.Command { Short: "Deploy or verify the gateway", RunE: func(cmd *cobra.Command, args []string) error { if remote { - scriptArgs := []string{"--remote"} - if kubeconfig != "" { - scriptArgs = append(scriptArgs, "--kubeconfig", kubeconfig) - } - return runner.RunScript(harnessDir, "deploy.sh", scriptArgs...) + gw := gateway.NewCLI(cli) + return deployRemote(harnessDir, gw, kubeconfig) } if local { gw := gateway.NewCLI(cli) @@ -44,14 +46,198 @@ func NewDeployCmd(harnessDir, cli string) *cobra.Command { return cmd } +func deployRemote(harnessDir string, gw gateway.Gateway, kubeconfig string) error { + ctx := context.Background() + namespace := os.Getenv("OPENSHELL_NAMESPACE") + if namespace == "" { + namespace = "openshell" + } + + chartVersion := os.Getenv("OPENSHELL_CHART_VERSION") + if chartVersion == "" { + cfg, _ := preflight.LoadConfig(filepath.Join(harnessDir, "openshell.toml")) + if cfg != nil { + chartVersion = cfg.ChartVersion + } + } + if chartVersion == "" { + chartVersion = "0.0.55" + } + chartOCI := "oci://ghcr.io/nvidia/openshell/helm-chart" + + fmt.Printf("OpenShell chart: %s\n", chartVersion) + if kubeconfig != "" { + fmt.Printf("KUBECONFIG: %s\n", kubeconfig) + } + fmt.Println() + + kc := k8s.New(kubeconfig, namespace) + kcNoNS := k8s.New(kubeconfig, "") + + // Step 1: Namespace + fmt.Println("=== Step 1: Creating namespace ===") + kcNoNS.RunKubectl(ctx, "create", "ns", namespace) + kcNoNS.RunKubectl(ctx, "label", "ns", namespace, + "pod-security.kubernetes.io/enforce=privileged", + "pod-security.kubernetes.io/warn=privileged", + "--overwrite") + + // Step 2: Sandbox CRD + fmt.Println("=== Step 2: Installing Sandbox CRD ===") + kcNoNS.RunKubectlPassthrough(ctx, "apply", "-f", + "https://github.com/kubernetes-sigs/agent-sandbox/releases/latest/download/manifest.yaml") + + // Step 3: OpenShift SCCs + fmt.Println("=== 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) + } + kc.RunOC(ctx, "adm", "policy", "add-scc-to-user", "anyuid", "-z", "openshell", "-n", namespace) + kcNoNS.RunKubectl(ctx, "create", "clusterrolebinding", "agent-sandbox-admin", + "--clusterrole=cluster-admin", + "--serviceaccount=agent-sandbox-system:agent-sandbox-controller") + + // RBAC for launcher + kc.ApplyYAML(ctx, + map[string]any{ + "apiVersion": "v1", + "kind": "ServiceAccount", + "metadata": map[string]any{"name": "openshell-launcher", "namespace": namespace}, + }, + map[string]any{ + "apiVersion": "rbac.authorization.k8s.io/v1", + "kind": "Role", + "metadata": map[string]any{"name": "openshell-launcher", "namespace": namespace}, + "rules": []map[string]any{{ + "apiGroups": []string{""}, + "resources": []string{"configmaps", "secrets"}, + "verbs": []string{"get", "list"}, + }}, + }, + map[string]any{ + "apiVersion": "rbac.authorization.k8s.io/v1", + "kind": "RoleBinding", + "metadata": map[string]any{"name": "openshell-launcher", "namespace": namespace}, + "subjects": []map[string]any{{ + "kind": "ServiceAccount", + "name": "openshell-launcher", + "namespace": namespace, + }}, + "roleRef": map[string]any{ + "kind": "Role", + "name": "openshell-launcher", + "apiGroup": "rbac.authorization.k8s.io", + }, + }, + ) + + // Step 4: Helm install + fmt.Println("=== Step 4: Deploying gateway via Helm ===") + sandboxImage := os.Getenv("SANDBOX_IMAGE") + if sandboxImage == "" { + sandboxImage = "quay.io/rcochran/openshell:sandbox" + } + + appsDomain, err := kcNoNS.GetJSONPath(ctx, "ingresses.config.openshift.io/cluster", "{.spec.domain}") + if err != nil || appsDomain == "" { + return fmt.Errorf("could not determine OpenShift apps domain") + } + routeHost := fmt.Sprintf("gateway-openshell.%s", appsDomain) + + helmArgs := []string{ + "upgrade", "--install", "openshell", chartOCI, + "--version", chartVersion, + "--values", filepath.Join(harnessDir, "values-ocp.yaml"), + "--set", "server.sandboxImage=" + sandboxImage, + "--set", "pkiInitJob.serverDnsNames[0]=" + routeHost, + } + if ps := os.Getenv("PULL_SECRET"); ps != "" { + helmArgs = append(helmArgs, "--set", "imagePullSecrets[0].name="+ps) + } + if sps := os.Getenv("SANDBOX_PULL_SECRET"); sps != "" { + helmArgs = append(helmArgs, "--set", "server.sandboxImagePullSecrets[0].name="+sps) + } + kc.RunHelm(ctx, helmArgs...) + + // Wait for gateway + fmt.Println("=== Waiting for gateway ===") + kc.RunKubectlPassthrough(ctx, "rollout", "status", "statefulset/openshell", "--timeout=300s") + + // Step 5: Route + fmt.Println("=== 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", + "kind": "Route", + "metadata": map[string]any{"name": "gateway", "namespace": namespace}, + "spec": map[string]any{ + "tls": map[string]any{"termination": "passthrough"}, + "to": map[string]any{"kind": "Service", "name": "openshell"}, + "port": map[string]any{"targetPort": "grpc"}, + }, + }) + } + fmt.Printf(" Route: %s\n", routeHost) + + // Step 6: CLI gateway config + fmt.Println("=== Step 6: Configuring CLI gateway ===") + gatewayName := os.Getenv("GATEWAY_NAME") + if gatewayName == "" { + gatewayName = "openshell-remote-ocp" + } + gatewayURL := fmt.Sprintf("https://%s:443", routeHost) + + // Remove existing gateways for this host + existing, _ := gw.GatewayList() + for _, g := range existing { + if strings.Contains(g.Endpoint, routeHost) { + gw.GatewayRemove(g.Name) + } + } + + gw.GatewayAdd(gatewayURL, gatewayName, true) + + // Extract mTLS certs + home, _ := os.UserHomeDir() + mtlsDir := filepath.Join(home, ".config", "openshell", "gateways", gatewayName, "mtls") + for _, field := range []string{"ca.crt", "tls.crt", "tls.key"} { + data, err := kc.GetSecretField(ctx, "openshell-client-tls", field) + if err != nil { + return fmt.Errorf("extracting %s from openshell-client-tls: %w", field, err) + } + if err := os.WriteFile(filepath.Join(mtlsDir, field), data, 0o600); err != nil { + return fmt.Errorf("writing %s: %w", field, err) + } + } + + gw.GatewaySelect(gatewayName) + status.OKf("%s registered (certs from cluster)", gatewayName) + + // Wait for gateway to be reachable + fmt.Print(" Waiting for gateway...") + for i := range 30 { + if gw.InferenceGet() == nil { + fmt.Println(" ✓ reachable") + break + } + time.Sleep(2 * time.Second) + fmt.Print(".") + if i == 29 { + fmt.Println(" ✗ timed out (try: openshell inference get)") + } + } + + fmt.Println() + fmt.Println("Done.") + return nil +} + func deployLocal(gw gateway.Gateway) error { - // CLI check cliPath := gw.CLIPath() if cliPath == "" { return fmt.Errorf("openshell CLI not found. Install it first:\n curl -LsSf https://raw.githubusercontent.com/NVIDIA/OpenShell/main/install.sh | sh") } - // Podman check status.Section("Container Runtime") podmanPath, _ := exec.LookPath("podman") if podmanPath == "" { @@ -62,7 +248,6 @@ func deployLocal(gw gateway.Gateway) error { out, _ := cmd.Output() status.OKf("Podman: %s", strings.TrimSpace(string(out))) - // Find local gateway status.Section("Gateway") gateways, err := gw.GatewayList() if err != nil { diff --git a/cmd/new.go b/cmd/new.go index 1a20294..4b7f437 100644 --- a/cmd/new.go +++ b/cmd/new.go @@ -1,11 +1,16 @@ package cmd import ( + "context" "fmt" "os" + "os/exec" + "path/filepath" + "strings" "time" "github.com/robbycochran/harness-openshell/internal/gateway" + "github.com/robbycochran/harness-openshell/internal/k8s" "github.com/robbycochran/harness-openshell/internal/profile" "github.com/robbycochran/harness-openshell/internal/runner" "github.com/robbycochran/harness-openshell/internal/status" @@ -30,11 +35,11 @@ func NewNewCmd(harnessDir, cli string) *cobra.Command { sandboxName = args[0] } + gw := gateway.NewCLI(cli) + if remote { - return newRemote(harnessDir, profileName, sandboxName, noTTY) + return newRemote(harnessDir, gw, profileName, sandboxName) } - - gw := gateway.NewCLI(cli) return newLocal(newLocalOpts{ harnessDir: harnessDir, gw: gw, @@ -70,15 +75,155 @@ type newLocalOpts struct { retrySleep time.Duration } -func newRemote(harnessDir, profileName, sandboxName string, noTTY bool) error { - args := []string{"--remote", "--profile", profileName} +func newRemote(harnessDir string, gw gateway.Gateway, profileName, sandboxName string) error { + ctx := context.Background() + namespace := os.Getenv("OPENSHELL_NAMESPACE") + if namespace == "" { + namespace = "openshell" + } + + // 1. Ensure gateway + if err := gw.InferenceGet(); err != nil { + fmt.Println("=== Deploying gateway ===") + if err := deployRemote(harnessDir, gw, ""); err != nil { + return fmt.Errorf("deploy failed: %w", err) + } + } + + // 2. Ensure providers + providers, _ := gw.ProviderList() + if len(providers) == 0 { + fmt.Println("\n=== Registering providers ===") + if err := registerProviders(harnessDir, gw, false); err != nil { + return fmt.Errorf("provider registration failed: %w", err) + } + } + + // 3. Ensure credentials + if err := ensureCreds(namespace, false); err != nil { + return fmt.Errorf("credentials setup failed: %w", err) + } + + // Parse profile + cfg, err := profile.Parse(harnessDir, profileName) + if err != nil { + return err + } if sandboxName != "" { - args = append(args, "--name", sandboxName) + cfg.Name = sandboxName } - if noTTY { - args = append(args, "--no-tty") + + kc := k8s.New("", namespace) + profilePath := filepath.Join(harnessDir, "profiles", profileName+".toml") + + // 1. ConfigMap from profile + out, err := kc.RunKubectl(ctx, "create", "configmap", "sandbox-"+cfg.Name, + "--from-file=config.toml="+profilePath, + "--dry-run=client", "-o", "yaml") + if err != nil { + return fmt.Errorf("creating config configmap: %w", err) } - return runner.RunScript(harnessDir, "new.sh", args...) + kc.RunKubectlOpts(ctx, k8s.KubectlOpts{ + Args: []string{"apply", "-f", "-"}, + Stdin: strings.NewReader(out), + }) + + // 2. ConfigMap from env (conditional) + envContent := cfg.BuildSandboxEnv() + if envContent != "" { + out, err := kc.RunKubectl(ctx, "create", "configmap", "sandbox-"+cfg.Name+"-env", + "--from-literal=sandbox.env="+envContent, + "--dry-run=client", "-o", "yaml") + if err == nil { + kc.RunKubectlOpts(ctx, k8s.KubectlOpts{ + Args: []string{"apply", "-f", "-"}, + Stdin: strings.NewReader(out), + }) + } + } + + // 3. Clean up old job + jobName := "sandbox-" + cfg.Name + kc.RunKubectl(ctx, "delete", "job", jobName, "--force", "--grace-period=0") + kc.RunKubectl(ctx, "delete", "pod", "-l", "job-name="+jobName, "--force", "--grace-period=0") + + // 4. Apply launcher Job + job := map[string]any{ + "apiVersion": "batch/v1", + "kind": "Job", + "metadata": map[string]any{"name": jobName, "namespace": namespace}, + "spec": map[string]any{ + "backoffLimit": 0, + "template": map[string]any{ + "spec": map[string]any{ + "serviceAccountName": "openshell-launcher", + "restartPolicy": "Never", + "containers": []map[string]any{{ + "name": "launcher", + "image": "quay.io/rcochran/openshell:launcher", + "imagePullPolicy": "Always", + "env": []map[string]any{ + {"name": "GATEWAY_ENDPOINT", "value": "https://openshell.openshell.svc.cluster.local:8080"}, + {"name": "HOME", "value": "/tmp"}, + }, + "volumeMounts": []map[string]any{ + {"name": "config", "mountPath": "/etc/openshell/sandbox", "readOnly": true}, + {"name": "gws", "mountPath": "/secrets/gws", "readOnly": true}, + {"name": "gateway-mtls", "mountPath": "/secrets/mtls", "readOnly": true}, + {"name": "sandbox-env", "mountPath": "/etc/openshell/env", "readOnly": true}, + }, + }}, + "volumes": []map[string]any{ + {"name": "config", "configMap": map[string]any{"name": "sandbox-" + cfg.Name}}, + {"name": "gws", "secret": map[string]any{"secretName": "openshell-gws", "optional": true}}, + {"name": "gateway-mtls", "secret": map[string]any{"secretName": "openshell-client-tls"}}, + {"name": "sandbox-env", "configMap": map[string]any{"name": "sandbox-" + cfg.Name + "-env", "optional": true}}, + }, + }, + }, + }, + } + if err := kc.ApplyYAML(ctx, job); err != nil { + return fmt.Errorf("applying launcher job: %w", err) + } + + // 5. Wait for launcher pod + fmt.Println() + fmt.Println("Waiting for launcher...") + kc.RunKubectl(ctx, "wait", "--for=condition=ready", "pod", + "-l", "job-name="+jobName, "--timeout=120s") + + // 6. Tail logs in background + logCmd := exec.CommandContext(ctx, "kubectl", "-n", namespace, + "logs", "-f", "-l", "job-name="+jobName) + logCmd.Stdout = os.Stdout + logCmd.Stderr = os.Stderr + logCmd.Start() + + // 7. Poll job status + for { + jobStatus, _ := kc.RunKubectl(ctx, "get", "job", jobName, + "-o", "jsonpath={.status.conditions[0].type}") + if jobStatus == "Complete" || jobStatus == "Failed" || jobStatus == "SuccessCriteriaMet" { + break + } + time.Sleep(2 * time.Second) + } + + if logCmd.Process != nil { + logCmd.Process.Kill() + logCmd.Wait() + } + + fmt.Println() + jobStatus, _ := kc.RunKubectl(ctx, "get", "job", jobName, + "-o", "jsonpath={.status.conditions[0].type}") + if jobStatus == "Complete" || jobStatus == "SuccessCriteriaMet" { + fmt.Printf("Sandbox ready. Connect with: harness connect %s\n", cfg.Name) + } else { + fmt.Println("Launcher failed.") + } + return nil } func newLocal(opts newLocalOpts) error { diff --git a/cmd/new_test.go b/cmd/new_test.go index a22f730..6cff433 100644 --- a/cmd/new_test.go +++ b/cmd/new_test.go @@ -56,6 +56,8 @@ 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 } diff --git a/cmd/teardown.go b/cmd/teardown.go index 5d251d3..8504967 100644 --- a/cmd/teardown.go +++ b/cmd/teardown.go @@ -1,10 +1,13 @@ package cmd import ( + "context" "fmt" + "os" + "strings" "github.com/robbycochran/harness-openshell/internal/gateway" - "github.com/robbycochran/harness-openshell/internal/runner" + "github.com/robbycochran/harness-openshell/internal/k8s" "github.com/robbycochran/harness-openshell/internal/status" "github.com/spf13/cobra" ) @@ -13,18 +16,17 @@ func NewTeardownCmd(harnessDir, cli string) *cobra.Command { var ( sandboxes bool providers bool - k8s bool + k8sFlag bool ) cmd := &cobra.Command{ Use: "teardown [--sandboxes] [--providers] [--k8s]", Short: "Tear down sandboxes, providers, or k8s resources", RunE: func(cmd *cobra.Command, args []string) error { - // Default: all flags true - if !sandboxes && !providers && !k8s { + if !sandboxes && !providers && !k8sFlag { sandboxes = true providers = true - k8s = true + k8sFlag = true } gw := gateway.NewCLI(cli) @@ -45,8 +47,8 @@ func NewTeardownCmd(harnessDir, cli string) *cobra.Command { return err } } - if k8s { - return teardownK8s(harnessDir) + if k8sFlag { + teardownK8s(gw) } fmt.Println("Done.") @@ -56,7 +58,7 @@ func NewTeardownCmd(harnessDir, cli string) *cobra.Command { cmd.Flags().BoolVar(&sandboxes, "sandboxes", false, "Delete all sandboxes") cmd.Flags().BoolVar(&providers, "providers", false, "Delete all providers") - cmd.Flags().BoolVar(&k8s, "k8s", false, "Delete k8s resources") + cmd.Flags().BoolVar(&k8sFlag, "k8s", false, "Delete k8s resources") return cmd } @@ -129,6 +131,87 @@ func teardownProviders(gw gateway.Gateway, activeGW string) error { return nil } -func teardownK8s(harnessDir string) error { - return runner.RunScript(harnessDir, "teardown.sh", "--k8s") +func teardownK8s(gw gateway.Gateway) { + ctx := context.Background() + namespace := os.Getenv("OPENSHELL_NAMESPACE") + if namespace == "" { + namespace = "openshell" + } + + kc := k8s.New("", namespace) + kcNoNS := k8s.New("", "") + + if !kcNoNS.NamespaceExists(ctx, namespace) { + fmt.Println("=== K8s ===") + status.Info("No openshell namespace found, skipping") + return + } + + // Helm release + fmt.Println("=== Helm release ===") + if _, err := kc.RunHelm(ctx, "uninstall", "openshell"); err == nil { + status.Info("Uninstalled") + } else { + status.Info("Not installed") + } + + // Sandbox CRD namespace + fmt.Println() + fmt.Println("=== Sandbox CRD ===") + if _, err := kcNoNS.RunKubectl(ctx, "delete", "ns", "agent-sandbox-system"); err == nil { + status.Info("Deleted agent-sandbox-system") + } else { + status.Info("Not found") + } + + // OpenShift SCCs + fmt.Println() + fmt.Println("=== OpenShift SCCs ===") + for _, sa := range []string{"openshell", "openshell-sandbox", "default"} { + 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") + status.Info("Cleared") + + // Secrets + fmt.Println() + fmt.Println("=== K8s secrets ===") + for _, secret := range []string{"openshell-gws", "openshell-atlassian"} { + if _, err := kc.RunKubectl(ctx, "delete", "secret", secret); err == nil { + fmt.Printf(" Deleted %s\n", secret) + } else { + fmt.Printf(" %s: not found\n", secret) + } + } + + // Namespace + fmt.Println() + fmt.Println("=== Namespace ===") + if _, err := kcNoNS.RunKubectl(ctx, "delete", "ns", namespace); err == nil { + fmt.Printf(" Deleted %s\n", namespace) + } else { + fmt.Printf(" %s: not found\n", namespace) + } + + // Gateway config cleanup + fmt.Println() + fmt.Println("=== Gateway config ===") + gateways, _ := gw.GatewayList() + for _, g := range gateways { + if !strings.Contains(g.Endpoint, "127.0.0.1") { + if err := gw.GatewayRemove(g.Name); err == nil { + fmt.Printf(" Removed gateway '%s'\n", g.Name) + } + } + } + // Select local gateway if available + for _, g := range gateways { + if strings.Contains(g.Endpoint, "127.0.0.1") { + gw.GatewaySelect(g.Name) + break + } + } + + fmt.Println() } diff --git a/go.mod b/go.mod index 9ce1bc4..60edbb8 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.22.4 require ( github.com/BurntSushi/toml v1.6.0 github.com/spf13/cobra v1.10.2 + gopkg.in/yaml.v3 v3.0.1 ) require ( diff --git a/go.sum b/go.sum index c09a7f6..73408f2 100644 --- a/go.sum +++ b/go.sum @@ -9,4 +9,7 @@ github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiT github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/gateway/cli.go b/internal/gateway/cli.go index a267fce..9c4d2e5 100644 --- a/internal/gateway/cli.go +++ b/internal/gateway/cli.go @@ -183,6 +183,18 @@ func (c *CLI) GatewayList() ([]GatewayInfo, error) { return gateways, nil } +func (c *CLI) GatewayAdd(endpoint, name string, local bool) error { + args := []string{"gateway", "add", endpoint, "--name", name} + if local { + args = append(args, "--local") + } + return c.silent(args...) +} + +func (c *CLI) GatewayRemove(name string) error { + return c.silent("gateway", "remove", name) +} + func (c *CLI) GatewaySelect(name string) error { return c.silent("gateway", "select", name) } diff --git a/internal/gateway/gateway.go b/internal/gateway/gateway.go index 11618a1..34f16b4 100644 --- a/internal/gateway/gateway.go +++ b/internal/gateway/gateway.go @@ -47,6 +47,8 @@ type Gateway interface { SandboxExec(name string, command ...string) error // Gateway management + GatewayAdd(endpoint, name string, local bool) error + GatewayRemove(name string) error GatewayList() ([]GatewayInfo, error) GatewaySelect(name string) error } diff --git a/internal/k8s/kubectl.go b/internal/k8s/kubectl.go new file mode 100644 index 0000000..f0d237d --- /dev/null +++ b/internal/k8s/kubectl.go @@ -0,0 +1,199 @@ +package k8s + +import ( + "bytes" + "context" + "encoding/base64" + "fmt" + "io" + "os" + "os/exec" + "strings" + "time" + + "gopkg.in/yaml.v3" +) + +var transientErrors = []string{ + "connection refused", + "connection reset", + "timeout", + "etcd leader changed", + "the object has been modified", + "unable to connect to the server", + "TLS handshake timeout", + "i/o timeout", +} + +type Client struct { + kubeconfig string + namespace string +} + +func New(kubeconfig, namespace string) *Client { + return &Client{kubeconfig: kubeconfig, namespace: namespace} +} + +type KubectlOpts struct { + Args []string + Stdin io.Reader + Quiet bool +} + +func (c *Client) RunKubectl(ctx context.Context, args ...string) (string, error) { + return c.RunKubectlOpts(ctx, KubectlOpts{Args: args}) +} + +func (c *Client) RunKubectlOpts(ctx context.Context, opts KubectlOpts) (string, error) { + args := opts.Args + if c.namespace != "" && !containsFlag(args, "-n", "--namespace") { + args = append([]string{"-n", c.namespace}, args...) + } + if c.kubeconfig != "" { + args = append([]string{"--kubeconfig", c.kubeconfig}, args...) + } + + var lastErr error + for attempt := range 3 { + cmd := exec.CommandContext(ctx, "kubectl", args...) + if opts.Stdin != nil { + cmd.Stdin = opts.Stdin + } + + var stdout, stderr bytes.Buffer + if opts.Quiet { + cmd.Stdout = io.Discard + cmd.Stderr = io.Discard + } else { + cmd.Stdout = &stdout + cmd.Stderr = &stderr + } + + lastErr = cmd.Run() + if lastErr == nil { + return strings.TrimSpace(stdout.String()), nil + } + + errOutput := stderr.String() + lastErr.Error() + if !isTransient(errOutput) { + return "", fmt.Errorf("kubectl %s: %s", strings.Join(opts.Args, " "), strings.TrimSpace(stderr.String())) + } + + if attempt < 2 { + delay := time.Duration(1<&2 +exit 1 +`) + c := New("", "") + _, err := c.RunKubectl(context.Background(), "get", "ns", "nonexistent") + if err == nil { + t.Error("expected error") + } +} + +func TestRunKubectl_RetryOnTransient(t *testing.T) { + dir := t.TempDir() + counterFile := filepath.Join(dir, "count") + os.WriteFile(counterFile, []byte("0"), 0o644) + + writeStub(t, `#!/bin/bash +COUNT=$(cat `+counterFile+`) +COUNT=$((COUNT + 1)) +echo $COUNT > `+counterFile+` +if [ $COUNT -lt 2 ]; then + echo "connection refused" >&2 + exit 1 +fi +echo "ok" +`) + c := New("", "") + out, err := c.RunKubectl(context.Background(), "get", "pods") + if err != nil { + t.Fatalf("RunKubectl: %v (should have retried)", err) + } + if out != "ok" { + t.Errorf("output = %q", out) + } + data, _ := os.ReadFile(counterFile) + if string(data) != "2\n" { + t.Errorf("expected 2 attempts, got %s", data) + } +} + +func TestRunKubectl_NoRetryOnNonTransient(t *testing.T) { + dir := t.TempDir() + counterFile := filepath.Join(dir, "count") + os.WriteFile(counterFile, []byte("0"), 0o644) + + writeStub(t, `#!/bin/bash +COUNT=$(cat `+counterFile+`) +COUNT=$((COUNT + 1)) +echo $COUNT > `+counterFile+` +echo "resource not found" >&2 +exit 1 +`) + c := New("", "") + _, err := c.RunKubectl(context.Background(), "get", "secret", "missing") + if err == nil { + t.Error("expected error") + } + data, _ := os.ReadFile(counterFile) + if string(data) != "1\n" { + t.Errorf("expected 1 attempt (no retry), got %s", data) + } +} + +func TestRunKubectl_NamespaceInjection(t *testing.T) { + dir := t.TempDir() + argsFile := filepath.Join(dir, "args") + writeStub(t, `#!/bin/bash +printf '%s\n' "$*" > `+argsFile+` +`) + c := New("", "openshell") + c.RunKubectl(context.Background(), "get", "pods") + data, _ := os.ReadFile(argsFile) + args := string(data) + if args != "-n openshell get pods\n" { + t.Errorf("args = %q, expected namespace injection", args) + } +} + +func TestRunKubectl_KubeconfigInjection(t *testing.T) { + dir := t.TempDir() + argsFile := filepath.Join(dir, "args") + writeStub(t, `#!/bin/bash +printf '%s\n' "$*" > `+argsFile+` +`) + c := New("/path/to/kubeconfig", "") + c.RunKubectl(context.Background(), "get", "ns") + data, _ := os.ReadFile(argsFile) + args := string(data) + if args != "--kubeconfig /path/to/kubeconfig get ns\n" { + t.Errorf("args = %q, expected kubeconfig injection", args) + } +} + +func TestApplyYAML(t *testing.T) { + dir := t.TempDir() + stdinFile := filepath.Join(dir, "stdin") + writeStub(t, `#!/bin/bash +cat > `+stdinFile+` +`) + c := New("", "test-ns") + err := c.ApplyYAML(context.Background(), map[string]any{ + "apiVersion": "v1", + "kind": "ServiceAccount", + "metadata": map[string]any{"name": "test-sa"}, + }) + if err != nil { + t.Fatalf("ApplyYAML: %v", err) + } + data, _ := os.ReadFile(stdinFile) + content := string(data) + if !contains(content, "kind: ServiceAccount") { + t.Errorf("YAML missing ServiceAccount: %s", content) + } + if !contains(content, "name: test-sa") { + t.Errorf("YAML missing name: %s", content) + } +} + +func TestSecretExists(t *testing.T) { + writeStub(t, `#!/bin/bash +[[ "$*" == *"my-secret"* ]] && exit 0 +exit 1 +`) + c := New("", "default") + if !c.SecretExists(context.Background(), "my-secret") { + t.Error("expected secret to exist") + } + if c.SecretExists(context.Background(), "missing") { + t.Error("expected secret to not exist") + } +} + +func TestIsTransient(t *testing.T) { + if !isTransient("dial tcp: connection refused") { + t.Error("connection refused should be transient") + } + if !isTransient("etcd leader changed") { + t.Error("etcd leader changed should be transient") + } + if isTransient("resource not found") { + t.Error("not found should NOT be transient") + } +} + +func contains(s, substr string) bool { + return len(s) > 0 && len(substr) > 0 && s != substr && indexOf(s, substr) >= 0 +} + +func indexOf(s, substr string) int { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return i + } + } + return -1 +} diff --git a/internal/preflight/preflight.go b/internal/preflight/preflight.go index 7e2bdc0..365bc4a 100644 --- a/internal/preflight/preflight.go +++ b/internal/preflight/preflight.go @@ -33,8 +33,14 @@ type ProvidersFile struct { } type ConfigFile struct { - Providers []string `toml:"providers"` - ProvidersCustom []string `toml:"providers-custom"` + Providers []string `toml:"providers"` + ProvidersCustom []string `toml:"providers-custom"` + Upstream UpstreamConfig `toml:"upstream"` + ChartVersion string `toml:"-"` +} + +type UpstreamConfig struct { + ChartVersion string `toml:"chart-version"` } func LoadProviders(path string) ([]Provider, error) { @@ -53,6 +59,7 @@ func LoadConfig(path string) (*ConfigFile, error) { if _, err := toml.DecodeFile(path, &cf); err != nil { return nil, fmt.Errorf("reading %s: %w", path, err) } + cf.ChartVersion = cf.Upstream.ChartVersion return &cf, nil } diff --git a/internal/profile/profile_test.go b/internal/profile/profile_test.go index 0677286..e2a4292 100644 --- a/internal/profile/profile_test.go +++ b/internal/profile/profile_test.go @@ -41,6 +41,8 @@ func (m *mockGateway) SandboxDelete(string) error func (m *mockGateway) SandboxConnect(string) error { return nil } func (m *mockGateway) SandboxUpload(string, string, string) error { return nil } func (m *mockGateway) SandboxExec(string, ...string) error { return nil } +func (m *mockGateway) GatewayAdd(string, string, bool) error { return nil } +func (m *mockGateway) GatewayRemove(string) error { return nil } func (m *mockGateway) GatewayList() ([]gateway.GatewayInfo, error) { return nil, nil } func (m *mockGateway) GatewaySelect(string) error { return nil } diff --git a/main.go b/main.go index 50b0d9b..d80e046 100644 --- a/main.go +++ b/main.go @@ -35,6 +35,7 @@ func main() { ) if err := root.Execute(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } }