diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 816f3c42..550f8aa9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -106,7 +106,7 @@ jobs: env: # Path to the built binary for this matrix leg. On Windows it forwards the # whole CLI into WSL; on Linux it runs natively. Used by every test step. - OF_BIN: ./openframe-${{ matrix.os }}-${{ matrix.arch }}${{ matrix.os == 'windows' && '.exe' || '' }} + OF_BIN: ./build/openframe-${{ matrix.os }}-${{ matrix.arch }}${{ matrix.os == 'windows' && '.exe' || '' }} # Cluster used by the lifecycle steps; its k3d kube-context is k3d-. OF_CLUSTER: openframe-test OF_CONTEXT: k3d-openframe-test @@ -165,8 +165,8 @@ jobs: # published release to auto-download, so build the Linux binary and # stream it into the default WSL distro's ~/.openframe/bin via stdin — # no Windows→WSL path translation, which is where the launcher looks. - GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o openframe-linux-amd64 . - wsl -- bash -c 'mkdir -p "$HOME/.openframe/bin" && cat > "$HOME/.openframe/bin/openframe" && chmod +x "$HOME/.openframe/bin/openframe"' < openframe-linux-amd64 + GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o build/openframe-linux-amd64 . + wsl -- bash -c 'mkdir -p "$HOME/.openframe/bin" && cat > "$HOME/.openframe/bin/openframe" && chmod +x "$HOME/.openframe/bin/openframe"' < build/openframe-linux-amd64 fi # --- Simplest: no cluster, read-only (all platforms, incl. darwin) ----- @@ -345,6 +345,70 @@ jobs: echo "===================================================================" "$OF_BIN" prerequisites check || echo "some prerequisites missing (expected pre-bootstrap)" + # Cloud-cluster command SURFACE, with no cloud and no Docker: the EKS + # coming-soon banner, flag validation, the GKE dry-run's graceful + # degradation, and `cluster use` error paths. Everything here is either + # exit-0 by design or an expected, asserted failure — no resource is + # ever created. Pure, so it runs on every OS (on Windows via the WSL + # forward, exercising the Linux binary's paths). + - name: 'Cluster: cloud command surface (no cloud, no Docker)' + shell: bash + run: | + echo "===================================================================" + echo "=== TEST: cloud cluster surface — banner, validation, dry-run, use" + echo "===================================================================" + fail() { echo "::error::$1"; exit 1; } + # Portable hang guard: macOS runners ship coreutils' gtimeout, not + # timeout. Fall back to no guard rather than failing with exit 127. + run_to() { + local t="$1"; shift + if command -v timeout >/dev/null 2>&1; then timeout "$t" "$@" + elif command -v gtimeout >/dev/null 2>&1; then gtimeout "$t" "$@" + else "$@"; fi + } + + echo "--- EKS create is gated: banner + non-zero exit (scripts must not see success)" + if "$OF_BIN" cluster create eks-smoke --type eks --skip-wizard >/tmp/eks.out 2>&1; then + cat /tmp/eks.out; fail "gated eks create must exit non-zero" + fi + grep -qi "coming soon" /tmp/eks.out || { cat /tmp/eks.out; fail "eks banner text missing"; } + + echo "--- unknown --type is rejected" + if "$OF_BIN" cluster create x --type minikube --skip-wizard >/tmp/neg.out 2>&1; then + cat /tmp/neg.out; fail "unknown cluster type must be rejected" + fi + grep -q "unknown cluster type" /tmp/neg.out || { cat /tmp/neg.out; fail "missing unknown-type message"; } + + echo "--- gke --skip-wizard demands --region and --project" + if "$OF_BIN" cluster create x --type gke --skip-wizard >/tmp/neg.out 2>&1; then + cat /tmp/neg.out; fail "gke without region/project must be rejected" + fi + grep -qE '\-\-(region|project) is required' /tmp/neg.out || { cat /tmp/neg.out; fail "missing required-flag message"; } + + echo "--- gke dry-run degrades gracefully without terraform/credentials" + # Two legitimate outcomes on an unauthenticated runner: terraform absent + # -> soft skip (exit 0); terraform present -> the auth flow fails fast + # (non-interactive, exit non-zero). Both must be actionable, never a hang. + set +e + run_to 120 "$OF_BIN" cluster create gke-smoke --type gke \ + --project no-such-project --region us-central1 --skip-wizard --dry-run /tmp/gke.out 2>&1 + code=$? + set -e + [ "$code" -eq 124 ] && { cat /tmp/gke.out; fail "gke dry-run hung on a prompt"; } + grep -qiE "skipping the plan preview|not authenticated|not installed|application default|not accessible" /tmp/gke.out \ + || { cat /tmp/gke.out; fail "gke dry-run produced neither a skip nor an auth hint (exit $code)"; } + echo "OK (exit $code)" + + echo "--- cluster use of an unknown cluster is an actionable error" + set +e + run_to 60 "$OF_BIN" cluster use no-such-cluster /tmp/use.out 2>&1 + code=$? + set -e + [ "$code" -eq 124 ] && { cat /tmp/use.out; fail "cluster use hung on a prompt"; } + [ "$code" -eq 0 ] && { cat /tmp/use.out; fail "cluster use of an unknown cluster must fail"; } + grep -qiE "not (known|found) locally|gcloud" /tmp/use.out || { cat /tmp/use.out; fail "missing actionable use error"; } + echo "cloud surface OK" + # Everything below needs a Docker daemon + k3d. It runs on linux and on # windows (the Windows binary forwards into WSL, where Docker was started # above), exercising the real cluster lifecycle on both. darwin stays @@ -360,6 +424,68 @@ jobs: "$OF_BIN" cluster list -o json "$OF_BIN" cluster list -o yaml + # Managed-cloud OWNERSHIP GUARDS, exercised on the real binary against a + # fake GKE workspace (an isolated OPENFRAME_CLUSTERS_DIR with a + # hand-written cluster.json) — no cloud, no terraform, fully + # deterministic. Locks: registry-backed list/status, the typed-confirm + # refusal on non-interactive cloud delete, and use's missing-context + # error. Linux only: one leg suffices and the Windows binary would need + # the fake workspace inside WSL's home. + - name: 'Cluster: managed-cloud guards (fake workspace, no cloud)' + if: matrix.os == 'linux' + shell: bash + env: + OPENFRAME_CLUSTERS_DIR: ${{ runner.temp }}/of-fake-clusters + run: | + echo "===================================================================" + echo "=== TEST: cloud ownership guards on a fake GKE workspace" + echo "===================================================================" + fail() { echo "::error::$1"; exit 1; } + export KUBECONFIG="$RUNNER_TEMP/of-fake-kubeconfig" + + mkdir -p "$OPENFRAME_CLUSTERS_DIR/fake-gke" + cat > "$OPENFRAME_CLUSTERS_DIR/fake-gke/cluster.json" <<'EOF' + {"name":"fake-gke","type":"gke","status":"ready","region":"us-central1", + "project":"fake-project","node_count":3,"created_at":"2026-07-22T00:00:00Z", + "endpoint":"https://203.0.113.10","ca_cert":"ZmFrZS1jYQ=="} + EOF + + echo "--- the registry cluster appears in list with source=openframe" + "$OF_BIN" cluster list -o json | jq -e \ + '.[] | select(.name == "fake-gke")' >/dev/null \ + || fail "fake-gke missing from cluster list -o json" + + echo "--- status resolves through the registry (no cloud calls)" + "$OF_BIN" cluster status fake-gke -o json | jq -e \ + '.type == "gke"' >/dev/null || fail "status -o json lacks type gke" + + echo "--- use without a kubeconfig context fails with the exact hint" + set +e + "$OF_BIN" cluster use fake-gke /tmp/use.out 2>&1 + code=$? + set -e + [ "$code" -eq 0 ] && { cat /tmp/use.out; fail "use without a context must fail"; } + grep -q "no kubeconfig context" /tmp/use.out || { cat /tmp/use.out; fail "missing no-context hint"; } + + echo "--- non-interactive cloud delete WITHOUT --force refuses" + # Two refusal gates guard this path, and either may fire first: the + # generic deletion confirmation ("confirmation required ... re-run + # with --force") refuses before the cloud-specific typed-confirm + # gate ("refusing to destroy cloud cluster") is reached. Both are + # correct refusals — assert the guarantee (non-zero exit, actionable + # message, workspace survives), not which gate fired. + set +e + timeout 60 "$OF_BIN" cluster delete fake-gke /tmp/del.out 2>&1 + code=$? + set -e + [ "$code" -eq 124 ] && { cat /tmp/del.out; fail "cluster delete hung on a prompt"; } + [ "$code" -eq 0 ] && { cat /tmp/del.out; fail "non-interactive cloud delete without --force must refuse"; } + grep -qE "refusing to destroy cloud cluster|confirmation required.*--force" /tmp/del.out || { cat /tmp/del.out; fail "missing an actionable refusal message"; } + + echo "--- the workspace (the only pointer to billed resources) survived" + [ -f "$OPENFRAME_CLUSTERS_DIR/fake-gke/cluster.json" ] || fail "refused delete must not remove the workspace" + echo "ownership guards OK" + # --- Medium: dry-run, still no real changes ---------------------------- - name: 'CLI: dry-run create & install' if: matrix.os != 'darwin' @@ -474,6 +600,11 @@ jobs: if: matrix.os != 'darwin' shell: bash timeout-minutes: 40 + env: + # Cap the CLI's app-wait below this step's 40m limit so it fails with + # its OWN diagnostic (stuck apps + health messages) instead of being + # killed opaquely by the job timeout. + OPENFRAME_APP_WAIT_TIMEOUT: 35m run: | echo "===================================================================" echo "=== TEST: real app install (non-interactive, full ArgoCD wait)" diff --git a/Makefile b/Makefile index bc4d6831..4a5d2458 100644 --- a/Makefile +++ b/Makefile @@ -1,9 +1,10 @@ # OpenFrame CLI Makefile -.PHONY: all build build-all clean test test-unit test-race test-integration lint fmt vet tidy help +.PHONY: all build build-all clean test test-unit test-race test-integration test-tfvalidate lint fmt vet tidy help # Variables BINARY_NAME := openframe +BUILD_DIR := build # -trimpath drops absolute build paths from the binary (reproducibility), matching # the release build in .goreleaser.yml. GO_BUILD := CGO_ENABLED=0 go build -trimpath @@ -22,18 +23,20 @@ UNIT_PKGS := . ./cmd/... ./internal/... ./tests/testutil/... ./tests/scripts/... # Default target all: build -build: ## Build binary for the current platform - @echo "Building $(BINARY_NAME) for $(GOOS)/$(GOARCH)..." - @$(GO_BUILD) -o $(BINARY_NAME)-$(GOOS)-$(GOARCH)$(BINARY_SUFFIX) . - -build-all: ## Cross-compile every release platform (matches .goreleaser.yml) - @echo "Building $(BINARY_NAME) for all release platforms..." - @GOOS=linux GOARCH=amd64 $(GO_BUILD) -o $(BINARY_NAME)-linux-amd64 . - @GOOS=linux GOARCH=arm64 $(GO_BUILD) -o $(BINARY_NAME)-linux-arm64 . - @GOOS=darwin GOARCH=amd64 $(GO_BUILD) -o $(BINARY_NAME)-darwin-amd64 . - @GOOS=darwin GOARCH=arm64 $(GO_BUILD) -o $(BINARY_NAME)-darwin-arm64 . - @GOOS=windows GOARCH=amd64 $(GO_BUILD) -o $(BINARY_NAME)-windows-amd64.exe . - @GOOS=windows GOARCH=arm64 $(GO_BUILD) -o $(BINARY_NAME)-windows-arm64.exe . +build: ## Build binary into $(BUILD_DIR)/ for the current platform + @echo "Building $(BINARY_NAME) for $(GOOS)/$(GOARCH) into $(BUILD_DIR)/..." + @mkdir -p $(BUILD_DIR) + @$(GO_BUILD) -o $(BUILD_DIR)/$(BINARY_NAME)-$(GOOS)-$(GOARCH)$(BINARY_SUFFIX) . + +build-all: ## Cross-compile every release platform into $(BUILD_DIR)/ (matches .goreleaser.yml) + @echo "Building $(BINARY_NAME) for all release platforms into $(BUILD_DIR)/..." + @mkdir -p $(BUILD_DIR) + @GOOS=linux GOARCH=amd64 $(GO_BUILD) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 . + @GOOS=linux GOARCH=arm64 $(GO_BUILD) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 . + @GOOS=darwin GOARCH=amd64 $(GO_BUILD) -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-amd64 . + @GOOS=darwin GOARCH=arm64 $(GO_BUILD) -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 . + @GOOS=windows GOARCH=amd64 $(GO_BUILD) -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe . + @GOOS=windows GOARCH=arm64 $(GO_BUILD) -o $(BUILD_DIR)/$(BINARY_NAME)-windows-arm64.exe . test-unit: ## Run unit tests (vet on; incl. root main_test.go + tests/testutil) @echo "Running unit tests..." @@ -51,6 +54,15 @@ test-integration: ## Run integration tests (real k3d clusters; needs docker + k3 @echo "Running integration tests (real clusters!)..." @go test -tags integration -count=1 ./tests/integration/... +# Terraform template validation is opt-in via a build tag: it runs a real +# `terraform init` (downloads the pinned modules + providers — network, ~min) +# and `terraform validate` on the generated EKS/GKE root modules. No cloud +# credentials needed; the strongest pre-e2e check of the templates. +test-tfvalidate: ## Validate generated terraform templates (needs terraform + network) + @echo "Validating terraform templates (downloads providers)..." + @go test -tags tfvalidate -count=1 -timeout 15m -run '^TestTerraformValidate$$' \ + ./internal/cluster/providers/eks/... ./internal/cluster/providers/gke/... + test: test-unit test-integration ## Run unit + integration tests lint: ## Run golangci-lint (govet, staticcheck, errcheck, gosec, ineffassign) @@ -72,7 +84,8 @@ tidy: ## Check go.mod/go.sum are tidy (fails if `go mod tidy` would change them) # and deleted them. clean: ## Remove build artifacts @rm -f $(BINARY_NAME) \ - $(BINARY_NAME)-linux-* $(BINARY_NAME)-darwin-* $(BINARY_NAME)-windows-* + $(BINARY_NAME)-linux-* $(BINARY_NAME)-darwin-* $(BINARY_NAME)-windows-* \ + $(BUILD_DIR)/$(BINARY_NAME)-* @echo "Cleaned build artifacts" help: ## Show this help diff --git a/README.md b/README.md index 70b681cb..24fad29e 100644 --- a/README.md +++ b/README.md @@ -1,263 +1,254 @@
- - - OpenFrame + + + OpenFrame

