diff --git a/Makefile b/Makefile index 6e5d1ea..b9692c8 100644 --- a/Makefile +++ b/Makefile @@ -20,6 +20,7 @@ DEV_LAUNCHER_IMAGE := $(DEV_REGISTRY):$(DEV_TAG)-launcher .PHONY: cli sandbox push-sandbox cli-launcher launcher push-launcher \ vet lint test-unit test test-local test-kind test-ocp test-all validate \ validate-local validate-local-ci validate-kind validate-kind-ci \ + validate-ocp validate-ocp-ci \ dev-sandbox dev-launcher validate-dev clean help ## ── CLI ────────────────────────────────────────────────────────────── @@ -177,14 +178,19 @@ validate-local-ci: cli ./test/test-flow.sh local --ci ## Default validation on kind: unit tests + full integration with user credentials. -## Requires: kind create cluster --name openshell -validate-kind: cli +## Requires: kind create cluster --name openshell. Builds dev sandbox image to quay.io/rcochran. +validate-kind: cli dev-sandbox @echo "=== Unit tests ===" CGO_ENABLED=0 go test ./... cd sandbox/launcher && go test ./... @echo "" @echo "=== Integration: kind gateway, default mode ===" - ./test/test-flow.sh kind --full + @kubectl create namespace openshell --dry-run=client -o yaml 2>/dev/null | kubectl apply -f - 2>/dev/null || true + @kubectl create secret docker-registry quay-pull \ + --docker-server=quay.io --docker-username=rcochran \ + --docker-password="$$(python3 -c "import json,base64,pathlib; d=json.loads(pathlib.Path('$$HOME/.docker/config.json').read_text()); print(base64.b64decode(d['auths']['quay.io']['auth']).decode().split(':',1)[1])")" \ + -n openshell --dry-run=client -o yaml 2>/dev/null | kubectl apply -f - 2>/dev/null || true + SANDBOX_IMAGE=$(DEV_SANDBOX_IMAGE) SANDBOX_PULL_SECRET=quay-pull ./test/test-flow.sh kind --full ## CI validation on kind: unit tests + integration without credentials. ## Requires: kind create cluster --name openshell @@ -196,6 +202,24 @@ validate-kind-ci: cli @echo "=== Integration: kind gateway, ci mode ===" ./test/test-flow.sh kind --ci +## OCP validation with credentials (default mode). Requires: KUBECONFIG, all provider creds. +validate-ocp: cli dev-launcher dev-sandbox + @echo "=== Unit tests ===" + CGO_ENABLED=0 go test ./... + cd sandbox/launcher && go test ./... + @echo "" + @echo "=== Integration: OCP gateway, default mode ===" + SANDBOX_IMAGE=$(DEV_SANDBOX_IMAGE) LAUNCHER_IMAGE=$(DEV_LAUNCHER_IMAGE) ./test/test-flow.sh ocp --full + +## OCP validation without credentials (ci mode). Requires: KUBECONFIG, launcher image pushed. +validate-ocp-ci: cli dev-launcher + @echo "=== Unit tests ===" + CGO_ENABLED=0 go test ./... + cd sandbox/launcher && go test ./... + @echo "" + @echo "=== Integration: OCP gateway, ci mode ===" + LAUNCHER_IMAGE=$(DEV_LAUNCHER_IMAGE) ./test/test-flow.sh ocp --ci --full + ## ── Convenience targets ─────────────────────────────────────────────── ## Clean built binaries diff --git a/cmd/create.go b/cmd/create.go index e9cf3d2..0a67068 100644 --- a/cmd/create.go +++ b/cmd/create.go @@ -49,6 +49,7 @@ func NewCreateCmd(harnessDir, cli string) *cobra.Command { if sandboxName != "" { cfg.Name = sandboxName } + injectAtlassianEnv(cfg) status.Section("Profile") fmt.Printf(" Name: %s\n", cfg.Name) @@ -203,19 +204,6 @@ func createDirect(harnessDir string, gw gateway.Gateway, profileName string, cfg } } - providersPath := filepath.Join(harnessDir, "providers.toml") - if allProviders, err := preflight.LoadProviders(providersPath); err == nil { - providerEnv := preflight.ProviderEnvVars(allProviders, cfg.Providers) - if cfg.Env == nil { - cfg.Env = make(map[string]string) - } - for k, v := range providerEnv { - if _, exists := cfg.Env[k]; !exists { - cfg.Env[k] = v - } - } - } - tmpParent, err := os.MkdirTemp("", "harness-") if err != nil { return fmt.Errorf("creating staging dir: %w", err) diff --git a/cmd/creds.go b/cmd/creds.go deleted file mode 100644 index d0de4f9..0000000 --- a/cmd/creds.go +++ /dev/null @@ -1,45 +0,0 @@ -package cmd - -import ( - "context" - "fmt" - "os" - - "github.com/robbycochran/harness-openshell/internal/k8s" - "github.com/robbycochran/harness-openshell/internal/status" -) - -func ensureCreds(kc k8s.Runner, namespace string, force bool) error { - ctx := context.Background() - - if !kc.NamespaceExists(ctx, namespace) { - return fmt.Errorf("namespace '%s' not found — run: harness deploy --remote", namespace) - } - - status.Section("Setting up cluster credentials") - status.Detailf("Namespace: %s", namespace) - - // Atlassian credentials - status.Section("Atlassian") - if force && kc.SecretExists(ctx, "openshell-atlassian") { - kc.RunKubectl(ctx, "delete", "secret", "openshell-atlassian") - status.Info("Deleted existing secret") - } - - if kc.SecretExists(ctx, "openshell-atlassian") { - 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", - "--from-literal=JIRA_URL="+jiraURL, - "--from-literal=JIRA_USERNAME="+jiraUser) - if err != nil { - return fmt.Errorf("creating atlassian secret: %w", err) - } - status.OKf("openshell-atlassian: created (%s)", jiraURL) - } else { - status.Info("Atlassian: JIRA_URL not set (skipping)") - } - - return nil -} diff --git a/cmd/creds_test.go b/cmd/creds_test.go deleted file mode 100644 index 5b86554..0000000 --- a/cmd/creds_test.go +++ /dev/null @@ -1,77 +0,0 @@ -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 atlassian secret - if nsRunner.CallCount("delete secret") != 1 { - t.Errorf("expected 1 secret delete, 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.go b/cmd/deploy.go index b3f36d7..79adaf8 100644 --- a/cmd/deploy.go +++ b/cmd/deploy.go @@ -264,7 +264,8 @@ func deployFromConfig(harnessDir string, gwCfg *gateway.GatewayConfig, gw gatewa return fmt.Errorf("listing existing gateways: %w", err) } for _, g := range existing { - if routeHost != "" && strings.Contains(g.Endpoint, routeHost) { + // Remove stale registration for same name or same route host (idempotent re-deploy). + if g.Name == gatewayName || (routeHost != "" && strings.Contains(g.Endpoint, routeHost)) { gw.GatewayRemove(g.Name) } } diff --git a/cmd/up.go b/cmd/up.go index 88460c3..e16f7a5 100644 --- a/cmd/up.go +++ b/cmd/up.go @@ -11,7 +11,6 @@ import ( "github.com/robbycochran/harness-openshell/internal/gateway" "github.com/robbycochran/harness-openshell/internal/k8s" - "github.com/robbycochran/harness-openshell/internal/preflight" "github.com/robbycochran/harness-openshell/internal/profile" "github.com/robbycochran/harness-openshell/internal/status" "github.com/spf13/cobra" @@ -113,11 +112,6 @@ func upRemote(harnessDir string, gwCfg *gateway.GatewayConfig, gw gateway.Gatewa } } - // 3. Ensure credentials - if err := ensureCreds(kc, namespace, false); err != nil { - return fmt.Errorf("credentials setup failed: %w", err) - } - // Parse profile cfg, err := profile.Parse(harnessDir, profileName) if err != nil { @@ -126,6 +120,7 @@ func upRemote(harnessDir string, gwCfg *gateway.GatewayConfig, gw gateway.Gatewa if sandboxName != "" { cfg.Name = sandboxName } + injectAtlassianEnv(cfg) // Resolve sandbox image for remote deploys. // SANDBOX_IMAGE env var overrides everything (dev/CI builds). @@ -306,9 +301,13 @@ func upLocal(opts upLocalOpts) error { if opts.sandboxName != "" { cfg.Name = opts.sandboxName } + injectAtlassianEnv(cfg) - // Resolve Dockerfile path relative to harnessDir - if cfg.From != "" && !filepath.IsAbs(cfg.From) { + // SANDBOX_IMAGE env var overrides the profile's image (dev/CI builds). + if envImage := os.Getenv("SANDBOX_IMAGE"); envImage != "" { + cfg.From = envImage + } else if cfg.From != "" && !filepath.IsAbs(cfg.From) { + // Resolve Dockerfile path relative to harnessDir candidate := filepath.Join(opts.harnessDir, cfg.From) if info, err := os.Stat(candidate); err == nil && info.IsDir() { cfg.From = candidate @@ -334,21 +333,7 @@ func upLocal(opts upLocalOpts) error { status.Warn("no providers available — run: harness providers") } - // 5. Inject non-secret provider env vars into sandbox env - providersPath := filepath.Join(opts.harnessDir, "providers.toml") - if allProviders, err := preflight.LoadProviders(providersPath); err == nil { - providerEnv := preflight.ProviderEnvVars(allProviders, cfg.Providers) - if cfg.Env == nil { - cfg.Env = make(map[string]string) - } - for k, v := range providerEnv { - if _, exists := cfg.Env[k]; !exists { - cfg.Env[k] = v - } - } - } - - // 6. Stage files + // 5. Stage files // The upload preserves the source dir name as a subdirectory at the destination. // startup.sh expects files at /sandbox/.config/openshell/sandbox.env, so the // staging dir must be named "openshell". @@ -407,6 +392,22 @@ func upLocal(opts upLocalOpts) error { return nil // unreachable but required by compiler } +// injectAtlassianEnv adds JIRA_URL and JIRA_USERNAME from the local environment +// into cfg.Env so they land in sandbox.env. Interim until Phase 2 payload renderer +// ships; remove when harness create renders payload/env.sh instead. +func injectAtlassianEnv(cfg *profile.Config) { + if cfg.Env == nil { + cfg.Env = make(map[string]string) + } + for _, key := range []string{"JIRA_URL", "JIRA_USERNAME"} { + if _, exists := cfg.Env[key]; !exists { + if v := os.Getenv(key); v != "" { + cfg.Env[key] = v + } + } + } +} + func launcherEnv(gatewayEndpoint, sandboxImage string) []map[string]any { env := []map[string]any{ {"name": "GATEWAY_ENDPOINT", "value": gatewayEndpoint}, diff --git a/cmd/up_test.go b/cmd/up_test.go index c3154eb..3c01b6e 100644 --- a/cmd/up_test.go +++ b/cmd/up_test.go @@ -190,6 +190,7 @@ func TestProfileHasCustomProviders(t *testing.T) { } func TestUpLocal_SandboxCreateOpts(t *testing.T) { + t.Setenv("SANDBOX_IMAGE", "") // prevent env leak from Makefile into profile.From check dir := setupTestProfile(t) gw := &mockGW{ providerList: []string{"github", "vertex-local"}, diff --git a/gateways/ocp/gateway.toml b/gateways/ocp/gateway.toml index a815042..fcb9985 100644 --- a/gateways/ocp/gateway.toml +++ b/gateways/ocp/gateway.toml @@ -39,7 +39,6 @@ scc-privileged = ["openshell", "openshell-sandbox"] scc-anyuid = ["openshell"] [secrets] -names = ["openshell-atlassian"] mtls = "openshell-client-tls" [launcher] diff --git a/internal/preflight/preflight.go b/internal/preflight/preflight.go index 996e325..14fa014 100644 --- a/internal/preflight/preflight.go +++ b/internal/preflight/preflight.go @@ -26,10 +26,9 @@ type Provider struct { } type Input struct { - Key string `toml:"key"` - Kind string `toml:"kind"` - Secret bool `toml:"secret"` - Sandbox bool `toml:"sandbox"` + Key string `toml:"key"` + Kind string `toml:"kind"` + Secret bool `toml:"secret"` } type ProvidersFile struct { @@ -85,31 +84,6 @@ func EnabledProviders(all []Provider, config *ConfigFile) []Provider { return result } -// ProviderEnvVars returns environment variables marked with sandbox = true -// for the named providers, resolved from the local environment. Only -// includes env vars that are set. -func ProviderEnvVars(providers []Provider, names []string) map[string]string { - wanted := make(map[string]bool, len(names)) - for _, n := range names { - wanted[n] = true - } - env := make(map[string]string) - for _, p := range providers { - if !wanted[p.Name] { - continue - } - for _, inp := range p.Inputs { - if inp.Kind != "env" || !inp.Sandbox { - continue - } - if val := os.Getenv(inp.Key); val != "" { - env[inp.Key] = val - } - } - } - return env -} - func CheckInput(inp Input) (bool, string) { switch inp.Kind { case "env": diff --git a/providers.toml b/providers.toml index 2188ed3..41b71de 100644 --- a/providers.toml +++ b/providers.toml @@ -29,8 +29,8 @@ type = "openshell" description = "Jira and Confluence (read-only, Basic auth resolved by proxy)" inputs = [ { key = "JIRA_API_TOKEN", kind = "env", secret = true }, - { key = "JIRA_URL", kind = "env", sandbox = true }, - { key = "JIRA_USERNAME", kind = "env", sandbox = true }, + { key = "JIRA_URL", kind = "env" }, + { key = "JIRA_USERNAME", kind = "env" }, { key = "curl -sf ${JIRA_URL}/rest/api/2/serverInfo -o /dev/null", kind = "check" }, { key = "curl -sf -u ${JIRA_USERNAME}:${JIRA_API_TOKEN} ${JIRA_URL}/rest/api/2/myself -o /dev/null", kind = "check" }, ] diff --git a/test/test-flow.sh b/test/test-flow.sh index 9a2f48a..192120a 100755 --- a/test/test-flow.sh +++ b/test/test-flow.sh @@ -119,10 +119,36 @@ check_providers() { fi } +sandbox_wait() { + # Poll for sandbox Ready, failing fast on k8s bad states (ImagePullBackOff, etc.) + local name="$1" + for i in $(seq 1 30); do + local phase + phase=$("$CLI" sandbox list 2>/dev/null | strip_ansi | awk -v n="$name" '$1==n {print $NF}') + [[ "$phase" == "Ready" ]] && return 0 + + # On k8s targets, check for pod bad states so we fail fast instead of timing out + if kubectl get pods -n openshell 2>/dev/null | grep -q "ImagePullBackOff\|ErrImagePull\|CrashLoopBackOff"; then + local bad + bad=$(kubectl get pods -n openshell 2>/dev/null | grep "ImagePullBackOff\|ErrImagePull\|CrashLoopBackOff" | awk '{print $1, $3}' | head -3) + echo " ERROR: k8s pod in bad state: $bad" >&2 + return 1 + fi + sleep 2 + done + return 1 +} + sandbox_verify() { local name="$1" local phase - phase=$("$CLI" sandbox list 2>/dev/null | strip_ansi | awk -v n="$name" '$1==n {print $NF}') + if ! sandbox_wait "$name"; then + phase=$("$CLI" sandbox list 2>/dev/null | strip_ansi | awk -v n="$name" '$1==n {print $NF}') + printf " ✗ %-35s %s\n" "sandbox ready" "(phase: ${phase:-not found})" + ((FAIL++)) + return + fi + phase="Ready" if [[ "$phase" != "Ready" ]]; then printf " ✗ %-35s\n" "sandbox ready" ((FAIL++)) @@ -233,18 +259,15 @@ test_gws() { 'echo "$GOOGLE_WORKSPACE_CLI_TOKEN" | grep -q "openshell:resolve:env"' # Real API call works through proxy (token resolved on the wire) + # Note: sandbox exec rejects newlines in args — keep curl on one line. step "gws: Gmail API via proxy" \ - "$CLI" sandbox exec --name "$sandbox_name" -- bash -c \ - 'curl -sf https://gmail.googleapis.com/gmail/v1/users/me/profile \ - -H "Authorization: Bearer $GOOGLE_WORKSPACE_CLI_TOKEN" -o /dev/null' + "$CLI" sandbox exec --name "$sandbox_name" -- bash -c 'curl -sf https://gmail.googleapis.com/gmail/v1/users/me/profile -H "Authorization: Bearer $GOOGLE_WORKSPACE_CLI_TOKEN" -o /dev/null' # Force gateway token rotation, verify sandbox still works openshell provider refresh rotate gws \ --credential-key GOOGLE_WORKSPACE_CLI_TOKEN &>/dev/null step "gws: works after rotation" \ - "$CLI" sandbox exec --name "$sandbox_name" -- bash -c \ - 'curl -sf https://gmail.googleapis.com/gmail/v1/users/me/profile \ - -H "Authorization: Bearer $GOOGLE_WORKSPACE_CLI_TOKEN" -o /dev/null' + "$CLI" sandbox exec --name "$sandbox_name" -- bash -c 'curl -sf https://gmail.googleapis.com/gmail/v1/users/me/profile -H "Authorization: Bearer $GOOGLE_WORKSPACE_CLI_TOKEN" -o /dev/null' echo "" } @@ -313,19 +336,24 @@ test_ocp() { step "deploy" "$HARNESS" deploy --remote fi - step "setup providers" "$HARNESS" providers - step "gateway reachable" "$CLI" inference get - check_providers + if ! $NO_PROVIDERS; then + step "setup providers" "$HARNESS" providers + step "gateway reachable" "$CLI" inference get + check_providers + fi if $FULL; then - step_output "sandbox create (up)" "$HARNESS" up --remote - local sandbox_name="agent" - - for i in $(seq 1 30); do - local phase=$("$CLI" sandbox list 2>/dev/null | strip_ansi | awk -v n="$sandbox_name" '$1==n {print $NF}') - [[ "$phase" == "Ready" ]] && break - sleep 2 - done + local sandbox_name + if $NO_PROVIDERS; then + # ci mode: use harness create (skips provider registration) with public ci profile + sandbox_name="test-ocp" + step_output "sandbox create" "$HARNESS" create --profile=ci --name "$sandbox_name" + else + # default mode: full up (deploy already done above, providers registered) + sandbox_name="agent" + step_output "sandbox create (up)" "$HARNESS" up --remote --name "$sandbox_name" --no-tty + fi + sandbox_verify "$sandbox_name" step "sandbox delete" "$CLI" sandbox delete "$sandbox_name" fi