License - Latest Release - Slack

# OpenFrame CLI -**OpenFrame CLI** is a modern, interactive command-line tool written in Go that bootstraps and manages [OpenFrame](https://openframe.ai) Kubernetes environments. With a single `openframe` binary, you can provision local K3D clusters, install the full OpenFrame platform stack via ArgoCD GitOps, and manage the entire lifecycle of your deployment — through both guided interactive wizards and fully scriptable non-interactive modes. +OpenFrame CLI is a modern, interactive command-line tool for managing OpenFrame Kubernetes clusters and development workflows. It provides seamless cluster lifecycle management, chart installation with ArgoCD, and developer-friendly tools for service intercepts and scaffolding. -> **OpenFrame** is the unified platform from [Flamingo](https://flamingo.run) that integrates multiple MSP tools into a single AI-driven interface, automating IT support operations across the stack. +[![OpenFrame Preview Webinar](https://img.youtube.com/vi/bINdW0CQbvY/maxresdefault.jpg)](https://www.youtube.com/watch?v=bINdW0CQbvY) ---- +## What is OpenFrame CLI? -## ✨ Features +OpenFrame CLI is part of the broader [OpenFrame](https://openframe.ai) ecosystem - an AI-powered MSP platform that replaces expensive proprietary software with open-source alternatives enhanced by intelligent automation. The CLI serves as the entry point for developers and operators to bootstrap, manage, and develop on OpenFrame environments. -| Feature | Description | -|---|---| -| **One-command bootstrap** | `openframe bootstrap` provisions a cluster and deploys the full platform in one step | -| **Interactive wizards** | Step-by-step guided prompts for new users — no YAML editing required | -| **Non-interactive / CI mode** | `--non-interactive` flag makes every command scriptable for pipelines | -| **ArgoCD GitOps integration** | Platform deployment is fully GitOps-driven using the `openframe-oss-tenant` chart | -| **Auto-prerequisite management** | Detects and installs Docker, k3d, and Helm automatically on macOS/Linux | -| **Cosign signature verification** | All self-updates are cryptographically verified against the official release workflow | -| **WSL2 support on Windows** | Transparently re-executes inside WSL2 — no manual Linux setup needed | -| **Secret redaction** | Credentials and tokens are automatically scrubbed from all debug output | -| **Machine-readable output** | `--output json/yaml` for clean scripted consumption | +## Key Features ---- +### 🚀 Complete Environment Bootstrapping +- **One-command setup**: Bootstrap entire OpenFrame environments with `openframe bootstrap` +- **OSS-tenant deployment**: Installs the public `openframe-oss-tenant` chart — no credentials or mode selection needed +- **Automated cluster creation**: Creates K3D clusters with all necessary components +- **ArgoCD integration**: Automatic chart installation and application management + +### 🔧 Cluster Management +- **Lifecycle operations**: Create, delete, list, and monitor Kubernetes clusters +- **K3D integration**: Lightweight Kubernetes for development and testing +- **Status monitoring**: Real-time cluster health and resource monitoring +- **Easy cleanup**: Remove clusters and associated resources with simple commands -## 🏗️ Architecture +### 📦 Chart & Application Management +- **Helm chart installation**: Streamlined chart deployment with dependency management +- **ArgoCD applications**: GitOps-based application lifecycle management +- **App-of-apps pattern**: Hierarchical application management for complex deployments +- **Synchronization monitoring**: Track deployment progress with detailed logging + +### 🛠 Development Tools +- **Service intercepts**: Local development with Telepresence integration +- **Scaffolding**: Generate boilerplate code and configurations +- **Live debugging**: Debug services running in Kubernetes from your local environment +- **Hot reload**: Rapid development cycles with instant feedback + +## Architecture Overview ```mermaid graph TB - subgraph CLI["CLI Entry Point"] - main["main.go"] - root["cmd/root.go"] - end - - subgraph Commands["Command Layer"] - bootstrap["bootstrap"] - cluster_cmd["cluster"] - app_cmd["app"] - prereq_cmd["prerequisites"] - update_cmd["update"] + subgraph "CLI Commands" + Bootstrap[openframe bootstrap] + Cluster[openframe cluster] + App[openframe app] + Dev[openframe dev] end - - subgraph Services["Service Layer"] - bsvc["bootstrap.Service"] - csvc["cluster.ClusterService"] - chsvc["chart/services.ChartService"] - prefw["prerequisites.Runner"] - supdater["selfupdate.Updater"] + + subgraph "Core Services" + ClusterSvc[Cluster Management] + ChartSvc[Chart Installation] + DevSvc[Development Tools] end - - subgraph Providers["Providers"] - k3dp["K3D Provider"] - argop["ArgoCD Manager"] - helmp["Helm Manager"] - gitp["Git Repository"] + + subgraph "External Tools" + K3D[K3D Clusters] + Helm[Helm Charts] + ArgoCD[ArgoCD Apps] + Telepresence[Service Intercepts] end - - subgraph Platform["OpenFrame Platform"] - k3d["K3D Kubernetes Cluster"] - argocd["ArgoCD GitOps Engine"] - openframe["OpenFrame OSS Tenant Chart"] + + subgraph "Target Environment" + K8s[Kubernetes] + Apps[Applications] + Services[Microservices] end - - main --> root - root --> Commands - bootstrap --> bsvc - cluster_cmd --> csvc - app_cmd --> chsvc - prereq_cmd --> prefw - update_cmd --> supdater - - bsvc --> csvc - bsvc --> chsvc - csvc --> k3dp - chsvc --> argop - chsvc --> helmp - chsvc --> gitp - - k3dp --> k3d - argop --> argocd - argocd --> openframe + + Bootstrap --> ClusterSvc + Bootstrap --> ChartSvc + Cluster --> ClusterSvc + App --> ChartSvc + Dev --> DevSvc + + ClusterSvc --> K3D + ChartSvc --> Helm + ChartSvc --> ArgoCD + DevSvc --> Telepresence + + K3D --> K8s + Helm --> Apps + ArgoCD --> Apps + Telepresence --> Services ``` ---- +## Quick Start -## 🚀 Quick Start +Get OpenFrame CLI up and running in 5 minutes! ### System Requirements | Resource | Minimum | Recommended | -|---|---|---| -| **RAM** | 24 GB | 32 GB | +|----------|---------|-------------| +| **RAM** | 24GB | 32GB | | **CPU Cores** | 6 cores | 12 cores | -| **Disk Space** | 50 GB free | 100 GB free | +| **Disk Space** | 50GB free | 100GB free | -### Step 1: Download the CLI +### Prerequisites -**macOS (Apple Silicon)** +Before installation, ensure you have: +- [Docker](https://docs.docker.com/get-docker/) 20.10+ +- [kubectl](https://kubernetes.io/docs/tasks/tools/) 1.25+ +- [Helm](https://helm.sh/docs/intro/install/) 3.10+ +- [K3D](https://k3d.io/v5.4.6/#installation) 5.0+ -```bash -curl -L https://github.com/flamingo-stack/openframe-cli/releases/latest/download/openframe-cli_darwin_arm64.tar.gz | tar xz -sudo mv openframe /usr/local/bin/ -``` +### Installation -**macOS (Intel)** +Choose your platform and install OpenFrame CLI: +#### Linux (AMD64) ```bash -curl -L https://github.com/flamingo-stack/openframe-cli/releases/latest/download/openframe-cli_darwin_amd64.tar.gz | tar xz +curl -fsSL https://github.com/flamingo-stack/openframe-cli/releases/latest/download/openframe-cli_linux_amd64.tar.gz | tar -xz sudo mv openframe /usr/local/bin/ +chmod +x /usr/local/bin/openframe ``` -**Linux (amd64)** - +#### macOS (Apple Silicon) ```bash -curl -L https://github.com/flamingo-stack/openframe-cli/releases/latest/download/openframe-cli_linux_amd64.tar.gz | tar xz +curl -fsSL https://github.com/flamingo-stack/openframe-cli/releases/latest/download/openframe-cli_darwin_arm64.tar.gz | tar -xz sudo mv openframe /usr/local/bin/ +chmod +x /usr/local/bin/openframe ``` -**Windows (amd64)** - -Download: https://github.com/flamingo-stack/openframe-cli/releases/latest/download/openframe-cli_windows_amd64.zip - -Extract and run the `openframe.exe` — it automatically forwards commands into WSL2. +#### Windows (WSL2) +1. Download: https://github.com/flamingo-stack/openframe-cli/releases/latest/download/openframe-cli_windows_amd64.zip +2. Extract and move `openframe.exe` to a directory in your `PATH` +3. Open WSL2 terminal and verify access -Browse all releases: [https://github.com/flamingo-stack/openframe-cli/releases](https://github.com/flamingo-stack/openframe-cli/releases) +### Bootstrap Your Environment -### Step 2: Check Prerequisites +Create a complete OpenFrame environment with a single command: ```bash -openframe prerequisites check -``` - -Install any missing tools automatically (macOS/Linux): - -```bash -openframe prerequisites install -``` - -### Step 3: Bootstrap OpenFrame +# Verify installation +openframe --version -```bash +# Bootstrap complete environment openframe bootstrap -``` - -The interactive wizard guides you through cluster naming, configuration, and version selection. For CI/CD environments: - -```bash -openframe bootstrap --non-interactive -``` ---- - -## 🛠️ Command Reference - -### `openframe bootstrap` - -Creates a K3D cluster and installs the full OpenFrame platform in a single step. - -```bash -openframe bootstrap # Interactive wizard -openframe bootstrap my-cluster # Named cluster -openframe bootstrap --non-interactive # CI/CD mode -openframe bootstrap --verbose # Show detailed ArgoCD sync progress +# Check cluster status +openframe cluster status ``` -### `openframe cluster` +The bootstrap process creates: +- K3D Kubernetes cluster +- ArgoCD for GitOps deployment +- Traefik ingress controller +- Core monitoring and logging components + +## Core Commands + +| Command | Description | Example | +|---------|-------------|---------| +| `openframe bootstrap` | Create a cluster and deploy OpenFrame | `openframe bootstrap my-cluster` | +| `openframe cluster create` | Create a k3d or cloud (EKS/GKE) cluster | `openframe cluster create dev --nodes 1` | +| `openframe cluster list` | List clusters | `openframe cluster list -o json` | +| `openframe cluster status` | Show cluster status | `openframe cluster status dev` | +| `openframe cluster delete` | Delete a cluster | `openframe cluster delete dev --force` | +| `openframe app install` | Install ArgoCD + app-of-apps | `openframe app install -c k3d-dev` | +| `openframe app upgrade` | Re-sync or move to a new ref | `openframe app upgrade -c k3d-dev --sync` | +| `openframe app status` | Report platform readiness | `openframe app status -c k3d-dev` | +| `openframe app access` | Show ArgoCD sign-in details | `openframe app access -c k3d-dev` | +| `openframe app uninstall` | Remove the app (keep the cluster) | `openframe app uninstall -c k3d-dev --yes` | +| `openframe prerequisites` | Check/install required tools | `openframe prerequisites install` | +| `openframe update` | Self-update the CLI | `openframe update check` | + +### Usage Examples + +Cluster lifecycle: ```bash -openframe cluster create # Create a new K3D cluster -openframe cluster list # List all managed clusters -openframe cluster status # Show detailed cluster status -openframe cluster delete my-cluster # Delete a cluster -openframe cluster cleanup # Remove unused resources +openframe cluster create dev --type k3d --nodes 1 --skip-wizard +openframe cluster create my-gke --type gke --project my-project --region us-central1 --skip-wizard # cloud (billed!) +openframe cluster create my-eks --type eks --skip-wizard # EKS is gated: shows a coming-soon banner, creates nothing +openframe cluster list # add -o json|yaml for scripts +openframe cluster status dev +openframe cluster delete dev --force ``` -### `openframe app` +Deploy and manage the platform (OSS tenant deployment): ```bash -openframe app install # Install ArgoCD + OpenFrame platform -openframe app upgrade --ref v1.4.0 # Change to a new release tag -openframe app status # Show platform readiness -openframe app access # Print ArgoCD credentials and URLs -openframe app uninstall # Remove app (keep cluster) +openframe app install # interactive: pick context +openframe app install dev --non-interactive # reuse existing openframe-helm-values.yaml +openframe app install -c k3d-dev --ref v1.3.0 # deploy a specific release tag +openframe app status -c k3d-dev # -o json|yaml supported +openframe app access -c k3d-dev # ArgoCD URL + admin credentials +openframe app upgrade -c k3d-dev --sync # force ArgoCD to re-sync current ref +openframe app upgrade -c k3d-dev --ref v1.4.0 # move to a new release tag +openframe app uninstall -c k3d-dev --yes ``` -### `openframe update` +Keep the CLI up to date (each release is checksum- and cosign-verified before it +replaces the running binary; the previous version is kept for rollback): ```bash -openframe update # Update to latest release -openframe update check # Check for available updates -openframe update rollback # Revert to previous version +openframe update # update to the latest release +openframe update check # report availability only (-o json|yaml) +openframe update v1.4.0 # switch to a specific release (up or down) +openframe update rollback # revert to the previous version, offline ``` ---- - -## 🔧 Technology Stack - -| Technology | Role | -|---|---| -| **Go** | Primary language | -| **Cobra** | CLI framework (command/flag parsing) | -| **K3D** | Local Kubernetes cluster provider | -| **ArgoCD** | GitOps deployment engine (via client-go dynamic client) | -| **Helm** | Kubernetes package manager (CLI wrapper) | -| **go-git** | Git operations (no `git` binary dependency) | -| **client-go** | Kubernetes API client | -| **pterm** | Terminal UI rendering (spinners, prompts, colors) | -| **Sigstore/cosign** | Binary signature verification for self-updates | - ---- - -## 📦 External Platform Repository +Non-interactive flags (`--non-interactive`, `--yes`, `--force`, `--skip-wizard`) +make every command scriptable; prompts are also skipped automatically in CI or +when stdin is not a terminal. -The OpenFrame platform configuration (Helm charts, values) lives in a separate repository: +## Technology Stack -- **Repository:** [flamingo-stack/openframe-oss-tenant](https://github.com/flamingo-stack/openframe-oss-tenant) -- **Documentation:** [https://github.com/flamingo-stack/openframe-oss-tenant/tree/main/docs](https://github.com/flamingo-stack/openframe-oss-tenant/tree/main/docs) +OpenFrame CLI integrates with industry-standard tools: ---- - -## 📚 Documentation +- **Kubernetes**: Container orchestration with K3D for development +- **ArgoCD**: GitOps continuous deployment +- **Helm**: Package management for Kubernetes +- **Telepresence**: Local development with remote services +- **Docker**: Container runtime and image management +- **Cobra**: Modern CLI framework with rich help and completion -See the [Documentation Index](./docs/README.md) for comprehensive guides: +## Documentation -- [Introduction](./docs/getting-started/introduction.md) — What is OpenFrame CLI? -- [Prerequisites](./docs/getting-started/prerequisites.md) — Environment setup -- [Quick Start](./docs/getting-started/quick-start.md) — 5-minute setup guide -- [First Steps](./docs/getting-started/first-steps.md) — Post-install exploration -- [Architecture Reference](./docs/reference/architecture/overview.md) — Full technical reference +📚 See the [Documentation](./docs/README.md) for comprehensive guides including: ---- +- **Getting Started**: Prerequisites, installation, and first steps +- **Development**: Local setup, architecture, and contribution guidelines +- **Reference**: Technical documentation, API specs, and configuration +- **CLI Tools**: Links to external repositories and tools -## 💬 Community & Support +## Community and Support -All discussions, support, and feature requests happen in the **OpenMSP Slack community** — there are no GitHub Issues or Discussions for this project. +OpenFrame is built by the community for the community: -- **Join Slack:** [https://www.openmsp.ai/](https://www.openmsp.ai/) -- **Invite link:** [https://join.slack.com/t/openmsp/shared_invite/zt-36bl7mx0h-3~U2nFH6nqHqoTPXMaHEHA](https://join.slack.com/t/openmsp/shared_invite/zt-36bl7mx0h-3~U2nFH6nqHqoTPXMaHEHA) +- **OpenMSP Slack**: [Join the community](https://join.slack.com/t/openmsp/shared_invite/zt-36bl7mx0h-3~U2nFH6nqHqoTPXMaHEHA) - Primary support channel +- **Website**: [https://flamingo.run](https://flamingo.run) +- **OpenFrame Platform**: [https://openframe.ai](https://openframe.ai) ---- +> **Note**: We don't use GitHub Issues or Discussions. All support and community interaction happens in the OpenMSP Slack community. -## 🤝 Contributing +## License -See [CONTRIBUTING.md](./CONTRIBUTING.md) for guidelines on code style, branch naming, commit messages, and the PR process. +This project is licensed under the Flamingo AI Unified License v1.0 - see the [LICENSE.md](LICENSE.md) file for details. --- -
Built with 💛 by the Flamingo team -
+ \ No newline at end of file diff --git a/cmd/cluster/cluster.go b/cmd/cluster/cluster.go index a4bf8639..09d84e9b 100644 --- a/cmd/cluster/cluster.go +++ b/cmd/cluster/cluster.go @@ -21,12 +21,13 @@ func GetClusterCmd() *cobra.Command { This command group provides cluster lifecycle management functionality: • create - Create a new cluster with interactive configuration - • delete - Remove a cluster and clean up resources + • delete - Remove a cluster and clean up resources • list - Show all managed clusters • status - Display detailed cluster information + • use - Switch the kubectl context to a cluster • cleanup - Remove unused images and resources -Supports K3d clusters for local development. +Supports K3d clusters for local development and Google GKE for cloud deployments (AWS EKS coming soon). Examples: openframe cluster create @@ -46,6 +47,16 @@ Examples: if cmd.Use != "cluster" { ui.ShowLogoWithContext(cmd.Context()) } + // create runs its own type-aware gate after the cluster type is known + // (a cloud cluster must not demand Docker/k3d); use only flips local + // kubeconfig/gcloud state and needs no tools at all. status and list + // are cross-provider read-only views: they must work against a cloud + // cluster with Docker stopped, so they skip the k3d gate and degrade + // gracefully instead (k3d enumeration is best-effort in the service). + switch cmd.Name() { + case "create", "use", "status", "list": + return nil + } return prerequisites.CheckPrerequisites() }, RunE: func(cmd *cobra.Command, args []string) error { @@ -61,6 +72,7 @@ Examples: getDeleteCmd(), getListCmd(), getStatusCmd(), + getUseCmd(), getCleanupCmd(), ) diff --git a/cmd/cluster/contract_test.go b/cmd/cluster/contract_test.go index faae2748..323d55e8 100644 --- a/cmd/cluster/contract_test.go +++ b/cmd/cluster/contract_test.go @@ -18,7 +18,7 @@ func TestClusterContract_RootShape(t *testing.T) { assert.Equal(t, "cluster", cluster.Name()) assert.ElementsMatch(t, []string{"k"}, cluster.Aliases, "k alias is part of the contract") - testutil.AssertSubcommands(t, cluster, "create", "list", "delete", "status", "cleanup") + testutil.AssertSubcommands(t, cluster, "create", "list", "delete", "status", "use", "cleanup") } func TestClusterContract_Flags(t *testing.T) { diff --git a/cmd/cluster/create.go b/cmd/cluster/create.go index 52129e5a..574ee5a9 100644 --- a/cmd/cluster/create.go +++ b/cmd/cluster/create.go @@ -1,12 +1,23 @@ package cluster import ( + "context" "fmt" + "os" + osexec "os/exec" "strings" + "github.com/flamingo-stack/openframe-cli/internal/cluster/discovery" "github.com/flamingo-stack/openframe-cli/internal/cluster/models" + "github.com/flamingo-stack/openframe-cli/internal/cluster/prerequisites" + infracostinstall "github.com/flamingo-stack/openframe-cli/internal/cluster/prerequisites/infracost" + "github.com/flamingo-stack/openframe-cli/internal/cluster/provider" + "github.com/flamingo-stack/openframe-cli/internal/cluster/providers/terraform" "github.com/flamingo-stack/openframe-cli/internal/cluster/ui" "github.com/flamingo-stack/openframe-cli/internal/cluster/utils" + "github.com/flamingo-stack/openframe-cli/internal/shared/executor" + sharedUI "github.com/flamingo-stack/openframe-cli/internal/shared/ui" + "github.com/pterm/pterm" "github.com/spf13/cobra" ) @@ -23,16 +34,23 @@ By default, shows a selection menu where you can choose: 1. Quick start with defaults (press Enter) - creates cluster with default settings 2. Interactive configuration wizard - step-by-step cluster customization -Creates a local cluster for OpenFrame development. If a cluster with the same -name already exists it is left untouched and reused — delete it first to start -from scratch. Use the bootstrap command to install OpenFrame components after -creation. +Creates a local k3d cluster or a cloud GKE cluster for OpenFrame (AWS EKS +creation is temporarily disabled and coming soon). If a cluster +with the same name already exists it is left untouched and reused — delete it +first to start from scratch. Use the bootstrap command to install OpenFrame +components after creation. + +Cloud clusters are provisioned with Terraform (installed automatically) and +create AWS resources that incur costs; the workspace and state live under +~/.openframe/clusters/. A failed create can be re-run to resume, or +torn down with 'openframe cluster delete'. Examples: openframe cluster create # Show creation mode selection openframe cluster create my-cluster # Show selection with custom name openframe cluster create --skip-wizard # Direct creation with defaults - openframe cluster create --nodes 3 --type k3d --skip-wizard`, + openframe cluster create --nodes 3 --type k3d --skip-wizard + openframe cluster create my-gke --type gke --project my-project --region us-central1 --skip-wizard`, Args: cobra.MaximumNArgs(1), PreRunE: func(cmd *cobra.Command, args []string) error { utils.SyncGlobalFlags() @@ -57,6 +75,149 @@ Examples: return createCmd } +// planPreviewFn is the cloud dry-run implementation. A package variable so +// cmd-layer tests can stub it out: the real one shells out to terraform, +// which unit tests must never do. +var planPreviewFn = cloudPlanPreview + +// cloudPlanPreview runs a real terraform plan for a cloud config and prints +// the resource footprint. Terraform not being installed is a soft skip — the +// prerequisite gate only runs on a real create, and a dry-run must not +// install anything. +func cloudPlanPreview(ctx context.Context, config models.ClusterConfig) error { + if _, err := terraform.FindTerraform(); err != nil { + pterm.Info.Println("terraform is not installed — skipping the plan preview (it installs automatically on a real create)") + return nil + } + + exec := executor.NewRealCommandExecutor(false, utils.GetGlobalFlags().Global.Verbose) + p, err := provider.New(config.Type, exec) + if err != nil { + return err + } + planner, ok := p.(provider.Planner) + if !ok { + return nil + } + + if config.Type == models.ClusterTypeGKE { + if err := discovery.NewAuthFlow(exec).Ensure(ctx, true); err != nil { + return err + } + } + + pterm.Info.Printf("Computing terraform plan for %s cluster '%s'...\n", config.Type, config.Name) + summary, err := planner.PlanCluster(ctx, config) + if err != nil { + return err + } + if !summary.HasChanges() { + pterm.Success.Println("Plan: no changes — the cluster already matches this configuration") + return nil + } + for _, change := range summary.Changes { + pterm.DefaultBasicText.Printf(" %-3s %s\n", change.Action, change.Address) + } + pterm.Success.Printf("Plan: %d to add, %d to change, %d to destroy\n", summary.Add, summary.Change, summary.Destroy) + showCostEstimate(ctx, exec, config, summary) + return nil +} + +// Seams for hermetic tests: availability probes the real PATH, the offer runs +// a real verified download, and the login launches a real browser flow — none +// of that may happen in unit tests. +var ( + infracostAvailableFn = terraform.InfracostAvailable + infracostOfferFn = offerInfracostInstall + infracostLoginFn = offerInfracostLogin +) + +// offerInfracostLogin runs the one-time `infracost auth login` (browser flow) +// right inside the CLI, so the user never needs a separate console. Attached +// straight to the terminal — the flow prints a URL and reads stdin, which the +// capturing executor would swallow. Returns whether a login was performed. +func offerInfracostLogin() bool { + if sharedUI.IsNonInteractive() { + return false + } + confirmed, err := sharedUI.ConfirmActionInteractive( + "infracost needs a one-time free API key. Run 'infracost auth login' now (opens a browser)?", true) + if err != nil || !confirmed { + return false + } + login := osexec.Command("infracost", "auth", "login") + login.Stdin = os.Stdin + login.Stdout = os.Stdout + login.Stderr = os.Stderr + if err := login.Run(); err != nil { + pterm.Warning.Printf("infracost auth login failed: %v\n", err) + return false + } + return true +} + +// offerInfracostInstall proposes the verified pinned infracost install in an +// interactive session. It returns whether infracost is available afterwards. +// The API key stays the user's one manual step: `infracost auth login`. +func offerInfracostInstall() bool { + if sharedUI.IsNonInteractive() { + return false + } + confirmed, err := sharedUI.ConfirmActionInteractive( + "Install infracost (verified download) to see a monthly cost estimate?", true) + if err != nil || !confirmed { + return false + } + if err := infracostinstall.NewInstaller().Install(); err != nil { + pterm.Warning.Printf("infracost install failed: %v\n", err) + pterm.Info.Println("This is usually a transient network/DNS issue — re-running create will retry the download") + return false + } + return infracostAvailableFn() +} + +// showCostEstimate prints a monthly estimate when infracost is available and +// working — offering to install it first in interactive sessions. Otherwise +// it prints NO figures, only the abstract cost warning with the provider's +// pricing page. Best-effort either way: cost information never fails the +// preview. +func showCostEstimate(ctx context.Context, exec executor.CommandExecutor, config models.ClusterConfig, summary terraform.PlanSummary) { + available := infracostAvailableFn() + if !available { + available = infracostOfferFn() + } + if available { + cost, err := terraform.EstimateMonthlyCost(ctx, exec, summary.PlanJSON) + if err != nil && infracostLoginFn() { + // The usual failure is the missing (free) API key. The login just + // ran inside the CLI — retry once. + cost, err = terraform.EstimateMonthlyCost(ctx, exec, summary.PlanJSON) + } + if err == nil { + pterm.Info.Printf("Estimated monthly cost (infracost): %s\n", cost) + return + } + pterm.Info.Println("Cost estimate unavailable — run 'infracost auth login' (free, one-time) and re-run") + if utils.GetGlobalFlags().Global.Verbose { + pterm.Debug.Printf("infracost estimate error: %v\n", err) + } + pterm.Info.Println(ui.CostHint(config.Type)) + return + } + pterm.Info.Println(ui.CostHint(config.Type)) + pterm.Info.Println("Tip: install infracost (https://www.infracost.io/docs/) to see a monthly cost estimate here") +} + +// showEKSComingSoonBanner is the temporary stub for AWS EKS creation. +func showEKSComingSoonBanner() { + pterm.DefaultBox. + WithTitle(" 🚧 AWS EKS — coming soon "). + WithTitleTopCenter(). + Println("Creating AWS EKS clusters will be available shortly.\n" + + "GKE is fully supported today:\n" + + " openframe cluster create my-gke --type gke --project --region ") +} + func runCreateCluster(cmd *cobra.Command, args []string) error { service := utils.GetCommandService() globalFlags := utils.GetGlobalFlags() @@ -117,6 +278,34 @@ func runCreateCluster(cmd *cobra.Command, args []string) error { if config.Type == "" { config.Type = models.ClusterTypeK3d } + + // Cloud settings only exist for cloud types; the k3d backend rejects a + // non-nil Cloud by design. + if config.Type == models.ClusterTypeEKS || config.Type == models.ClusterTypeGKE { + cf := globalFlags.Create + config.Cloud = &models.CloudConfig{ + Region: cf.Region, + Profile: cf.Profile, + Project: cf.Project, + MachineType: cf.MachineType, + MinNodes: cf.MinNodes, + MaxNodes: cf.MaxNodes, + Spot: cf.Spot, + HA: cf.HA, + BackendConfig: cf.BackendConfig, + } + } + } + + // AWS EKS creation is temporarily gated behind a coming-soon banner while + // the GKE flow is being finished end-to-end. The EKS provider stays fully + // functional for existing clusters (status/delete/resume) — only NEW + // creates are gated. + if config.Type == models.ClusterTypeEKS { + showEKSComingSoonBanner() + // Non-zero on purpose: a scripted `--type eks` must not look like a + // successful create when nothing was provisioned. + return fmt.Errorf("AWS EKS cluster creation is coming soon — use --type gke or k3d for now") } // Show configuration summary for dry-run or skip-wizard modes @@ -124,14 +313,64 @@ func runCreateCluster(cmd *cobra.Command, args []string) error { operationsUI := ui.NewOperationsUI() operationsUI.ShowConfigurationSummary(config, globalFlags.Create.DryRun, globalFlags.Create.SkipWizard) - // If dry-run, don't actually create the cluster + // If dry-run, don't actually create the cluster. For cloud types the + // dry-run is a real terraform plan of what create would provision. if globalFlags.Create.DryRun { + if config.Type == models.ClusterTypeEKS || config.Type == models.ClusterTypeGKE { + return planPreviewFn(cmd.Context(), config) + } return nil } } + // Type-aware prerequisite gate: runs after the type is known (wizard or + // flags), so only the tools the chosen backend needs are demanded. It sits + // after the dry-run return on purpose — the gate may INSTALL tools, and + // dry-run must not mutate the system. + if err := prerequisites.CheckForClusterType(config.Type); err != nil { + return err + } + + // Single auth flow: for GKE, offer `gcloud auth login` (+ ADC for + // terraform) right here instead of failing later in the provider + // preflight with a "run this command" error. + if config.Type == models.ClusterTypeGKE { + if err := discovery.NewAuthFlow(utils.CommandExecutor()).Ensure(cmd.Context(), true); err != nil { + return err + } + // In flag mode the project came from --project, not the wizard's + // validated picker, so check it against the accessible projects and fail + // fast with the valid options — before terraform runs. + if globalFlags.Create.SkipWizard { + if err := validateGKEProjectFlag(cmd.Context(), utils.CommandExecutor(), config.Cloud.Project); err != nil { + return err + } + } + } + // Execute cluster creation through service layer // We ignore the returned rest.Config as it's not needed for standalone cluster creation _, err := service.CreateCluster(cmd.Context(), config) return err } + +// validateGKEProjectFlag checks a --project against the gcloud identity's +// accessible projects and returns an actionable error (listing the valid +// options) when it is not among them. Best-effort: if the projects cannot be +// listed (permissions, network), it returns nil and defers to the provider +// preflight's 'gcloud projects describe' accessibility check. +func validateGKEProjectFlag(ctx context.Context, exec executor.CommandExecutor, project string) error { + projects, err := discovery.NewGKEDiscoverer(exec).AllProjects(ctx) + if err != nil || len(projects) == 0 { + return nil // can't enumerate — the provider preflight still validates access + } + for _, p := range projects { + if p == project { + return nil + } + } + if len(projects) > 15 { + return fmt.Errorf("GCP project %q is not among your %d accessible projects — run 'gcloud projects list' to see them", project, len(projects)) + } + return fmt.Errorf("GCP project %q is not among your accessible projects: %s", project, strings.Join(projects, ", ")) +} diff --git a/cmd/cluster/create_behavior_test.go b/cmd/cluster/create_behavior_test.go index 98b39f22..f5e3a206 100644 --- a/cmd/cluster/create_behavior_test.go +++ b/cmd/cluster/create_behavior_test.go @@ -1,8 +1,12 @@ package cluster import ( + "context" + "strings" "testing" + "github.com/flamingo-stack/openframe-cli/internal/cluster/models" + "github.com/flamingo-stack/openframe-cli/internal/cluster/providers/terraform" "github.com/flamingo-stack/openframe-cli/internal/cluster/utils" "github.com/flamingo-stack/openframe-cli/internal/shared/executor" "github.com/flamingo-stack/openframe-cli/tests/testutil" @@ -72,6 +76,61 @@ func TestRunCreateCluster_DryRunDefaultsNameWhenNoArgs(t *testing.T) { } } +// While EKS creation is gated behind the coming-soon banner, the plan preview +// is a GKE-only path (see TestRunCreateCluster_EKSShowsComingSoonBanner). +func TestRunCreateCluster_CloudDryRunRunsPlanPreview(t *testing.T) { + setupCreate(t) + // Stub the preview: the real one shells out to terraform. + var previewed *models.ClusterConfig + orig := planPreviewFn + planPreviewFn = func(ctx context.Context, config models.ClusterConfig) error { + previewed = &config + return nil + } + t.Cleanup(func() { planPreviewFn = orig }) + + cmd := getCreateCmd() + gf := utils.GetGlobalFlags() + gf.Create.SkipWizard = true + gf.Create.DryRun = true + gf.Create.ClusterType = "gke" + gf.Create.Region = "us-central1" + gf.Create.Project = "my-project" + + if err := runCreateCluster(cmd, []string{"cloud-cluster"}); err != nil { + t.Fatalf("gke dry-run should return nil, got %v", err) + } + if previewed == nil { + t.Fatal("gke dry-run must invoke the terraform plan preview") + } + if previewed.Cloud == nil || previewed.Cloud.Region != "us-central1" { + t.Fatalf("preview received wrong config: %+v", previewed) + } +} + +func TestRunCreateCluster_K3dDryRunSkipsPlanPreview(t *testing.T) { + setupCreate(t) + called := false + orig := planPreviewFn + planPreviewFn = func(ctx context.Context, config models.ClusterConfig) error { + called = true + return nil + } + t.Cleanup(func() { planPreviewFn = orig }) + + cmd := getCreateCmd() + gf := utils.GetGlobalFlags() + gf.Create.SkipWizard = true + gf.Create.DryRun = true + + if err := runCreateCluster(cmd, []string{"local-cluster"}); err != nil { + t.Fatalf("k3d dry-run should return nil, got %v", err) + } + if called { + t.Fatal("k3d dry-run must not invoke the terraform plan preview") + } +} + // setupWithExecutor wires a specific mock executor into the command service. func setupWithExecutor(t *testing.T, exec *executor.MockCommandExecutor) { t.Helper() @@ -101,3 +160,145 @@ func TestRunClusterStatus_ListFailureSurfacesError(t *testing.T) { t.Fatal("expected an error when cluster listing fails") } } + +// TestRunCreateCluster_EKSShowsComingSoonBanner: while EKS creation is gated, +// --type eks must show the banner and exit cleanly — no prerequisite gate, no +// provider calls, no validation errors about missing --region. +func TestRunCreateCluster_EKSShowsComingSoonBanner(t *testing.T) { + setupCreate(t) + called := false + orig := planPreviewFn + planPreviewFn = func(ctx context.Context, config models.ClusterConfig) error { + called = true + return nil + } + t.Cleanup(func() { planPreviewFn = orig }) + + cmd := getCreateCmd() + gf := utils.GetGlobalFlags() + gf.Create.SkipWizard = true + gf.Create.ClusterType = "eks" + + err := runCreateCluster(cmd, []string{"cloud-cluster"}) + if err == nil { + t.Fatal("gated eks create must exit non-zero — a script must not mistake the banner for success") + } + if !strings.Contains(err.Error(), "coming soon") { + t.Fatalf("expected an actionable coming-soon error, got: %v", err) + } + if called { + t.Fatal("eks must not reach the plan preview while gated") + } +} + +// TestShowCostEstimate drives the infracost offer/estimate flow on seams — +// no real PATH probe, download, or infracost invocation ever happens. +func TestShowCostEstimate(t *testing.T) { + summary := terraform.PlanSummary{Add: 1, PlanJSON: []byte(`{"format_version":"1.2"}`)} + config := models.ClusterConfig{Name: "x", Type: models.ClusterTypeGKE, NodeCount: 1, + Cloud: &models.CloudConfig{Region: "us-central1", Project: "p"}} + + override := func(t *testing.T, available bool, offer bool) (offered *bool) { + t.Helper() + offered = new(bool) + origAvail, origOffer := infracostAvailableFn, infracostOfferFn + infracostAvailableFn = func() bool { return available } + infracostOfferFn = func() bool { *offered = true; return offer } + t.Cleanup(func() { infracostAvailableFn, infracostOfferFn = origAvail, origOffer }) + return offered + } + + t.Run("unavailable and declined: no infracost invocation", func(t *testing.T) { + utils.InitGlobalFlags() + t.Cleanup(utils.ResetGlobalFlags) + offered := override(t, false, false) + mock := executor.NewMockCommandExecutor() + + showCostEstimate(context.Background(), mock, config, summary) + + if !*offered { + t.Fatal("the install offer must be made when infracost is unavailable") + } + if mock.WasCommandExecuted("infracost") { + t.Fatal("infracost must not run when unavailable and declined") + } + }) + + t.Run("offer accepted: estimate runs", func(t *testing.T) { + utils.InitGlobalFlags() + t.Cleanup(utils.ResetGlobalFlags) + override(t, false, true) + mock := executor.NewMockCommandExecutor() + mock.SetResponse("infracost breakdown", &executor.CommandResult{ + ExitCode: 0, Stdout: `{"totalMonthlyCost":"120.00","currency":"USD"}`}) + + showCostEstimate(context.Background(), mock, config, summary) + + if !mock.WasCommandExecuted("infracost breakdown") { + t.Fatal("estimate must run after an accepted install offer") + } + }) + + t.Run("available but estimate fails: login offered, declined -> hint", func(t *testing.T) { + utils.InitGlobalFlags() + t.Cleanup(utils.ResetGlobalFlags) + offered := override(t, true, false) + loginOffered := false + origLogin := infracostLoginFn + infracostLoginFn = func() bool { loginOffered = true; return false } + t.Cleanup(func() { infracostLoginFn = origLogin }) + mock := executor.NewMockCommandExecutor() + mock.SetShouldFail(true, "No INFRACOST_API_KEY environment variable is set") + + showCostEstimate(context.Background(), mock, config, summary) + + if *offered { + t.Fatal("no install offer when infracost is already available") + } + if !loginOffered { + t.Fatal("a failed estimate must offer the in-CLI 'infracost auth login'") + } + }) + + t.Run("failed estimate + accepted login: estimate retried", func(t *testing.T) { + utils.InitGlobalFlags() + t.Cleanup(utils.ResetGlobalFlags) + override(t, true, false) + mock := executor.NewMockCommandExecutor() + // First breakdown fails (no key); the accepted login flips the mock to + // success, mimicking a real auth login taking effect. + mock.SetShouldFail(true, "No INFRACOST_API_KEY environment variable is set") + origLogin := infracostLoginFn + infracostLoginFn = func() bool { + mock.SetShouldFail(false, "") + mock.SetResponse("infracost breakdown", &executor.CommandResult{ + ExitCode: 0, Stdout: `{"totalMonthlyCost":"120.00","currency":"USD"}`}) + return true + } + t.Cleanup(func() { infracostLoginFn = origLogin }) + + showCostEstimate(context.Background(), mock, config, summary) + + if mock.GetCommandCount() < 2 { + t.Fatalf("estimate must be retried after a successful login, got %d invocations", mock.GetCommandCount()) + } + }) +} + +// TestOfferInfracostInstall_NonInteractiveNeverPrompts: CI sessions must not +// hit the confirm prompt (nor a download). +func TestOfferInfracostInstall_NonInteractiveNeverPrompts(t *testing.T) { + t.Setenv("CI", "true") + if offerInfracostInstall() { + t.Fatal("non-interactive sessions must not offer/install infracost") + } +} + +// TestOfferInfracostLogin_NonInteractiveNeverPrompts: CI sessions must not +// hit the confirm prompt (nor a browser). +func TestOfferInfracostLogin_NonInteractiveNeverPrompts(t *testing.T) { + t.Setenv("CI", "true") + if offerInfracostLogin() { + t.Fatal("non-interactive sessions must not run infracost auth login") + } +} diff --git a/cmd/cluster/create_project_validation_test.go b/cmd/cluster/create_project_validation_test.go new file mode 100644 index 00000000..a1a29485 --- /dev/null +++ b/cmd/cluster/create_project_validation_test.go @@ -0,0 +1,41 @@ +package cluster + +import ( + "context" + "strings" + "testing" + + "github.com/flamingo-stack/openframe-cli/internal/shared/executor" +) + +func TestValidateGKEProjectFlag(t *testing.T) { + list := &executor.CommandResult{ExitCode: 0, Stdout: "tenant-y0\nshared-abc\nprod-42\n"} + + t.Run("project in the list passes", func(t *testing.T) { + mock := executor.NewMockCommandExecutor() + mock.SetResponse("gcloud projects list", list) + if err := validateGKEProjectFlag(context.Background(), mock, "shared-abc"); err != nil { + t.Fatalf("expected nil for a listed project, got %v", err) + } + }) + + t.Run("project not in the list errors with the valid options", func(t *testing.T) { + mock := executor.NewMockCommandExecutor() + mock.SetResponse("gcloud projects list", list) + err := validateGKEProjectFlag(context.Background(), mock, "typo-proj") + if err == nil { + t.Fatal("expected an error for an unlisted project") + } + if !strings.Contains(err.Error(), "typo-proj") || !strings.Contains(err.Error(), "tenant-y0") { + t.Fatalf("error should name the bad project and list valid options, got: %v", err) + } + }) + + t.Run("best-effort: a gcloud listing failure does not block create", func(t *testing.T) { + mock := executor.NewMockCommandExecutor() + mock.SetShouldFail(true, "network down") + if err := validateGKEProjectFlag(context.Background(), mock, "anything"); err != nil { + t.Fatalf("a gcloud failure must defer to the provider preflight, not block; got %v", err) + } + }) +} diff --git a/cmd/cluster/delete.go b/cmd/cluster/delete.go index 57069e92..8275a262 100644 --- a/cmd/cluster/delete.go +++ b/cmd/cluster/delete.go @@ -7,6 +7,8 @@ import ( "github.com/flamingo-stack/openframe-cli/internal/cluster/ui" "github.com/flamingo-stack/openframe-cli/internal/cluster/utils" sharedErrors "github.com/flamingo-stack/openframe-cli/internal/shared/errors" + sharedUI "github.com/flamingo-stack/openframe-cli/internal/shared/ui" + "github.com/pterm/pterm" "github.com/spf13/cobra" ) @@ -50,6 +52,24 @@ Examples: return deleteCmd } +// confirmCloudDeletion is the stronger destroy gate for cloud clusters: +// re-typing the cluster name. Local clusters and --force pass through +// (--force is the CI escape hatch); a non-interactive session without --force +// refuses rather than hanging on a prompt — defense in depth behind the +// generic confirmation, which may not always run before this. +func confirmCloudDeletion(clusterType models.ClusterType, clusterName string, force bool) (bool, error) { + if clusterType != models.ClusterTypeEKS && clusterType != models.ClusterTypeGKE { + return true, nil + } + if force { + return true, nil + } + if sharedUI.IsNonInteractive() { + return false, fmt.Errorf("refusing to destroy cloud cluster '%s' non-interactively; pass --force to confirm", clusterName) + } + return ui.ConfirmTypedClusterName(clusterName) +} + func runDeleteCluster(cmd *cobra.Command, args []string) error { service := utils.GetCommandService() operationsUI := ui.NewOperationsUI() @@ -82,6 +102,17 @@ func runDeleteCluster(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to detect cluster type: %w", err) } + // Destroying a cloud cluster deletes billed infrastructure irreversibly, + // so it takes a stronger gate than the generic yes/no above. + proceed, err := confirmCloudDeletion(clusterType, clusterName, globalFlags.Delete.Force) + if err != nil { + return sharedErrors.HandleGlobalError(err, globalFlags.Global.Verbose) + } + if !proceed { + pterm.Info.Println("Cluster name did not match — nothing was deleted") + return nil + } + // Execute cluster deletion through service layer err = service.DeleteCluster(cmd.Context(), clusterName, clusterType, globalFlags.Delete.Force) if err != nil { @@ -90,6 +121,6 @@ func runDeleteCluster(cmd *cobra.Command, args []string) error { } // Show friendly success message - operationsUI.ShowOperationSuccess("delete", clusterName) + operationsUI.ShowOperationSuccess("delete", clusterName, clusterType) return nil } diff --git a/cmd/cluster/delete_test.go b/cmd/cluster/delete_test.go index f04d306b..3a1fba52 100644 --- a/cmd/cluster/delete_test.go +++ b/cmd/cluster/delete_test.go @@ -1,8 +1,10 @@ package cluster import ( + "strings" "testing" + "github.com/flamingo-stack/openframe-cli/internal/cluster/models" "github.com/flamingo-stack/openframe-cli/internal/cluster/utils" "github.com/flamingo-stack/openframe-cli/tests/testutil" ) @@ -21,3 +23,38 @@ func TestDeleteCommand(t *testing.T) { testutil.TestClusterCommand(t, "delete", getDeleteCmd, setupFunc, teardownFunc) } + +// TestConfirmCloudDeletion locks the stronger destroy gate: cloud clusters +// delete billed infrastructure, so a non-interactive delete without --force +// must refuse instead of proceeding; --force and local clusters pass through. +func TestConfirmCloudDeletion(t *testing.T) { + t.Setenv("CI", "true") // force ui.IsNonInteractive() + + t.Run("k3d passes without confirmation", func(t *testing.T) { + proceed, err := confirmCloudDeletion(models.ClusterTypeK3d, "local", false) + if err != nil || !proceed { + t.Fatalf("k3d must pass through, got proceed=%v err=%v", proceed, err) + } + }) + + t.Run("cloud with force passes", func(t *testing.T) { + for _, clusterType := range []models.ClusterType{models.ClusterTypeEKS, models.ClusterTypeGKE} { + proceed, err := confirmCloudDeletion(clusterType, "cloudy", true) + if err != nil || !proceed { + t.Fatalf("%s with --force must pass, got proceed=%v err=%v", clusterType, proceed, err) + } + } + }) + + t.Run("cloud non-interactive without force refuses", func(t *testing.T) { + for _, clusterType := range []models.ClusterType{models.ClusterTypeEKS, models.ClusterTypeGKE} { + proceed, err := confirmCloudDeletion(clusterType, "cloudy", false) + if proceed || err == nil { + t.Fatalf("%s must refuse, got proceed=%v err=%v", clusterType, proceed, err) + } + if !strings.Contains(err.Error(), "refusing to destroy cloud cluster") { + t.Fatalf("expected the refusal message, got: %v", err) + } + } + }) +} diff --git a/cmd/cluster/list.go b/cmd/cluster/list.go index 9e24303c..aa6c679f 100644 --- a/cmd/cluster/list.go +++ b/cmd/cluster/list.go @@ -1,11 +1,14 @@ package cluster import ( + "context" "encoding/json" "fmt" + "github.com/flamingo-stack/openframe-cli/internal/cluster/discovery" "github.com/flamingo-stack/openframe-cli/internal/cluster/models" "github.com/flamingo-stack/openframe-cli/internal/cluster/utils" + "github.com/pterm/pterm" "github.com/spf13/cobra" "sigs.k8s.io/yaml" ) @@ -22,8 +25,13 @@ func getListCmd() *cobra.Command { Displays cluster information including name, type, status, and node count from all registered providers in a formatted table. +With --all, additionally discovers GKE clusters that exist in the GCP +projects of your gcloud configurations but were created outside openframe. +Discovered clusters are read-only: openframe never modifies or deletes them. + Examples: openframe cluster list + openframe cluster list --all openframe cluster list --verbose openframe cluster list --quiet`, PreRunE: func(cmd *cobra.Command, args []string) error { @@ -52,6 +60,7 @@ Examples: func runListClusters(cmd *cobra.Command, args []string) error { service := utils.GetCommandService() + globalFlags := utils.GetGlobalFlags() // Get all clusters clusters, err := service.ListClusters() @@ -59,19 +68,78 @@ func runListClusters(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to list clusters: %w", err) } + var notices []string + if globalFlags.List.All { + external, discoveryNotices := discoverExternalClusters(cmd.Context(), clusters) + clusters = append(clusters, external...) + notices = discoveryNotices + } + switch out, _ := cmd.Flags().GetString("output"); out { case "json": return printClustersJSON(clusters) case "yaml": return printClustersYAML(clusters) case "", "text": - globalFlags := utils.GetGlobalFlags() - return service.DisplayClusterList(clusters, globalFlags.List.Quiet, globalFlags.Global.Verbose) + if err := service.DisplayClusterList(clusters, globalFlags.List.Quiet, globalFlags.Global.Verbose); err != nil { + return err + } + for _, notice := range notices { + pterm.Info.Println(notice) + } + return nil default: return fmt.Errorf("invalid --output %q (want \"text\", \"json\", or \"yaml\")", out) } } +// discoverExternalClusters runs GKE discovery, dropping entries that are +// already managed (same name+project as a registry cluster). Auth problems +// degrade to notices, never errors: a logged-out gcloud must not break list. +// EKS discovery is not implemented yet — a notice says so. +func discoverExternalClusters(ctx context.Context, managed []models.ClusterInfo) ([]models.ClusterInfo, []string) { + notices := []string{"AWS EKS discovery is coming soon — external EKS clusters are not shown yet"} + + exec := utils.CommandExecutor() + d := discovery.NewGKEDiscoverer(exec) + switch d.AuthStatus(ctx) { + case discovery.CLIMissing: + return nil, append(notices, "GKE: gcloud is not installed — install it to discover external clusters") + case discovery.NotAuthenticated: + // One unambiguous flow: offer the login right here (interactive only — + // non-interactive sessions get the same message as before). + if err := discovery.NewAuthFlow(exec).Ensure(ctx, false); err != nil { + return nil, append(notices, "GKE: "+err.Error()) + } + } + + result, err := d.Discover(ctx) + if err != nil { + return nil, append(notices, fmt.Sprintf("GKE discovery failed: %v", err)) + } + for _, w := range result.Warnings { + notices = append(notices, "GKE discovery skipped "+w) + } + + isManaged := func(c models.ClusterInfo) bool { + for _, m := range managed { + // Project-aware: a local k3d cluster (empty Project) must not + // suppress an external GKE cluster that merely shares its name. + if m.Name == c.Name && m.Project == c.Project { + return true + } + } + return false + } + var external []models.ClusterInfo + for _, c := range result.Clusters { + if !isManaged(c) { + external = append(external, c) + } + } + return external, notices +} + // clusterJSON is the machine-readable shape of a cluster. type clusterJSON struct { Name string `json:"name"` diff --git a/cmd/cluster/list_test.go b/cmd/cluster/list_test.go index d6ce75bc..56841741 100644 --- a/cmd/cluster/list_test.go +++ b/cmd/cluster/list_test.go @@ -1,9 +1,11 @@ package cluster import ( + "path/filepath" "testing" "github.com/flamingo-stack/openframe-cli/internal/cluster/utils" + "github.com/flamingo-stack/openframe-cli/internal/shared/executor" "github.com/flamingo-stack/openframe-cli/tests/testutil" ) @@ -21,3 +23,53 @@ func TestListCommand(t *testing.T) { testutil.TestClusterCommand(t, "list", getListCmd, setupFunc, teardownFunc) } + +// TestRunListClusters_AllDiscoversExternalGKE drives list --all end to end on +// mocks: k3d listing + gcloud discovery, with the EKS coming-soon notice. +func TestRunListClusters_AllDiscoversExternalGKE(t *testing.T) { + t.Setenv("OPENFRAME_CLUSTERS_DIR", t.TempDir()) + t.Setenv("KUBECONFIG", filepath.Join(t.TempDir(), "kubeconfig")) + utils.InitGlobalFlags() + mock := executor.NewMockCommandExecutor() + mock.SetResponse("k3d cluster list", &executor.CommandResult{ExitCode: 0, Stdout: "[]"}) + mock.SetResponse("gcloud auth list", &executor.CommandResult{ExitCode: 0, Stdout: "dev@example.com\n"}) + mock.SetResponse("gcloud config configurations list", &executor.CommandResult{ExitCode: 0, + Stdout: `[{"name":"dev-x","properties":{"core":{"project":"proj-x"}}}]`}) + mock.SetResponse("clusters list --project proj-x", &executor.CommandResult{ExitCode: 0, + Stdout: `[{"name":"ext-1","location":"us-central1","status":"RUNNING","currentNodeCount":2}]`}) + utils.SetTestExecutor(mock) + t.Cleanup(utils.ResetGlobalFlags) + + cmd := getListCmd() + utils.GetGlobalFlags().List.All = true + + if err := runListClusters(cmd, nil); err != nil { + t.Fatalf("list --all: %v", err) + } + // The discovery call chain must have run. + if !mock.WasCommandExecuted("clusters list --project proj-x") { + t.Fatal("expected GKE discovery to list clusters in configured projects") + } +} + +// TestRunListClusters_AllNotAuthenticatedDegradesGracefully: a logged-out +// gcloud must not fail the command. +func TestRunListClusters_AllNotAuthenticatedDegradesGracefully(t *testing.T) { + t.Setenv("OPENFRAME_CLUSTERS_DIR", t.TempDir()) + utils.InitGlobalFlags() + mock := executor.NewMockCommandExecutor() + mock.SetResponse("k3d cluster list", &executor.CommandResult{ExitCode: 0, Stdout: "[]"}) + mock.SetResponse("gcloud auth list", &executor.CommandResult{ExitCode: 0, Stdout: ""}) + utils.SetTestExecutor(mock) + t.Cleanup(utils.ResetGlobalFlags) + + cmd := getListCmd() + utils.GetGlobalFlags().List.All = true + + if err := runListClusters(cmd, nil); err != nil { + t.Fatalf("list --all without auth must degrade to a notice, got: %v", err) + } + if mock.WasCommandExecuted("clusters list --project") { + t.Fatal("discovery must not query projects when not authenticated") + } +} diff --git a/cmd/cluster/status_json_test.go b/cmd/cluster/status_json_test.go index 21f11ee9..bf3b5dac 100644 --- a/cmd/cluster/status_json_test.go +++ b/cmd/cluster/status_json_test.go @@ -13,7 +13,10 @@ import ( func TestStatusCommandHasOutputFlag(t *testing.T) { f := getStatusCmd().Flags().Lookup("output") if f == nil { + // The explicit return keeps SA5011 happy on linters that lose the + // noreturn fact for t.Fatal. t.Fatal("cluster status is missing the --output flag") + return } if f.Shorthand != "o" { t.Fatalf("--output shorthand = %q, want %q", f.Shorthand, "o") diff --git a/cmd/cluster/use.go b/cmd/cluster/use.go new file mode 100644 index 00000000..257dcca8 --- /dev/null +++ b/cmd/cluster/use.go @@ -0,0 +1,170 @@ +package cluster + +import ( + "context" + "fmt" + "strings" + + "github.com/flamingo-stack/openframe-cli/internal/cluster/discovery" + "github.com/flamingo-stack/openframe-cli/internal/cluster/models" + "github.com/flamingo-stack/openframe-cli/internal/cluster/ui" + "github.com/flamingo-stack/openframe-cli/internal/cluster/utils" + "github.com/flamingo-stack/openframe-cli/internal/k8s" + "github.com/flamingo-stack/openframe-cli/internal/shared/executor" + "github.com/pterm/pterm" + "github.com/spf13/cobra" +) + +func getUseCmd() *cobra.Command { + // Ensure global flags are initialized + utils.InitGlobalFlags() + + useCmd := &cobra.Command{ + Use: "use [NAME]", + Short: "Switch the kubectl context to a cluster", + Long: `Switch the current kubectl context (and, for GKE, the active gcloud +configuration) to the named cluster. + +Works for every cluster the CLI can see: local k3d clusters, clusters created +by openframe, and external GKE clusters discovered in your gcloud projects. +For an external cluster without a kubeconfig entry, credentials are fetched +via 'gcloud container clusters get-credentials' first. + +Only local configuration changes: the cluster itself is never touched. + +Examples: + openframe cluster use openframe-dev # local k3d + openframe cluster use my-gke # openframe-managed GKE + openframe cluster use tenant-cluster-1 # external GKE (discovered) + openframe cluster use # interactive selection`, + Args: cobra.MaximumNArgs(1), + PreRunE: func(cmd *cobra.Command, args []string) error { + utils.SyncGlobalFlags() + return utils.ValidateGlobalFlags() + }, + RunE: utils.WrapCommandWithCommonSetup(runUseCluster), + } + + return useCmd +} + +func runUseCluster(cmd *cobra.Command, args []string) error { + service := utils.GetCommandService() + exec := utils.CommandExecutor() + ctx := cmd.Context() + + // Resolve the cluster name: explicit arg, or interactive selection from + // the clusters the CLI already knows (local + managed — fast, no cloud + // calls; external clusters are addressed by explicit name). + name := "" + if len(args) > 0 { + name = strings.TrimSpace(args[0]) + } else { + clusters, err := service.ListClusters() + if err != nil { + return fmt.Errorf("failed to list clusters: %w", err) + } + name, err = ui.SelectClusterByName(clusters, "Select a cluster to use") + if err != nil { + return err + } + if name == "" { + return nil + } + } + + kubeconfig := k8s.DefaultKubeconfigPath() + + // 1) Clusters the CLI knows: managed cloud (registry) or local k3d. + if clusterType, err := service.DetectClusterType(name); err == nil { + contextName := name + if clusterType == models.ClusterTypeK3d { + contextName = k8s.ResolveContextForCluster(kubeconfig, name) + } + if clusterType == models.ClusterTypeGKE { + if info, err := service.GetClusterStatus(name); err == nil { + alignGcloudConfiguration(ctx, exec, info.Project) + } + } + return switchTo(kubeconfig, contextName, name) + } + + // 2) External GKE clusters, addressed by name via discovery. + return useExternalGKE(ctx, exec, kubeconfig, name) +} + +// useExternalGKE finds an external cluster by name and points kubectl at it, +// fetching credentials when the kubeconfig has no entry yet. +func useExternalGKE(ctx context.Context, exec executor.CommandExecutor, kubeconfig, name string) error { + d := discovery.NewGKEDiscoverer(exec) + switch d.AuthStatus(ctx) { + case discovery.CLIMissing: + return fmt.Errorf("cluster '%s' is not known locally, and gcloud is not installed to look for it in GCP", name) + case discovery.NotAuthenticated: + // One unambiguous flow: offer the login right here (interactive only). + pterm.Info.Printf("Cluster '%s' is not known locally — looking for it in your GCP projects requires a Google Cloud login\n", name) + if err := discovery.NewAuthFlow(exec).Ensure(ctx, false); err != nil { + return err + } + } + + result, err := d.Discover(ctx) + if err != nil { + return err + } + var found *models.ClusterInfo + for i := range result.Clusters { + if result.Clusters[i].Name == name { + found = &result.Clusters[i] + break + } + } + if found == nil { + return fmt.Errorf("cluster '%s' not found locally or in the %s", name, "GCP projects of your gcloud configurations") + } + + alignGcloudConfiguration(ctx, exec, found.Project) + + contextName := found.Context + if contextName == "" { + // No kubeconfig entry yet — fetch credentials (adds the gke_* context). + pterm.Info.Printf("Fetching credentials for '%s' (project %s, %s)...\n", name, found.Project, found.Region) + // --location (not --region): discovery's Region field carries the GKE + // location, which is a ZONE for zonal clusters. + if _, err := exec.Execute(ctx, "gcloud", "container", "clusters", "get-credentials", name, + "--project", found.Project, "--location", found.Region); err != nil { + return fmt.Errorf("could not fetch credentials for '%s' (for private clusters try 'gcloud container fleet memberships get-credentials %s'): %w", name, name, err) + } + contextName = fmt.Sprintf("gke_%s_%s_%s", found.Project, found.Region, name) + } + return switchTo(kubeconfig, contextName, name) +} + +// switchTo flips current-context and reports the result. +func switchTo(kubeconfig, contextName, clusterName string) error { + if !k8s.HasContext(kubeconfig, contextName) { + return fmt.Errorf("cluster '%s' has no kubeconfig context '%s' — fetch credentials for it first", clusterName, contextName) + } + if err := k8s.SwitchContext(kubeconfig, contextName); err != nil { + return err + } + pterm.Success.Printf("Switched kubectl context to '%s' (cluster '%s')\n", contextName, clusterName) + return nil +} + +// alignGcloudConfiguration activates the gcloud configuration whose project +// matches the cluster, so gcloud commands line up with kubectl. Best-effort: +// no matching configuration is fine, and failures never block the switch. +func alignGcloudConfiguration(ctx context.Context, exec executor.CommandExecutor, project string) { + if project == "" { + return + } + d := discovery.NewGKEDiscoverer(exec) + configName, err := d.ConfigurationForProject(ctx, project) + if err != nil || configName == "" { + return + } + if _, err := exec.Execute(ctx, "gcloud", "config", "configurations", "activate", configName); err == nil { + pterm.Info.Printf("Activated gcloud configuration '%s' (project %s)\n", configName, project) + } +} diff --git a/cmd/cluster/use_test.go b/cmd/cluster/use_test.go new file mode 100644 index 00000000..b78a7883 --- /dev/null +++ b/cmd/cluster/use_test.go @@ -0,0 +1,160 @@ +package cluster + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/flamingo-stack/openframe-cli/internal/cluster/models" + "github.com/flamingo-stack/openframe-cli/internal/cluster/providers/terraform" + "github.com/flamingo-stack/openframe-cli/internal/cluster/utils" + "github.com/flamingo-stack/openframe-cli/internal/k8s" + "github.com/flamingo-stack/openframe-cli/internal/shared/executor" +) + +// writeUseKubeconfig writes a kubeconfig with the given context names and +// points $KUBECONFIG at it. +func writeUseKubeconfig(t *testing.T, current string, contexts ...string) string { + t.Helper() + content := "apiVersion: v1\nkind: Config\ncurrent-context: " + current + "\nclusters:\n- name: c\n cluster:\n server: https://x.example\ncontexts:\n" + for _, name := range contexts { + content += "- name: " + name + "\n context:\n cluster: c\n user: u\n" + } + content += "users:\n- name: u\n" + path := filepath.Join(t.TempDir(), "kubeconfig") + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("KUBECONFIG", path) + return path +} + +func setupUse(t *testing.T) *executor.MockCommandExecutor { + t.Helper() + t.Setenv("OPENFRAME_CLUSTERS_DIR", t.TempDir()) + utils.InitGlobalFlags() + mock := executor.NewMockCommandExecutor() + utils.SetTestExecutor(mock) + t.Cleanup(utils.ResetGlobalFlags) + return mock +} + +func TestRunUseCluster_K3d(t *testing.T) { + mock := setupUse(t) + mock.SetResponse("k3d cluster get dev", &executor.CommandResult{ExitCode: 0, Stdout: "dev"}) + path := writeUseKubeconfig(t, "other", "other", "k3d-dev") + + if err := runUseCluster(getUseCmd(), []string{"dev"}); err != nil { + t.Fatalf("use k3d: %v", err) + } + _, current, _ := k8s.LoadContexts(path) + if current != "k3d-dev" { + t.Fatalf("current-context = %q, want k3d-dev", current) + } +} + +func TestRunUseCluster_ManagedGKEAlignsGcloudConfiguration(t *testing.T) { + mock := setupUse(t) + mock.SetResponse("gcloud config configurations list", &executor.CommandResult{ExitCode: 0, + Stdout: `[{"name":"dev-x","properties":{"core":{"project":"proj-x"}}}]`}) + record := terraform.Record{ + Name: "my-gke", Type: models.ClusterTypeGKE, Status: terraform.StatusReady, + Region: "us-central1", Project: "proj-x", NodeCount: 3, + } + reg := terraform.NewRegistry(os.Getenv("OPENFRAME_CLUSTERS_DIR")) + if err := reg.Workspace("my-gke").Scaffold(record, nil, nil); err != nil { + t.Fatal(err) + } + path := writeUseKubeconfig(t, "other", "other", "my-gke") + + if err := runUseCluster(getUseCmd(), []string{"my-gke"}); err != nil { + t.Fatalf("use managed gke: %v", err) + } + _, current, _ := k8s.LoadContexts(path) + if current != "my-gke" { + t.Fatalf("current-context = %q, want my-gke", current) + } + if !mock.WasCommandExecuted("gcloud config configurations activate dev-x") { + t.Fatal("expected the matching gcloud configuration to be activated") + } +} + +func TestRunUseCluster_ExternalGKEWithExistingContext(t *testing.T) { + mock := setupUse(t) + path := writeUseKubeconfig(t, "other", "other", "connectgateway_proj-x_us-central1_ext-1") + mock.SetResponse("gcloud auth list", &executor.CommandResult{ExitCode: 0, Stdout: "me@example.com\n"}) + mock.SetResponse("gcloud config configurations list", &executor.CommandResult{ExitCode: 0, + Stdout: `[{"name":"dev-x","properties":{"core":{"project":"proj-x"}}}]`}) + mock.SetResponse("clusters list --project proj-x", &executor.CommandResult{ExitCode: 0, + Stdout: `[{"name":"ext-1","location":"us-central1","status":"RUNNING","currentNodeCount":2}]`}) + // k3d detection must miss so the flow falls through to discovery. + mock.SetResponse("k3d cluster get ext-1", &executor.CommandResult{ExitCode: 1, Stderr: "not found"}) + + if err := runUseCluster(getUseCmd(), []string{"ext-1"}); err != nil { + t.Fatalf("use external gke: %v", err) + } + _, current, _ := k8s.LoadContexts(path) + if current != "connectgateway_proj-x_us-central1_ext-1" { + t.Fatalf("current-context = %q", current) + } + if mock.WasCommandExecuted("get-credentials") { + t.Fatal("credentials must not be re-fetched when a context already exists") + } +} + +func TestRunUseCluster_ExternalGKEFetchesCredentials(t *testing.T) { + mock := setupUse(t) + writeUseKubeconfig(t, "other", "other") + mock.SetResponse("gcloud auth list", &executor.CommandResult{ExitCode: 0, Stdout: "me@example.com\n"}) + mock.SetResponse("gcloud config configurations list", &executor.CommandResult{ExitCode: 0, + Stdout: `[{"name":"dev-x","properties":{"core":{"project":"proj-x"}}}]`}) + mock.SetResponse("clusters list --project proj-x", &executor.CommandResult{ExitCode: 0, + Stdout: `[{"name":"ext-1","location":"us-central1","status":"RUNNING","currentNodeCount":2}]`}) + mock.SetResponse("k3d cluster get ext-1", &executor.CommandResult{ExitCode: 1, Stderr: "not found"}) + + err := runUseCluster(getUseCmd(), []string{"ext-1"}) + // The mock cannot actually write the gke_* context, so the switch fails — + // but the credentials fetch must have been attempted first, with + // --location (which accepts both regions and zones). + if !mock.WasCommandExecuted("gcloud container clusters get-credentials ext-1 --project proj-x --location us-central1") { + t.Fatalf("expected a --location get-credentials attempt, got: %v", mock.GetExecutedCommands()) + } + if err == nil || !strings.Contains(err.Error(), "no kubeconfig context") { + t.Fatalf("expected a missing-context error after mock fetch, got: %v", err) + } +} + +func TestRunUseCluster_NotAuthenticatedIsActionable(t *testing.T) { + mock := setupUse(t) + writeUseKubeconfig(t, "other", "other") + mock.SetResponse("gcloud auth list", &executor.CommandResult{ExitCode: 0, Stdout: ""}) + mock.SetResponse("k3d cluster get ghost", &executor.CommandResult{ExitCode: 1, Stderr: "not found"}) + + err := runUseCluster(getUseCmd(), []string{"ghost"}) + if err == nil || !strings.Contains(err.Error(), "gcloud auth login") { + t.Fatalf("expected an auth hint, got: %v", err) + } +} + +// TestRunUseCluster_ExternalZonalGKEFetchesByLocation: a ZONAL cluster's +// location is a zone — get-credentials must receive it via --location, where +// --region would fail. +func TestRunUseCluster_ExternalZonalGKEFetchesByLocation(t *testing.T) { + mock := setupUse(t) + writeUseKubeconfig(t, "other", "other") + mock.SetResponse("gcloud auth list", &executor.CommandResult{ExitCode: 0, Stdout: "me@example.com\n"}) + mock.SetResponse("gcloud config configurations list", &executor.CommandResult{ExitCode: 0, + Stdout: `[{"name":"dev-x","properties":{"core":{"project":"proj-x"}}}]`}) + mock.SetResponse("clusters list --project proj-x", &executor.CommandResult{ExitCode: 0, + Stdout: `[{"name":"zonal-1","location":"us-central1-a","status":"RUNNING","currentNodeCount":1}]`}) + mock.SetResponse("k3d cluster get zonal-1", &executor.CommandResult{ExitCode: 1, Stderr: "not found"}) + + err := runUseCluster(getUseCmd(), []string{"zonal-1"}) + if !mock.WasCommandExecuted("gcloud container clusters get-credentials zonal-1 --project proj-x --location us-central1-a") { + t.Fatalf("expected a zone-valued --location fetch, got: %v", mock.GetExecutedCommands()) + } + if err == nil || !strings.Contains(err.Error(), "no kubeconfig context") { + t.Fatalf("expected a missing-context error after mock fetch, got: %v", err) + } +} diff --git a/cmd/root.go b/cmd/root.go index bc4aba36..babaca0d 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "os/signal" + "runtime/debug" "syscall" "github.com/flamingo-stack/openframe-cli/cmd/app" @@ -40,10 +41,54 @@ var ( // DefaultVersionInfo provides default version information, populated from the // build-time vars above (overridden via -ldflags -X at release time). -var DefaultVersionInfo = VersionInfo{ - Version: version, - Commit: commit, - Date: date, +var DefaultVersionInfo = resolveVersionInfo(version, commit, date) + +// resolveVersionInfo returns the build-time version metadata. On a dev build — +// where the release-time -ldflags were not applied, so commit is "none" and +// date "unknown" — it backfills them from the VCS stamp Go embeds in every +// `go build`/`go install`/`make build` binary, so the build is identifiable +// ("dev (85c7c15f11b9) built on