diff --git a/Makefile b/Makefile index d6e42b61..fa0fa40d 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ # 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 @@ -50,6 +50,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) diff --git a/README.md b/README.md index 4797610d..9b290b5a 100644 --- a/README.md +++ b/README.md @@ -162,7 +162,7 @@ The bootstrap process creates: | Command | Description | Example | |---------|-------------|---------| | `openframe bootstrap` | Create a cluster and deploy OpenFrame | `openframe bootstrap my-cluster` | -| `openframe cluster create` | Create a Kubernetes cluster | `openframe cluster create dev --nodes 1` | +| `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` | @@ -180,6 +180,7 @@ Cluster lifecycle: ```bash openframe cluster create dev --type k3d --nodes 1 --skip-wizard +openframe cluster create my-eks --type eks --region us-east-1 --skip-wizard # cloud (billed!) openframe cluster list # add -o json|yaml for scripts openframe cluster status dev openframe cluster delete dev --force diff --git a/cmd/cluster/cluster.go b/cmd/cluster/cluster.go index a4bf8639..152d73fb 100644 --- a/cmd/cluster/cluster.go +++ b/cmd/cluster/cluster.go @@ -26,7 +26,7 @@ This command group provides cluster lifecycle management functionality: • status - Display detailed cluster information • cleanup - Remove unused images and resources -Supports K3d clusters for local development. +Supports K3d clusters for local development and AWS EKS for cloud deployments. Examples: openframe cluster create @@ -46,6 +46,12 @@ 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); the other subcommands + // are k3d-scoped, so the k3d gate stays here. + if cmd.Name() == "create" { + return nil + } return prerequisites.CheckPrerequisites() }, RunE: func(cmd *cobra.Command, args []string) error { diff --git a/cmd/cluster/create.go b/cmd/cluster/create.go index 52129e5a..7b4741a5 100644 --- a/cmd/cluster/create.go +++ b/cmd/cluster/create.go @@ -1,12 +1,18 @@ package cluster import ( + "context" "fmt" "strings" "github.com/flamingo-stack/openframe-cli/internal/cluster/models" + "github.com/flamingo-stack/openframe-cli/internal/cluster/prerequisites" + "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" + "github.com/pterm/pterm" "github.com/spf13/cobra" ) @@ -23,16 +29,22 @@ 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 EKS cluster for OpenFrame. 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. + +EKS 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-eks --type eks --region us-east-1 --skip-wizard`, Args: cobra.MaximumNArgs(1), PreRunE: func(cmd *cobra.Command, args []string) error { utils.SyncGlobalFlags() @@ -57,6 +69,44 @@ 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 + } + + 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 + } + pterm.Success.Printf("Plan: %d to add, %d to change, %d to destroy\n", summary.Add, summary.Change, summary.Destroy) + return nil +} + func runCreateCluster(cmd *cobra.Command, args []string) error { service := utils.GetCommandService() globalFlags := utils.GetGlobalFlags() @@ -117,6 +167,22 @@ 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, + BackendConfig: cf.BackendConfig, + } + } } // Show configuration summary for dry-run or skip-wizard modes @@ -124,12 +190,24 @@ 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 + } + // 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) diff --git a/cmd/cluster/create_behavior_test.go b/cmd/cluster/create_behavior_test.go index 98b39f22..59fd629b 100644 --- a/cmd/cluster/create_behavior_test.go +++ b/cmd/cluster/create_behavior_test.go @@ -1,8 +1,10 @@ package cluster import ( + "context" "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/internal/shared/executor" "github.com/flamingo-stack/openframe-cli/tests/testutil" @@ -72,6 +74,63 @@ func TestRunCreateCluster_DryRunDefaultsNameWhenNoArgs(t *testing.T) { } } +func TestRunCreateCluster_CloudDryRunRunsPlanPreview(t *testing.T) { + for _, clusterType := range []string{"eks", "gke"} { + t.Run(clusterType, func(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 = clusterType + gf.Create.Region = "us-east-1" + gf.Create.Project = "my-project" + + if err := runCreateCluster(cmd, []string{"cloud-cluster"}); err != nil { + t.Fatalf("%s dry-run should return nil, got %v", clusterType, err) + } + if previewed == nil { + t.Fatal("cloud dry-run must invoke the terraform plan preview") + } + if previewed.Cloud == nil || previewed.Cloud.Region != "us-east-1" { + 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() diff --git a/cmd/cluster/delete.go b/cmd/cluster/delete.go index 57069e92..c986f8f5 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 { 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/root.go b/cmd/root.go index bc4aba36..73201262 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -64,7 +64,7 @@ for CLI design with wizard-style interactive prompts. Key Features: - Interactive Wizard - Step-by-step guided setup - - Cluster Management - K3d, Kind, and cloud provider support + - Cluster Management - local K3d clusters (cloud providers planned) - Helm Integration - App-of-Apps pattern with ArgoCD - Prerequisite Checking - Validates tools before running diff --git a/docs/README.md b/docs/README.md index df1f0a73..f9ba3031 100644 --- a/docs/README.md +++ b/docs/README.md @@ -10,11 +10,12 @@ This repository (`flamingo-stack/openframe-cli`) is the CLI. The platform and ap - [Prerequisites](./getting-started/prerequisites.md) — System requirements and dependencies - [Quick Start](./getting-started/quick-start.md) — Install and bootstrap in a few minutes - [First Steps](./getting-started/first-steps.md) — Core commands and workflows +- [Cloud Clusters](./getting-started/cloud-clusters.md) — Provision EKS/GKE clusters with Terraform ## Commands - `openframe bootstrap` — Create a cluster and install the platform in one step -- `openframe cluster {create,delete,list,status,cleanup}` — Manage k3d clusters +- `openframe cluster {create,delete,list,status,cleanup}` — Manage k3d and cloud (EKS/GKE) clusters - `openframe app {install,upgrade,status,access,uninstall}` — Manage the OpenFrame app-of-apps deployment - `openframe prerequisites {check,install}` — Check and install required tools - `openframe update` (`check`, `rollback`, `update `) — Self-update the CLI diff --git a/docs/architecture/decisions.md b/docs/architecture/decisions.md index 0992717b..7e417584 100644 --- a/docs/architecture/decisions.md +++ b/docs/architecture/decisions.md @@ -83,13 +83,23 @@ for the OSS tenant deployment. ## D5 — Cluster providers behind a unified interface -Cluster creation goes through a `Provider` interface parameterized by -**provider** (k3d local now; GKE/EKS later) and **target** (local vs cloud). -Only **k3d** is implemented; cloud providers return a clear "coming soon" -message. No new providers are added now — the interface exists so they can be -added later without touching the rest of the CLI. - -For OSS the target is always **local** (k3d). SaaS targets (cloud) are future work. +Cluster creation goes through a `Provider` interface with three backends: +**k3d** (local), **EKS**, and **GKE** (cloud). Backends are selected via the +`provider.New(type)` factory, keyed on `ClusterConfig.Type`; the rest of the +CLI never knows which backend runs. Cloud providers additionally implement +`Planner` (`--dry-run` renders a real `terraform plan` footprint). + +The cloud backends share one terraform engine (D7/D8): each generates a +pinned, self-contained root module on the public `terraform-aws-modules` / +`terraform-google-modules` modules and drives `terraform` via terraform-exec. +Kubeconfig entries carry no static credentials — auth runs through the +provider CLI exec plugins (`aws eks get-token`, `gke-gcloud-auth-plugin`), +with the context named after the cluster so exact-match context resolution +works unchanged. + +For OSS the default remains **local** (k3d); cloud clusters are an explicit +`--type eks|gke` opt-in with a cost warning and a typed-name confirmation on +delete. --- @@ -110,6 +120,33 @@ typed argo-cd clientset. Benefits: --- +## D7 — Terraform (BUSL) as the provisioning engine, installed verified + +Cloud clusters are provisioned with **HashiCorp Terraform**, not OpenTofu. +BUSL 1.1 only restricts "hosted or embedded" offerings **competitive with +HashiCorp's products**; this CLI uses terraform as an internal tool to +provision the user's own infrastructure, which is not a competitive offering. +The binary is installed like every other prerequisite: a pinned version with +SHA256 verification into `~/.openframe/bin` (no curl-pipe-bash, no sudo). An +already-installed `terraform` on PATH in `~/.openframe/bin` is preferred. + +If a server-side scenario ever provisions clusters *as a service* with +terraform, that is a different BUSL use profile and needs its own review. + +## D8 — Local terraform state in per-cluster workspaces + +Each cloud cluster owns a workspace under `~/.openframe/clusters//`: +the generated root module, `terraform.tfvars.json`, local state, and a +`cluster.json` registry record (type, status, endpoint/CA). The registry is +what makes cloud clusters visible to `list`/`status`/`delete` without cloud +API calls, and the state file is the only pointer to billed resources — so a +workspace is **never deleted on a failed apply**, only after a successful +destroy. Re-running `create` resumes an interrupted apply. + +Remote state is opt-in via `--backend-config s3://bucket/prefix` (EKS) or +`gcs://bucket/prefix` (GKE) for users who need the state to survive the +machine that created the cluster. + ## Platform support - **macOS / Linux** — full support; prerequisites are checked and auto-installed. diff --git a/docs/getting-started/cloud-clusters.md b/docs/getting-started/cloud-clusters.md new file mode 100644 index 00000000..e5a9108a --- /dev/null +++ b/docs/getting-started/cloud-clusters.md @@ -0,0 +1,98 @@ +# Cloud Clusters (EKS / GKE) + +Besides local k3d clusters, `openframe cluster create` can provision managed +Kubernetes clusters in AWS (EKS) or Google Cloud (GKE) using Terraform under +the hood. The CLI installs its own verified Terraform binary and generates the +infrastructure code for you — no Terraform knowledge required. + +> **Cost warning.** Cloud clusters create billed resources: a managed control +> plane (~$73/month on both providers), VM nodes, and NAT/networking. The CLI +> shows this warning before creating and requires you to re-type the cluster +> name before deleting. + +## Prerequisites + +Checked and installed automatically on `cluster create`: + +| Type | Tools | You provide | +|------|-------|-------------| +| eks | terraform (pinned, verified), AWS CLI | working AWS credentials (`aws configure` or `--profile`) | +| gke | terraform (pinned, verified), gcloud, gke-gcloud-auth-plugin | `gcloud auth login` + a GCP project | + +Credentials are preflighted before anything is created (`aws sts +get-caller-identity` / `gcloud auth print-access-token`), so a broken login +fails in seconds, not mid-provisioning. + +## Creating a cluster + +Interactive (wizard asks for type, region, instance type): + +```bash +openframe cluster create +``` + +Non-interactive: + +```bash +# AWS EKS +openframe cluster create my-eks --type eks --region us-east-1 --skip-wizard + +# Google GKE +openframe cluster create my-gke --type gke --project my-project --region us-central1 --skip-wizard +``` + +Useful flags: `--machine-type`, `--min-nodes` / `--max-nodes`, `--spot`, +`--profile` (AWS), `--nodes` (initial size), `--version` (`.`, +e.g. `1.33`). + +Provisioning takes ~10–20 minutes; the CLI streams per-resource progress. +When it finishes, your kubeconfig gets a context named after the cluster and +it becomes the current context — `kubectl get nodes` just works +(authentication runs through short-lived tokens via `aws eks get-token` / +`gke-gcloud-auth-plugin`; no static credentials are stored). + +## Previewing without creating + +`--dry-run` runs a real `terraform plan` and prints the resource footprint +without creating anything (and without registering the cluster): + +```bash +openframe cluster create my-eks --type eks --region us-east-1 --skip-wizard --dry-run +# Plan: 47 to add, 0 to change, 0 to destroy +``` + +## Where the state lives + +Each cloud cluster owns a workspace in `~/.openframe/clusters//`: the +generated Terraform module and the state file. The state is the only pointer +to your billed cloud resources — the workspace is never deleted on a failed +create, only after a successful delete. + +- **A create failed or was interrupted?** Re-run the same `cluster create` — + it resumes where it stopped. +- **Want the state to survive your machine?** Pass a remote backend at + create time: `--backend-config s3://bucket/prefix` (EKS) or + `--backend-config gcs://bucket/prefix` (GKE). + +## Day-2 commands + +```bash +openframe cluster list # local + cloud clusters +openframe cluster status my-eks +openframe cluster delete my-eks # terraform destroy; asks to re-type the name +openframe app install # install OpenFrame onto the current context +``` + +`cluster delete --force` skips the typed confirmation (for CI). `cluster +cleanup` does not apply to cloud clusters — use `delete`. + +## Troubleshooting + +- **"AWS ... cannot authenticate" / "gcloud is not authenticated"** — fix + credentials (`aws configure`, `gcloud auth login`) and re-run; nothing was + created. +- **Create failed mid-way** — the error names the workspace directory. Re-run + `cluster create ` to resume, or `cluster delete ` to tear down + what was partially created. +- **Verbose Terraform output** — add `--verbose` to stream Terraform's own + logs during create/delete. diff --git a/go.mod b/go.mod index fee54696..555f34c9 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,8 @@ go 1.26.0 require ( github.com/elastic/go-sysinfo v1.15.5 github.com/go-git/go-git/v5 v5.19.1 + github.com/hashicorp/terraform-exec v0.25.2 + github.com/hashicorp/terraform-json v0.27.2 github.com/manifoldco/promptui v0.9.0 github.com/pterm/pterm v0.12.83 github.com/sigstore/sigstore-go v1.2.2 @@ -28,6 +30,7 @@ require ( dario.cat/mergo v1.0.2 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/ProtonMail/go-crypto v1.4.1 // indirect + github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect github.com/blang/semver v3.5.1+incompatible // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect @@ -80,6 +83,7 @@ require ( github.com/google/uuid v1.6.0 // indirect github.com/gookit/color v1.6.1 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect + github.com/hashicorp/go-version v1.9.0 // indirect github.com/in-toto/attestation v1.2.0 // indirect github.com/in-toto/in-toto-golang v0.11.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect @@ -120,6 +124,7 @@ require ( github.com/xanzy/ssh-agent v0.3.3 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect + github.com/zclconf/go-cty v1.18.1 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/otel v1.44.0 // indirect go.opentelemetry.io/otel/metric v1.44.0 // indirect diff --git a/go.sum b/go.sum index d7c3dd85..5875f906 100644 --- a/go.sum +++ b/go.sum @@ -51,6 +51,8 @@ github.com/alessio/shellescape v1.4.1 h1:V7yhSDDn8LP4lc4jS8pFkt0zCnzVJlG5JXy9BVK github.com/alessio/shellescape v1.4.1/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPpyFjDCtHLS30= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= +github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY= +github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= @@ -270,8 +272,16 @@ github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9 github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4= github.com/hashicorp/go-sockaddr v1.0.7 h1:G+pTkSO01HpR5qCxg7lxfsFEZaG+C0VssTy/9dbT+Fw= github.com/hashicorp/go-sockaddr v1.0.7/go.mod h1:FZQbEYa1pxkQ7WLpyXJ6cbjpT8q0YgQaK/JakXqGyWw= +github.com/hashicorp/go-version v1.9.0 h1:CeOIz6k+LoN3qX9Z0tyQrPtiB1DFYRPfCIBtaXPSCnA= +github.com/hashicorp/go-version v1.9.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/hashicorp/hc-install v0.9.5 h1:XHCjcMn2563ysuaQ9v9ec2FNc7c2PJOIEEGobAFeIx4= +github.com/hashicorp/hc-install v0.9.5/go.mod h1:ihEW4LshrNkxq2bU/MpVbKyn+yt1is2hYqUTHDGhG84= github.com/hashicorp/hcl v1.0.1-vault-7 h1:ag5OxFVy3QYTFTJODRzTKVZ6xvdfLLCA1cy/Y6xGI0I= github.com/hashicorp/hcl v1.0.1-vault-7/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM= +github.com/hashicorp/terraform-exec v0.25.2 h1:fFLAVEtAjKdGfawGUXDnKooCnqJi+TuohT3W99AGbhk= +github.com/hashicorp/terraform-exec v0.25.2/go.mod h1:uaQV2oqVLqM4cixJryk6qIWS1qji3GtuwPG5pjGXYfc= +github.com/hashicorp/terraform-json v0.27.2 h1:BwGuzM6iUPqf9JYM/Z4AF1OJ5VVJEEzoKST/tRDBJKU= +github.com/hashicorp/terraform-json v0.27.2/go.mod h1:GzPLJ1PLdUG5xL6xn1OXWIjteQRT2CNT9o/6A9mi9hE= github.com/hashicorp/vault/api v1.22.0 h1:+HYFquE35/B74fHoIeXlZIP2YADVboaPjaSicHEZiH0= github.com/hashicorp/vault/api v1.22.0/go.mod h1:IUZA2cDvr4Ok3+NtK2Oq/r+lJeXkeCrHRmqdyWfpmGM= github.com/howeyc/gopass v0.0.0-20210920133722-c8aef6fb66ef h1:A9HsByNhogrvm9cWb28sjiS3i7tcKCkflWFEkHfuAgM= @@ -434,6 +444,8 @@ github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfS github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/zalando/go-keyring v0.2.3 h1:v9CUu9phlABObO4LPWycf+zwMG7nlbb3t/B5wa97yms= github.com/zalando/go-keyring v0.2.3/go.mod h1:HL4k+OXQfJUWaMnqyuSOc0drfGPX2b51Du6K+MRgZMk= +github.com/zclconf/go-cty v1.18.1 h1:yEGE8M4iIZlyKQURZNb2SnEyZlZHUcBCnx6KF81KuwM= +github.com/zclconf/go-cty v1.18.1/go.mod h1:qpnV6EDNgC1sns/AleL1fvatHw72j+S+nS+MJ+T2CSg= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 h1:yI1/OhfEPy7J9eoa6Sj051C7n5dvpj0QX8g4sRchg04= diff --git a/internal/cluster/models/cluster.go b/internal/cluster/models/cluster.go index b903f857..11c48f34 100644 --- a/internal/cluster/models/cluster.go +++ b/internal/cluster/models/cluster.go @@ -17,6 +17,25 @@ type ClusterConfig struct { Type ClusterType `json:"type"` NodeCount int `json:"node_count"` K8sVersion string `json:"k8s_version"` + // Cloud carries the settings that only make sense for managed cloud + // clusters (GKE/EKS). Nil for local clusters; the k3d backend rejects a + // config that sets it. + Cloud *CloudConfig `json:"cloud,omitempty"` +} + +// CloudConfig holds the provider-agnostic knobs for a managed cloud cluster. +type CloudConfig struct { + Region string `json:"region"` + Project string `json:"project,omitempty"` // GCP project + Profile string `json:"profile,omitempty"` // AWS profile + MachineType string `json:"machine_type,omitempty"` + MinNodes int `json:"min_nodes,omitempty"` + MaxNodes int `json:"max_nodes,omitempty"` + Spot bool `json:"spot,omitempty"` + // BackendConfig is an optional remote-state location + // (s3://bucket/prefix for EKS, gcs://bucket/prefix for GKE); + // empty means local state in the cluster workspace. + BackendConfig string `json:"backend_config,omitempty"` } // ClusterInfo represents information about a cluster @@ -41,21 +60,3 @@ type NodeInfo struct { Status string `json:"status"` Role string `json:"role"` } - -// ProviderOptions contains provider-specific options -type ProviderOptions struct { - K3d *K3dOptions `json:"k3d,omitempty"` - GKE *GKEOptions `json:"gke,omitempty"` - Verbose bool `json:"verbose,omitempty"` -} - -// K3dOptions contains k3d-specific options -type K3dOptions struct { - PortMappings []string `json:"port_mappings,omitempty"` -} - -// GKEOptions contains GKE-specific options -type GKEOptions struct { - Zone string `json:"zone"` - Project string `json:"project"` -} diff --git a/internal/cluster/models/cluster_test.go b/internal/cluster/models/cluster_test.go index d3571833..341a430b 100644 --- a/internal/cluster/models/cluster_test.go +++ b/internal/cluster/models/cluster_test.go @@ -1,6 +1,7 @@ package models import ( + "encoding/json" "testing" "time" @@ -177,84 +178,29 @@ func TestNodeInfo(t *testing.T) { }) } -func TestProviderOptions(t *testing.T) { - t.Run("creates provider options with K3d options", func(t *testing.T) { - options := ProviderOptions{ - K3d: &K3dOptions{ - PortMappings: []string{"8080:80@loadbalancer", "8443:443@loadbalancer"}, - }, - Verbose: true, - } +func TestCloudConfig(t *testing.T) { + t.Run("cluster config without cloud settings has nil Cloud", func(t *testing.T) { + var config ClusterConfig - assert.NotNil(t, options.K3d) - assert.Equal(t, []string{"8080:80@loadbalancer", "8443:443@loadbalancer"}, options.K3d.PortMappings) - assert.True(t, options.Verbose) - assert.Nil(t, options.GKE) + assert.Nil(t, config.Cloud) }) - t.Run("creates provider options with GKE options", func(t *testing.T) { - options := ProviderOptions{ - GKE: &GKEOptions{ - Zone: "us-central1-a", - Project: "my-project", - }, + t.Run("holds provider-agnostic cloud settings", func(t *testing.T) { + cloud := CloudConfig{ + Region: "us-east-1", + Profile: "default", + MachineType: "m6i.large", + MinNodes: 1, + MaxNodes: 5, + Spot: true, } - assert.NotNil(t, options.GKE) - assert.Equal(t, "us-central1-a", options.GKE.Zone) - assert.Equal(t, "my-project", options.GKE.Project) - assert.Nil(t, options.K3d) - assert.False(t, options.Verbose) - }) - - t.Run("creates empty provider options", func(t *testing.T) { - options := ProviderOptions{} - - assert.Nil(t, options.K3d) - assert.Nil(t, options.GKE) - assert.False(t, options.Verbose) - }) -} - -func TestK3dOptions(t *testing.T) { - t.Run("creates K3d options with port mappings", func(t *testing.T) { - options := K3dOptions{ - PortMappings: []string{ - "8080:80@loadbalancer", - "8443:443@loadbalancer", - "6550:6443@server:0", - }, - } - - assert.Len(t, options.PortMappings, 3) - assert.Contains(t, options.PortMappings, "8080:80@loadbalancer") - assert.Contains(t, options.PortMappings, "8443:443@loadbalancer") - assert.Contains(t, options.PortMappings, "6550:6443@server:0") - }) - - t.Run("creates empty K3d options", func(t *testing.T) { - options := K3dOptions{} - - assert.Empty(t, options.PortMappings) - }) -} - -func TestGKEOptions(t *testing.T) { - t.Run("creates GKE options with zone and project", func(t *testing.T) { - options := GKEOptions{ - Zone: "europe-west1-b", - Project: "my-gcp-project", - } - - assert.Equal(t, "europe-west1-b", options.Zone) - assert.Equal(t, "my-gcp-project", options.Project) - }) - - t.Run("creates empty GKE options", func(t *testing.T) { - options := GKEOptions{} - - assert.Empty(t, options.Zone) - assert.Empty(t, options.Project) + assert.Equal(t, "us-east-1", cloud.Region) + assert.Equal(t, "default", cloud.Profile) + assert.Equal(t, "m6i.large", cloud.MachineType) + assert.Equal(t, 1, cloud.MinNodes) + assert.Equal(t, 5, cloud.MaxNodes) + assert.True(t, cloud.Spot) }) } @@ -289,16 +235,25 @@ func TestJSONSerialization(t *testing.T) { assert.Equal(t, 5, info.NodeCount) }) - t.Run("provider options serialization", func(t *testing.T) { - options := ProviderOptions{ - K3d: &K3dOptions{ - PortMappings: []string{"8080:80@loadbalancer"}, + t.Run("cloud config round-trips through JSON and is omitted when nil", func(t *testing.T) { + config := ClusterConfig{ + Name: "cloud-cluster", + Type: ClusterTypeEKS, + Cloud: &CloudConfig{ + Region: "eu-west-1", + MaxNodes: 4, }, - Verbose: true, } - // Basic validation that struct tags are correct - assert.NotNil(t, options.K3d) - assert.True(t, options.Verbose) + data, err := json.Marshal(config) + assert.NoError(t, err) + + var decoded ClusterConfig + assert.NoError(t, json.Unmarshal(data, &decoded)) + assert.Equal(t, config, decoded) + + local, err := json.Marshal(ClusterConfig{Name: "local", Type: ClusterTypeK3d}) + assert.NoError(t, err) + assert.NotContains(t, string(local), "cloud") }) } diff --git a/internal/cluster/models/flags.go b/internal/cluster/models/flags.go index 0302590e..1f04e61e 100644 --- a/internal/cluster/models/flags.go +++ b/internal/cluster/models/flags.go @@ -19,6 +19,16 @@ type CreateFlags struct { NodeCount int K8sVersion string SkipWizard bool + + // Cloud-only flags (EKS/GKE) + Region string + Profile string // AWS + Project string // GCP + MachineType string + MinNodes int + MaxNodes int + Spot bool + BackendConfig string } // ListFlags contains flags specific to list command @@ -56,10 +66,19 @@ func AddGlobalFlags(cmd *cobra.Command, global *GlobalFlags) { // AddCreateFlags adds create-specific flags to a command func AddCreateFlags(cmd *cobra.Command, flags *CreateFlags) { - cmd.Flags().StringVarP(&flags.ClusterType, "type", "t", "", "Cluster type (k3d, gke)") + cmd.Flags().StringVarP(&flags.ClusterType, "type", "t", "", "Cluster type (k3d, eks, gke)") cmd.Flags().IntVarP(&flags.NodeCount, "nodes", "n", 3, "Number of nodes (default 3)") cmd.Flags().StringVar(&flags.K8sVersion, "version", "", "Kubernetes version") cmd.Flags().BoolVar(&flags.SkipWizard, "skip-wizard", false, "Skip interactive wizard") + + cmd.Flags().StringVar(&flags.Region, "region", "", "Cloud region (required for cloud types)") + cmd.Flags().StringVar(&flags.Profile, "profile", "", "AWS credentials profile (eks only)") + cmd.Flags().StringVar(&flags.Project, "project", "", "GCP project (required for --type gke)") + cmd.Flags().StringVar(&flags.MachineType, "machine-type", "", "Node instance type (cloud only; defaults: m6i.large on eks, e2-standard-4 on gke)") + cmd.Flags().IntVar(&flags.MinNodes, "min-nodes", 0, "Node group minimum size (cloud only)") + cmd.Flags().IntVar(&flags.MaxNodes, "max-nodes", 0, "Node group maximum size (cloud only)") + cmd.Flags().BoolVar(&flags.Spot, "spot", false, "Use spot capacity for nodes (cloud only)") + cmd.Flags().StringVar(&flags.BackendConfig, "backend-config", "", "Remote terraform state: s3://bucket/prefix (eks) or gcs://bucket/prefix (gke); default is local state") } // AddListFlags adds list-specific flags to a command @@ -131,6 +150,34 @@ func ValidateCreateFlags(flags *CreateFlags) error { return err } + // Reject unknown --type values up front. + clusterType := ClusterType(flags.ClusterType) + switch clusterType { + case "", ClusterTypeK3d, ClusterTypeGKE, ClusterTypeEKS: + // known + default: + return fmt.Errorf("unknown cluster type '%s' (supported: k3d, eks, gke)", flags.ClusterType) + } + + // The wizard prompts for these; in skip-wizard mode they must come from + // flags. + isCloud := clusterType == ClusterTypeEKS || clusterType == ClusterTypeGKE + if isCloud && flags.SkipWizard && flags.Region == "" { + return fmt.Errorf("--region is required for --type %s with --skip-wizard", flags.ClusterType) + } + if clusterType == ClusterTypeGKE && flags.SkipWizard && flags.Project == "" { + return fmt.Errorf("--project is required for --type gke with --skip-wizard") + } + if flags.BackendConfig != "" && !isCloud { + return fmt.Errorf("--backend-config only applies to cloud cluster types (eks, gke)") + } + if flags.MinNodes < 0 || flags.MaxNodes < 0 { + return fmt.Errorf("node bounds must not be negative: min=%d max=%d", flags.MinNodes, flags.MaxNodes) + } + if flags.MinNodes > 0 && flags.MaxNodes > 0 && flags.MinNodes > flags.MaxNodes { + return fmt.Errorf("--min-nodes (%d) must not exceed --max-nodes (%d)", flags.MinNodes, flags.MaxNodes) + } + // Validate node count - this validation is now handled at command level // to distinguish between explicitly set values and defaults if flags.NodeCount <= 0 { diff --git a/internal/cluster/models/flags_test.go b/internal/cluster/models/flags_test.go index 7676d6f4..26ff3f07 100644 --- a/internal/cluster/models/flags_test.go +++ b/internal/cluster/models/flags_test.go @@ -286,6 +286,23 @@ func TestFlagValidation(t *testing.T) { assert.Contains(t, err.Error(), "node count must be at least 1") }) + t.Run("accepts empty and recognized cluster types", func(t *testing.T) { + for _, clusterType := range []string{"", "k3d", "gke", "eks"} { + flags := &CreateFlags{ClusterType: clusterType, NodeCount: 3} + + err := ValidateCreateFlags(flags) + assert.NoError(t, err, "type %q should pass flag validation", clusterType) + } + }) + + t.Run("rejects unknown cluster type", func(t *testing.T) { + flags := &CreateFlags{ClusterType: "minikube", NodeCount: 3} + + err := ValidateCreateFlags(flags) + assert.Error(t, err) + assert.Contains(t, err.Error(), "unknown cluster type 'minikube'") + }) + t.Run("validates list flags", func(t *testing.T) { flags := &ListFlags{Quiet: true} @@ -315,3 +332,17 @@ func TestFlagValidation(t *testing.T) { assert.NoError(t, err) }) } + +func TestValidateCreateFlags_BackendConfig(t *testing.T) { + t.Run("accepted for cloud types", func(t *testing.T) { + flags := &CreateFlags{ClusterType: "eks", SkipWizard: true, Region: "us-east-1", NodeCount: 3, BackendConfig: "s3://bucket/prefix"} + assert.NoError(t, ValidateCreateFlags(flags)) + }) + + t.Run("rejected for k3d", func(t *testing.T) { + flags := &CreateFlags{ClusterType: "k3d", NodeCount: 3, BackendConfig: "s3://bucket/prefix"} + err := ValidateCreateFlags(flags) + assert.Error(t, err) + assert.Contains(t, err.Error(), "only applies to cloud cluster types") + }) +} diff --git a/internal/cluster/prerequisites/aws/aws.go b/internal/cluster/prerequisites/aws/aws.go new file mode 100644 index 00000000..ae5f6048 --- /dev/null +++ b/internal/cluster/prerequisites/aws/aws.go @@ -0,0 +1,94 @@ +package aws + +import ( + "context" + "fmt" + "os/exec" + "runtime" + "time" + + "github.com/flamingo-stack/openframe-cli/internal/platform" +) + +// AwsInstaller installs the AWS CLI required by the EKS provider (kubeconfig +// auth runs through `aws eks get-token`). Credentials are NOT checked here — +// the EKS provider preflights them with `aws sts get-caller-identity` so the +// error can name the profile being used. +type AwsInstaller struct{} + +func NewAwsInstaller() *AwsInstaller { + return &AwsInstaller{} +} + +func commandExists(cmd string) bool { + _, err := exec.LookPath(cmd) + return err == nil +} + +func (a *AwsInstaller) IsInstalled() bool { + if !commandExists("aws") { + return false + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + return exec.CommandContext(ctx, "aws", "--version").Run() == nil +} + +func (a *AwsInstaller) GetInstallHelp() string { + return platform.InstallHint("aws") +} + +// Install installs the AWS CLI via the platform package manager. There is no +// verified-download fallback: AWS ships the v2 CLI as a frequently-rotated +// installer bundle without stable per-version checksums to pin. +func (a *AwsInstaller) Install() error { + switch runtime.GOOS { + case "darwin": + return a.installMacOS() + case "linux": + return a.installLinux() + default: + // Windows is unsupported here by design: the CLI forwards into WSL and + // runs as linux, so native-Windows install code is never reached. + return fmt.Errorf("automatic AWS CLI installation not supported on %s", runtime.GOOS) + } +} + +func (a *AwsInstaller) installMacOS() error { + if !commandExists("brew") { + return fmt.Errorf("automatic AWS CLI installation on macOS requires Homebrew. Please install brew first: https://brew.sh") + } + if err := runCommand("brew", "install", "awscli"); err != nil { + return fmt.Errorf("failed to install AWS CLI: %w", err) + } + return nil +} + +func (a *AwsInstaller) installLinux() error { + type pm struct { + name string + args []string + } + managers := []pm{ + {"apt", []string{"apt", "install", "-y", "awscli"}}, + {"dnf", []string{"dnf", "install", "-y", "awscli"}}, + {"yum", []string{"yum", "install", "-y", "awscli"}}, + {"pacman", []string{"pacman", "-S", "--noconfirm", "aws-cli"}}, + } + for _, m := range managers { + if !commandExists(m.name) { + continue + } + // Package installs need root; sudo -n keeps this non-interactive (the + // prerequisite flow already runs under a user confirmation). + if err := runCommand("sudo", append([]string{"-n"}, m.args...)...); err == nil { + return nil + } + } + return fmt.Errorf("could not install the AWS CLI automatically. %s", a.GetInstallHelp()) +} + +func runCommand(name string, args ...string) error { + cmd := exec.Command(name, args...) // #nosec G204 -- explicit argv, no shell; command and args are internal, not untrusted input + return cmd.Run() +} diff --git a/internal/cluster/prerequisites/checker.go b/internal/cluster/prerequisites/checker.go index 0acb3d25..935e56ef 100644 --- a/internal/cluster/prerequisites/checker.go +++ b/internal/cluster/prerequisites/checker.go @@ -1,9 +1,13 @@ package prerequisites import ( + "github.com/flamingo-stack/openframe-cli/internal/cluster/models" + "github.com/flamingo-stack/openframe-cli/internal/cluster/prerequisites/aws" "github.com/flamingo-stack/openframe-cli/internal/cluster/prerequisites/docker" + "github.com/flamingo-stack/openframe-cli/internal/cluster/prerequisites/gcloud" "github.com/flamingo-stack/openframe-cli/internal/cluster/prerequisites/helm" "github.com/flamingo-stack/openframe-cli/internal/cluster/prerequisites/k3d" + "github.com/flamingo-stack/openframe-cli/internal/cluster/prerequisites/terraform" "github.com/flamingo-stack/openframe-cli/internal/shared/ui" ) @@ -16,8 +20,10 @@ type Requirement struct { Command string IsInstalled func() bool InstallHelp func() string + Install func() error } +// NewPrerequisiteChecker returns the requirement set for local k3d clusters. func NewPrerequisiteChecker() *PrerequisiteChecker { return &PrerequisiteChecker{ requirements: []Requirement{ @@ -31,18 +37,47 @@ func NewPrerequisiteChecker() *PrerequisiteChecker { } return "Docker is installed but not running. Please start Docker Desktop or the Docker daemon." }, + Install: func() error { return docker.NewDockerInstaller().Install() }, }, { Name: "k3d", Command: "k3d", IsInstalled: func() bool { return k3d.NewK3dInstaller().IsInstalled() }, InstallHelp: func() string { return k3d.NewK3dInstaller().GetInstallHelp() }, + Install: func() error { return k3d.NewK3dInstaller().Install() }, }, { Name: "helm", Command: "helm", IsInstalled: func() bool { return helm.NewHelmInstaller().IsInstalled() }, InstallHelp: func() string { return helm.NewHelmInstaller().GetInstallHelp() }, + Install: func() error { return helm.NewHelmInstaller().Install() }, + }, + }, + } +} + +// NewEKSPrerequisiteChecker returns the requirement set for EKS clusters: +// terraform (provisioning engine) and the AWS CLI (kubeconfig exec auth). +// Docker/k3d are deliberately absent — a cloud cluster needs no local runtime. +// AWS credentials are preflighted by the EKS provider itself, where the error +// can name the profile in use. +func NewEKSPrerequisiteChecker() *PrerequisiteChecker { + return &PrerequisiteChecker{ + requirements: []Requirement{ + { + Name: "terraform", + Command: "terraform", + IsInstalled: func() bool { return terraform.NewTerraformInstaller().IsInstalled() }, + InstallHelp: func() string { return terraform.NewTerraformInstaller().GetInstallHelp() }, + Install: func() error { return terraform.NewTerraformInstaller().Install() }, + }, + { + Name: "AWS CLI", + Command: "aws", + IsInstalled: func() bool { return aws.NewAwsInstaller().IsInstalled() }, + InstallHelp: func() string { return aws.NewAwsInstaller().GetInstallHelp() }, + Install: func() error { return aws.NewAwsInstaller().Install() }, }, }, } @@ -62,7 +97,68 @@ func (pc *PrerequisiteChecker) CheckAll() (bool, []string) { return allPresent, missing } +// NewGKEPrerequisiteChecker returns the requirement set for GKE clusters: +// terraform (provisioning engine), the gcloud CLI, and gke-gcloud-auth-plugin +// (kubeconfig exec auth). GCP credentials are preflighted by the GKE provider +// itself, where the error can name the project in use. +func NewGKEPrerequisiteChecker() *PrerequisiteChecker { + return &PrerequisiteChecker{ + requirements: []Requirement{ + { + Name: "terraform", + Command: "terraform", + IsInstalled: func() bool { return terraform.NewTerraformInstaller().IsInstalled() }, + InstallHelp: func() string { return terraform.NewTerraformInstaller().GetInstallHelp() }, + Install: func() error { return terraform.NewTerraformInstaller().Install() }, + }, + { + Name: "gcloud", + Command: "gcloud", + IsInstalled: func() bool { return gcloud.NewGcloudInstaller().IsInstalled() }, + InstallHelp: func() string { return gcloud.NewGcloudInstaller().GetInstallHelp() }, + Install: func() error { return gcloud.NewGcloudInstaller().Install() }, + }, + { + Name: "gke-gcloud-auth-plugin", + Command: "gke-gcloud-auth-plugin", + IsInstalled: func() bool { return gcloud.NewAuthPluginInstaller().IsInstalled() }, + InstallHelp: func() string { return gcloud.NewAuthPluginInstaller().GetInstallHelp() }, + Install: func() error { return gcloud.NewAuthPluginInstaller().Install() }, + }, + }, + } +} + func CheckPrerequisites() error { // A CI environment or a non-terminal stdin must not hit an interactive prompt. return NewInstaller().CheckAndInstallNonInteractive(ui.IsNonInteractive()) } + +// checkerForClusterType returns the requirement set for a cluster type: +// Docker/k3d/helm for local k3d clusters, terraform + the cloud CLI for the +// cloud types. Unknown types return nil — they pass the gate and fail at the +// provider factory instead. Pure dispatch (no checks, no installs), so tests +// can verify the mapping without touching the machine. +func checkerForClusterType(clusterType models.ClusterType) *PrerequisiteChecker { + switch clusterType { + case models.ClusterTypeK3d, "": + return NewPrerequisiteChecker() + case models.ClusterTypeEKS: + return NewEKSPrerequisiteChecker() + case models.ClusterTypeGKE: + return NewGKEPrerequisiteChecker() + default: + return nil + } +} + +// CheckForClusterType runs the prerequisite gate for the given cluster type, +// installing missing tools (auto-approved when non-interactive). +func CheckForClusterType(clusterType models.ClusterType) error { + checker := checkerForClusterType(clusterType) + if checker == nil { + return nil + } + // A CI environment or a non-terminal stdin must not hit an interactive prompt. + return NewInstallerWithChecker(checker).CheckAndInstallNonInteractive(ui.IsNonInteractive()) +} diff --git a/internal/cluster/prerequisites/checker_test.go b/internal/cluster/prerequisites/checker_test.go index 527b8a41..9007706f 100644 --- a/internal/cluster/prerequisites/checker_test.go +++ b/internal/cluster/prerequisites/checker_test.go @@ -4,6 +4,7 @@ import ( "runtime" "testing" + "github.com/flamingo-stack/openframe-cli/internal/cluster/models" "github.com/flamingo-stack/openframe-cli/internal/cluster/prerequisites/docker" "github.com/flamingo-stack/openframe-cli/internal/cluster/prerequisites/helm" "github.com/flamingo-stack/openframe-cli/internal/cluster/prerequisites/k3d" @@ -82,6 +83,57 @@ func containsAny(str string, substrings []string) bool { return false } +// TestCheckerForClusterType verifies the type→requirements dispatch WITHOUT +// invoking CheckForClusterType: that function runs real installers in +// non-interactive mode, and an earlier version of this test did exactly that — +// it downloaded terraform onto CI runners and failed on `gcloud components +// install` (CI regression). Only the pure mapping is unit-testable. +func TestCheckerForClusterType(t *testing.T) { + names := func(c *PrerequisiteChecker) []string { + var out []string + for _, r := range c.requirements { + out = append(out, r.Name) + } + return out + } + + cases := []struct { + clusterType models.ClusterType + want []string + }{ + {models.ClusterTypeK3d, []string{"Docker", "k3d", "helm"}}, + {models.ClusterType(""), []string{"Docker", "k3d", "helm"}}, + {models.ClusterTypeEKS, []string{"terraform", "AWS CLI"}}, + {models.ClusterTypeGKE, []string{"terraform", "gcloud", "gke-gcloud-auth-plugin"}}, + } + for _, tc := range cases { + checker := checkerForClusterType(tc.clusterType) + if checker == nil { + t.Fatalf("checkerForClusterType(%q) = nil, want a requirement set", tc.clusterType) + } + got := names(checker) + if len(got) != len(tc.want) { + t.Fatalf("checkerForClusterType(%q) = %v, want %v", tc.clusterType, got, tc.want) + } + for i := range got { + if got[i] != tc.want[i] { + t.Errorf("checkerForClusterType(%q)[%d] = %s, want %s", tc.clusterType, i, got[i], tc.want[i]) + } + } + // Every requirement must be fully wired — a nil func would panic the + // installer flow at runtime. + for _, r := range checker.requirements { + if r.IsInstalled == nil || r.Install == nil || r.InstallHelp == nil { + t.Errorf("%s/%s: requirement funcs must all be set", tc.clusterType, r.Name) + } + } + } + + if checkerForClusterType("unknown") != nil { + t.Error("unknown types must return nil (gate passes, provider factory rejects)") + } +} + func TestCheckAllWithMissingTools(t *testing.T) { checker := NewPrerequisiteChecker() diff --git a/internal/cluster/prerequisites/docker/docker_test.go b/internal/cluster/prerequisites/docker/docker_test.go index 62cc3ef9..426bbcfb 100644 --- a/internal/cluster/prerequisites/docker/docker_test.go +++ b/internal/cluster/prerequisites/docker/docker_test.go @@ -37,48 +37,29 @@ func TestDockerInstaller_GetInstallHelp(t *testing.T) { } } +// TestDockerInstaller_Install only exercises the fail-fast error paths. It +// must NEVER call Install() where the real install could proceed: on CI +// runners with Homebrew this test used to run an actual +// `brew install --cask docker-desktop` (~100s, mutating the runner and +// failing on brew's own errors). func TestDockerInstaller_Install(t *testing.T) { - installer := NewDockerInstaller() - - // We can't actually test installation in CI, but we can test error handling - err := installer.Install() - - // On unsupported platforms, should return specific error - if runtime.GOOS != "darwin" && runtime.GOOS != "linux" && runtime.GOOS != "windows" { - expectedPrefix := "automatic Docker installation not supported on" - if err == nil || !containsSubstring(err.Error(), expectedPrefix) { - t.Errorf("Expected error containing '%s', got: %v", expectedPrefix, err) - } - return + if runtime.GOOS == "darwin" && commandExists("brew") { + t.Skip("would run a real 'brew install --cask docker-desktop'") } - - // On macOS without brew, should suggest installing brew - if runtime.GOOS == "darwin" && !commandExists("brew") { - if err == nil { - t.Error("Expected error when Homebrew is not installed") - } else { - expectedSubstring := "Homebrew is required" - if !containsSubstring(err.Error(), expectedSubstring) { - t.Errorf("Expected error containing '%s', got: %v", expectedSubstring, err) - } - } - return + if runtime.GOOS == "linux" { + t.Skip("would run a real package-manager install") } - - // On Linux without sudo or package managers, should fail - if runtime.GOOS == "linux" && !commandExists("sudo") { - if err != nil { - // This is expected, installation needs sudo - return - } + if runtime.GOOS == "windows" { + t.Skip("would attempt a real WSL setup") } - // On Windows, may attempt WSL setup (will likely fail in test environment) - // Just verify it doesn't panic and returns some result - if runtime.GOOS == "windows" { - // Windows installation will likely fail due to WSL not being set up in tests - // We just verify the function runs without panicking - _ = err + // Only the guaranteed-error path remains: darwin without Homebrew. + err := NewDockerInstaller().Install() + if err == nil { + t.Fatal("expected an error when no install tooling is available") + } + if !containsSubstring(err.Error(), "Homebrew is required") { + t.Errorf("expected a Homebrew hint, got: %v", err) } } diff --git a/internal/cluster/prerequisites/gcloud/gcloud.go b/internal/cluster/prerequisites/gcloud/gcloud.go new file mode 100644 index 00000000..fdc6db24 --- /dev/null +++ b/internal/cluster/prerequisites/gcloud/gcloud.go @@ -0,0 +1,86 @@ +// Package gcloud installs the Google Cloud CLI pieces the GKE provider needs: +// the gcloud CLI itself and the gke-gcloud-auth-plugin kubeconfig exec plugin. +package gcloud + +import ( + "context" + "fmt" + "os/exec" + "runtime" + "time" + + "github.com/flamingo-stack/openframe-cli/internal/platform" +) + +func commandExists(cmd string) bool { + _, err := exec.LookPath(cmd) + return err == nil +} + +func runQuiet(name string, args ...string) error { + cmd := exec.Command(name, args...) // #nosec G204 -- explicit argv, no shell; command and args are internal, not untrusted input + return cmd.Run() +} + +// GcloudInstaller manages the gcloud CLI. +type GcloudInstaller struct{} + +func NewGcloudInstaller() *GcloudInstaller { return &GcloudInstaller{} } + +func (g *GcloudInstaller) IsInstalled() bool { + if !commandExists("gcloud") { + return false + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + return exec.CommandContext(ctx, "gcloud", "--version").Run() == nil +} + +func (g *GcloudInstaller) GetInstallHelp() string { + return platform.InstallHint("gcloud") +} + +// Install installs the gcloud CLI. On macOS Homebrew carries the SDK; on +// Linux the official install requires a distribution-specific repo setup, so +// the user is pointed at the docs instead of a fragile automated attempt. +func (g *GcloudInstaller) Install() error { + switch runtime.GOOS { + case "darwin": + if !commandExists("brew") { + return fmt.Errorf("automatic gcloud installation on macOS requires Homebrew. Please install brew first: https://brew.sh") + } + if err := runQuiet("brew", "install", "--cask", "google-cloud-sdk"); err != nil { + return fmt.Errorf("failed to install the Google Cloud SDK: %w", err) + } + return nil + default: + return fmt.Errorf("automatic gcloud installation is not supported on %s. %s", runtime.GOOS, g.GetInstallHelp()) + } +} + +// AuthPluginInstaller manages gke-gcloud-auth-plugin, the exec plugin GKE +// kubeconfigs authenticate through. +type AuthPluginInstaller struct{} + +func NewAuthPluginInstaller() *AuthPluginInstaller { return &AuthPluginInstaller{} } + +func (a *AuthPluginInstaller) IsInstalled() bool { + return commandExists("gke-gcloud-auth-plugin") +} + +func (a *AuthPluginInstaller) GetInstallHelp() string { + return platform.InstallHint("gke-gcloud-auth-plugin") +} + +// Install installs the plugin through gcloud's component manager (requires +// gcloud itself, which precedes this requirement in the GKE set). +func (a *AuthPluginInstaller) Install() error { + if !commandExists("gcloud") { + return fmt.Errorf("gke-gcloud-auth-plugin is installed via gcloud, which is missing") + } + if err := runQuiet("gcloud", "components", "install", "gke-gcloud-auth-plugin", "--quiet"); err != nil { + return fmt.Errorf("failed to install gke-gcloud-auth-plugin (for package-manager gcloud installs, use the OS package instead — see %s): %w", + "https://cloud.google.com/kubernetes-engine/docs/how-to/cluster-access-for-kubectl", err) + } + return nil +} diff --git a/internal/cluster/prerequisites/installer.go b/internal/cluster/prerequisites/installer.go index 36a8dd29..7af43b86 100644 --- a/internal/cluster/prerequisites/installer.go +++ b/internal/cluster/prerequisites/installer.go @@ -6,8 +6,6 @@ import ( "strings" "github.com/flamingo-stack/openframe-cli/internal/cluster/prerequisites/docker" - "github.com/flamingo-stack/openframe-cli/internal/cluster/prerequisites/helm" - "github.com/flamingo-stack/openframe-cli/internal/cluster/prerequisites/k3d" "github.com/flamingo-stack/openframe-cli/internal/shared/errors" "github.com/flamingo-stack/openframe-cli/internal/shared/ui" "github.com/flamingo-stack/openframe-cli/internal/shared/ui/spinner" @@ -19,9 +17,24 @@ type Installer struct { } func NewInstaller() *Installer { - return &Installer{ - checker: NewPrerequisiteChecker(), + return NewInstallerWithChecker(NewPrerequisiteChecker()) +} + +// NewInstallerWithChecker builds an installer around a specific requirement +// set (k3d local vs EKS cloud); the install/verify flow is data-driven from +// the checker's requirements. +func NewInstallerWithChecker(checker *PrerequisiteChecker) *Installer { + return &Installer{checker: checker} +} + +// requirement returns the checker requirement matching name (case-insensitive). +func (i *Installer) requirement(name string) *Requirement { + for idx := range i.checker.requirements { + if strings.EqualFold(i.checker.requirements[idx].Name, name) { + return &i.checker.requirements[idx] + } } + return nil } func (i *Installer) installSpecificTools(tools []string) error { @@ -40,22 +53,19 @@ func (i *Installer) installSpecificTools(tools []string) error { sp.Success(fmt.Sprintf("%s installed successfully", tool)) } - // Verify only the installed tools are actually installed (don't check Docker running state) + // Verify the installed tools are actually usable. Docker is special-cased: + // its requirement's IsInstalled checks the daemon is RUNNING, which the + // start/wait phase handles separately — here only binary presence matters. var stillMissing []string for _, tool := range tools { - switch strings.ToLower(tool) { - case "docker": + if strings.EqualFold(tool, "docker") { if !docker.NewDockerInstaller().IsInstalled() { stillMissing = append(stillMissing, "Docker") } - case "k3d": - if !k3d.NewK3dInstaller().IsInstalled() { - stillMissing = append(stillMissing, "k3d") - } - case "helm": - if !helm.NewHelmInstaller().IsInstalled() { - stillMissing = append(stillMissing, "helm") - } + continue + } + if req := i.requirement(tool); req != nil && !req.IsInstalled() { + stillMissing = append(stillMissing, req.Name) } } @@ -79,19 +89,11 @@ func containsTool(tools []string, name string) bool { } func (i *Installer) installTool(tool string) error { - switch strings.ToLower(tool) { - case "docker": - installer := docker.NewDockerInstaller() - return installer.Install() - case "k3d": - installer := k3d.NewK3dInstaller() - return installer.Install() - case "helm": - installer := helm.NewHelmInstaller() - return installer.Install() - default: + req := i.requirement(tool) + if req == nil || req.Install == nil { return fmt.Errorf("unknown tool: %s", tool) } + return req.Install() } // CheckAndInstallNonInteractive checks and installs prerequisites with optional non-interactive mode @@ -219,11 +221,10 @@ func (i *Installer) showManualInstructions() { fmt.Println() pterm.Info.Println("Installation skipped. Here are manual installation instructions:") - // Get instructions for all prerequisites - allInstructions := []string{ - docker.NewDockerInstaller().GetInstallHelp(), - k3d.NewK3dInstaller().GetInstallHelp(), - helm.NewHelmInstaller().GetInstallHelp(), + // Get instructions for this checker's prerequisites + var allInstructions []string + for _, req := range i.checker.requirements { + allInstructions = append(allInstructions, req.InstallHelp()) } tableData := pterm.TableData{{"Tool", "Installation Instructions"}} diff --git a/internal/cluster/prerequisites/k3d/k3d_test.go b/internal/cluster/prerequisites/k3d/k3d_test.go index c4bdfee0..e63c9ed7 100644 --- a/internal/cluster/prerequisites/k3d/k3d_test.go +++ b/internal/cluster/prerequisites/k3d/k3d_test.go @@ -39,37 +39,29 @@ func TestK3dInstaller_GetInstallHelp(t *testing.T) { } } +// TestK3dInstaller_Install only exercises the fail-fast error paths. It must +// NEVER call Install() where the real install could proceed: on CI runners +// this test used to run a real `brew install k3d` (darwin) or download the +// pinned binary into the runner's ~/.openframe/bin (linux). func TestK3dInstaller_Install(t *testing.T) { - installer := NewK3dInstaller() - - // We can't actually test installation in CI, but we can test error handling - err := installer.Install() - - // On unsupported platforms, should return specific error - if runtime.GOOS != "darwin" && runtime.GOOS != "linux" && runtime.GOOS != "windows" { - expectedPrefix := "automatic k3d installation not supported on" - if err == nil || !containsSubstring(err.Error(), expectedPrefix) { - t.Errorf("Expected error containing '%s', got: %v", expectedPrefix, err) - } - return + if runtime.GOOS == "darwin" && commandExists("brew") { + t.Skip("would run a real 'brew install k3d'") } - - // On macOS without brew, should suggest installing brew - if runtime.GOOS == "darwin" && !commandExists("brew") { - if err == nil { - t.Error("Expected error when Homebrew is not installed") - } else { - expectedSubstring := "Homebrew is required" - if !containsSubstring(err.Error(), expectedSubstring) { - t.Errorf("Expected error containing '%s', got: %v", expectedSubstring, err) - } - } - return + if runtime.GOOS == "linux" { + t.Skip("would run a real package-manager install or verified download") + } + if runtime.GOOS == "windows" { + t.Skip("k3d installs via WSL on windows") } - // On Linux and Windows, the installation will likely fail in test environments - // We just verify the function runs without panicking - _ = err + // Only the guaranteed-error path remains: darwin without Homebrew. + err := NewK3dInstaller().Install() + if err == nil { + t.Fatal("expected an error when no install tooling is available") + } + if !containsSubstring(err.Error(), "Homebrew") { + t.Errorf("expected a Homebrew hint, got: %v", err) + } } func TestCommandExists(t *testing.T) { diff --git a/internal/cluster/prerequisites/terraform/terraform.go b/internal/cluster/prerequisites/terraform/terraform.go new file mode 100644 index 00000000..9a2b528b --- /dev/null +++ b/internal/cluster/prerequisites/terraform/terraform.go @@ -0,0 +1,118 @@ +package terraform + +import ( + "context" + "encoding/json" + "fmt" + "os/exec" + "runtime" + "strconv" + "strings" + "time" + + "github.com/flamingo-stack/openframe-cli/internal/platform" + "github.com/flamingo-stack/openframe-cli/internal/shared/download" + "github.com/pterm/pterm" +) + +// TerraformInstaller installs the Terraform CLI used by the cloud cluster +// providers. Unlike docker/k3d/helm it always installs the pinned, verified +// binary into ~/.openframe/bin — package managers no longer carry current +// Terraform (the homebrew-core formula is disabled since the BUSL change), so +// the verified download is the primary path, not the fallback. +type TerraformInstaller struct{} + +func NewTerraformInstaller() *TerraformInstaller { + return &TerraformInstaller{} +} + +func commandExists(cmd string) bool { + _, err := exec.LookPath(cmd) + return err == nil +} + +// Minimum terraform version the generated root modules require +// (required_version = ">= 1.15.0" in the templates). An older system +// terraform must count as NOT installed, so the pinned binary gets installed +// into ~/.openframe/bin — which then wins PATH resolution. +const minMajor, minMinor = 1, 15 + +// IsInstalled reports whether a terraform binary that satisfies the +// templates' version constraint is reachable. The CLI-managed bin dir is +// prepended to PATH so a previously installed pinned binary is found even in +// a fresh shell. +func (t *TerraformInstaller) IsInstalled() bool { + if binDir, err := download.UserBinDir(); err == nil { + download.PrependToPath(binDir) + } + if !commandExists("terraform") { + return false + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + out, err := exec.CommandContext(ctx, "terraform", "version", "-json").Output() + if err != nil { + return false + } + var v struct { + TerraformVersion string `json:"terraform_version"` + } + if err := json.Unmarshal(out, &v); err != nil { + return false + } + return versionSatisfies(v.TerraformVersion, minMajor, minMinor) +} + +// versionSatisfies reports whether version ("1.15.8") is >= major.minor. +// Unparseable versions are treated as too old — the safe direction, since it +// triggers a pinned install rather than a mid-provisioning failure. +func versionSatisfies(version string, major, minor int) bool { + parts := strings.SplitN(strings.TrimPrefix(version, "v"), ".", 3) + if len(parts) < 2 { + return false + } + gotMajor, err1 := strconv.Atoi(parts[0]) + gotMinor, err2 := strconv.Atoi(parts[1]) + if err1 != nil || err2 != nil { + return false + } + return gotMajor > major || (gotMajor == major && gotMinor >= minor) +} + +func (t *TerraformInstaller) GetInstallHelp() string { + return platform.InstallHint("terraform") +} + +// Install downloads the pinned Terraform release, verifies its SHA256, and +// installs it into ~/.openframe/bin (no sudo). Unlike docker/k3d (which run +// inside WSL on Windows), terraform is installed natively on all three +// platforms — the provisioning engine invokes it directly. +func (t *TerraformInstaller) Install() error { + switch runtime.GOOS { + case "darwin", "linux", "windows": + return t.installVerified() + default: + return fmt.Errorf("automatic terraform installation not supported on %s", runtime.GOOS) + } +} + +func (t *TerraformInstaller) installVerified() error { + binDir, err := download.UserBinDir() + if err != nil { + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + fmt.Printf("Downloading verified terraform %s...\n", download.Terraform.Version) + path, err := (download.Downloader{}).InstallPinnedTool(ctx, download.Terraform, binDir) + if err != nil { + return fmt.Errorf("verified terraform install failed: %w", err) + } + + download.PrependToPath(binDir) + pterm.Success.Printf("Installed verified terraform %s to %s\n", download.Terraform.Version, path) + pterm.Info.Printf("To use terraform directly in your shell, add %s to PATH: export PATH=\"%s:$PATH\"\n", binDir, binDir) + return nil +} diff --git a/internal/cluster/prerequisites/terraform/terraform_test.go b/internal/cluster/prerequisites/terraform/terraform_test.go new file mode 100644 index 00000000..072cd4ed --- /dev/null +++ b/internal/cluster/prerequisites/terraform/terraform_test.go @@ -0,0 +1,31 @@ +package terraform + +import "testing" + +// TestVersionSatisfies locks the terraform version gate: the generated root +// modules require >= 1.15, so an older system terraform must count as NOT +// installed (found by a real tfvalidate run against terraform 1.13.3, which +// passed the old binary-presence check and then failed on required_version). +func TestVersionSatisfies(t *testing.T) { + cases := []struct { + version string + want bool + }{ + {"1.15.0", true}, + {"1.15.8", true}, + {"1.16.0", true}, + {"2.0.0", true}, + {"v1.15.8", true}, + {"1.13.3", false}, + {"1.14.9", false}, + {"0.15.0", false}, + {"", false}, + {"nonsense", false}, + {"1", false}, + } + for _, tc := range cases { + if got := versionSatisfies(tc.version, minMajor, minMinor); got != tc.want { + t.Errorf("versionSatisfies(%q) = %v, want %v", tc.version, got, tc.want) + } + } +} diff --git a/internal/cluster/provider/factory.go b/internal/cluster/provider/factory.go new file mode 100644 index 00000000..900c5d9c --- /dev/null +++ b/internal/cluster/provider/factory.go @@ -0,0 +1,28 @@ +package provider + +import ( + "github.com/flamingo-stack/openframe-cli/internal/cluster/models" + "github.com/flamingo-stack/openframe-cli/internal/cluster/providers/eks" + "github.com/flamingo-stack/openframe-cli/internal/cluster/providers/gke" + "github.com/flamingo-stack/openframe-cli/internal/cluster/providers/k3d" + "github.com/flamingo-stack/openframe-cli/internal/shared/executor" + "github.com/pterm/pterm" +) + +// New returns the Provider for the given cluster type. An empty type defaults +// to k3d (the local development default); an unrecognized type is a +// configuration error. +func New(clusterType models.ClusterType, exec executor.CommandExecutor) (Provider, error) { + switch clusterType { + case models.ClusterTypeK3d, "": + return k3d.CreateClusterManagerWithExecutor(exec), nil + case models.ClusterTypeEKS: + // pterm's debug switch is the CLI-wide --verbose signal; it makes the + // terraform engine stream terraform's own output during long applies. + return eks.New(exec, pterm.PrintDebugMessages) + case models.ClusterTypeGKE: + return gke.New(exec, pterm.PrintDebugMessages) + default: + return nil, models.NewInvalidConfigError("type", clusterType, "unknown cluster type") + } +} diff --git a/internal/cluster/provider/factory_test.go b/internal/cluster/provider/factory_test.go new file mode 100644 index 00000000..0f002a74 --- /dev/null +++ b/internal/cluster/provider/factory_test.go @@ -0,0 +1,42 @@ +package provider + +import ( + "errors" + "testing" + + "github.com/flamingo-stack/openframe-cli/internal/cluster/models" + "github.com/flamingo-stack/openframe-cli/internal/shared/executor" + "github.com/stretchr/testify/assert" +) + +func TestNew(t *testing.T) { + exec := executor.NewMockCommandExecutor() + + t.Run("k3d returns a provider", func(t *testing.T) { + p, err := New(models.ClusterTypeK3d, exec) + assert.NoError(t, err) + assert.NotNil(t, p) + }) + + t.Run("empty type defaults to k3d", func(t *testing.T) { + p, err := New("", exec) + assert.NoError(t, err) + assert.NotNil(t, p) + }) + + t.Run("cloud types return providers", func(t *testing.T) { + t.Setenv("OPENFRAME_CLUSTERS_DIR", t.TempDir()) + for _, clusterType := range []models.ClusterType{models.ClusterTypeEKS, models.ClusterTypeGKE} { + p, err := New(clusterType, exec) + assert.NoError(t, err, "type %s", clusterType) + assert.NotNil(t, p, "type %s", clusterType) + } + }) + + t.Run("unknown type is a config error", func(t *testing.T) { + p, err := New("minikube", exec) + assert.Nil(t, p) + var invalid models.ErrInvalidClusterConfig + assert.True(t, errors.As(err, &invalid), "expected ErrInvalidClusterConfig, got %v", err) + }) +} diff --git a/internal/cluster/provider/provider.go b/internal/cluster/provider/provider.go index 6b5887e5..3db1e9da 100644 --- a/internal/cluster/provider/provider.go +++ b/internal/cluster/provider/provider.go @@ -1,16 +1,20 @@ // Package provider defines the unified cluster-provider abstraction. // // A Provider creates and manages Kubernetes clusters. Today only k3d (local) is -// implemented; cloud providers (GKE, EKS) are placeholders that return a -// friendly "coming soon" error. New backends implement the same Provider -// interface, so the rest of the CLI never needs to know which backend is used. +// implemented; for the recognized cloud types (GKE, EKS) the factory returns +// ErrProviderNotFound until their backends land. New backends implement the +// same Provider interface, so the rest of the CLI never needs to know which +// backend is used. package provider import ( "context" "github.com/flamingo-stack/openframe-cli/internal/cluster/models" + "github.com/flamingo-stack/openframe-cli/internal/cluster/providers/eks" + "github.com/flamingo-stack/openframe-cli/internal/cluster/providers/gke" "github.com/flamingo-stack/openframe-cli/internal/cluster/providers/k3d" + "github.com/flamingo-stack/openframe-cli/internal/cluster/providers/terraform" "k8s.io/client-go/rest" ) @@ -38,12 +42,23 @@ type Provider interface { GetKubeconfig(ctx context.Context, name string, clusterType models.ClusterType) (string, error) } -// Compile-time assertion that the k3d manager satisfies Provider. +// Planner is the optional preview capability of cloud providers: a real +// terraform plan of what CreateCluster would do, without registering the +// cluster or touching state. k3d has no meaningful plan, so this is a +// separate interface rather than a tenth Provider method. +type Planner interface { + PlanCluster(ctx context.Context, config models.ClusterConfig) (terraform.PlanSummary, error) +} + +// Compile-time assertions that the backends satisfy Provider. // -// NOTE: there is deliberately NO factory here. The old New(clusterType, -// target, ...) "single seam" was never called from production — every -// constructor hard-coded the k3d manager, so the factory was decorative -// (audit B7). The interface itself is the real seam: it is what -// ClusterService depends on and what tests mock. When a second backend -// (GKE/EKS) actually lands, reintroduce a factory alongside its first caller. -var _ Provider = (*k3d.K3dManager)(nil) +// Backends are selected through New (factory.go). The old decorative factory +// was removed in audit B7 because nothing called it; this one is real — +// ClusterService resolves its backend through it, keyed on ClusterConfig.Type. +var ( + _ Provider = (*k3d.K3dManager)(nil) + _ Provider = (*eks.Provider)(nil) + _ Provider = (*gke.Provider)(nil) + _ Planner = (*eks.Provider)(nil) + _ Planner = (*gke.Provider)(nil) +) diff --git a/internal/cluster/providers/eks/kubeconfig.go b/internal/cluster/providers/eks/kubeconfig.go new file mode 100644 index 00000000..321bd81d --- /dev/null +++ b/internal/cluster/providers/eks/kubeconfig.go @@ -0,0 +1,115 @@ +package eks + +import ( + "encoding/base64" + "fmt" + + tfengine "github.com/flamingo-stack/openframe-cli/internal/cluster/providers/terraform" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" + clientcmdapi "k8s.io/client-go/tools/clientcmd/api" +) + +// EKS kubeconfig entries carry no static credentials: authentication runs +// through the client-go exec plugin (`aws eks get-token`), so tokens are +// short-lived and minted from the operator's AWS identity on every call. + +// execArgs builds the aws exec-plugin argv for a cluster record. +func execArgs(rec tfengine.Record) []string { + args := []string{"eks", "get-token", "--cluster-name", rec.Name, "--region", rec.Region, "--output", "json"} + if rec.Profile != "" { + args = append(args, "--profile", rec.Profile) + } + return args +} + +func execConfig(rec tfengine.Record) *clientcmdapi.ExecConfig { + return &clientcmdapi.ExecConfig{ + APIVersion: "client.authentication.k8s.io/v1beta1", + Command: "aws", + Args: execArgs(rec), + InteractiveMode: clientcmdapi.NeverExecInteractiveMode, + } +} + +// caData decodes the base64 CA bundle the EKS module outputs. +func caData(rec tfengine.Record) ([]byte, error) { + ca, err := base64.StdEncoding.DecodeString(rec.CACert) + if err != nil { + return nil, fmt.Errorf("decoding cluster CA for %s: %w", rec.Name, err) + } + return ca, nil +} + +// kubeconfigFor renders an in-memory kubeconfig with a single context named +// after the cluster — the plain name (not an ARN) so the rest of the CLI can +// resolve it by exact match. +func kubeconfigFor(rec tfengine.Record) (*clientcmdapi.Config, error) { + ca, err := caData(rec) + if err != nil { + return nil, err + } + cfg := clientcmdapi.NewConfig() + cfg.Clusters[rec.Name] = &clientcmdapi.Cluster{ + Server: rec.Endpoint, + CertificateAuthorityData: ca, + } + cfg.AuthInfos[rec.Name] = &clientcmdapi.AuthInfo{Exec: execConfig(rec)} + cfg.Contexts[rec.Name] = &clientcmdapi.Context{Cluster: rec.Name, AuthInfo: rec.Name} + cfg.CurrentContext = rec.Name + return cfg, nil +} + +// restConfigFor builds a rest.Config straight from the record — no kubeconfig +// file round-trip needed. +func restConfigFor(rec tfengine.Record) (*rest.Config, error) { + ca, err := caData(rec) + if err != nil { + return nil, err + } + return &rest.Config{ + Host: rec.Endpoint, + TLSClientConfig: rest.TLSClientConfig{CAData: ca}, + ExecProvider: execConfig(rec), + }, nil +} + +// mergeIntoDefaultKubeconfig writes the cluster's context into the user's +// kubeconfig (honoring $KUBECONFIG) and switches the current context to it — +// the same post-create behavior the k3d provider gets from k3d itself. +func mergeIntoDefaultKubeconfig(rec tfengine.Record) error { + pathOpts := clientcmd.NewDefaultPathOptions() + existing, err := pathOpts.GetStartingConfig() + if err != nil { + return fmt.Errorf("loading kubeconfig: %w", err) + } + generated, err := kubeconfigFor(rec) + if err != nil { + return err + } + existing.Clusters[rec.Name] = generated.Clusters[rec.Name] + existing.AuthInfos[rec.Name] = generated.AuthInfos[rec.Name] + existing.Contexts[rec.Name] = generated.Contexts[rec.Name] + existing.CurrentContext = rec.Name + if err := clientcmd.ModifyConfig(pathOpts, *existing, true); err != nil { + return fmt.Errorf("writing kubeconfig: %w", err) + } + return nil +} + +// removeFromDefaultKubeconfig drops the cluster's context after a destroy. +// Best-effort: a missing entry is not an error. +func removeFromDefaultKubeconfig(name string) error { + pathOpts := clientcmd.NewDefaultPathOptions() + existing, err := pathOpts.GetStartingConfig() + if err != nil { + return err + } + delete(existing.Clusters, name) + delete(existing.AuthInfos, name) + delete(existing.Contexts, name) + if existing.CurrentContext == name { + existing.CurrentContext = "" + } + return clientcmd.ModifyConfig(pathOpts, *existing, true) +} diff --git a/internal/cluster/providers/eks/provider.go b/internal/cluster/providers/eks/provider.go new file mode 100644 index 00000000..938e2869 --- /dev/null +++ b/internal/cluster/providers/eks/provider.go @@ -0,0 +1,312 @@ +// Package eks implements the cluster Provider for AWS EKS. Provisioning runs +// through the shared terraform engine: the provider generates a root module +// (public terraform-aws-modules/eks + vpc modules, pinned) into the cluster's +// workspace and drives init/apply/destroy there. See the package comment in +// internal/cluster/providers/terraform for the workspace layout. +package eks + +import ( + "context" + "fmt" + "os" + "strings" + "time" + + "github.com/flamingo-stack/openframe-cli/internal/cluster/models" + tfengine "github.com/flamingo-stack/openframe-cli/internal/cluster/providers/terraform" + "github.com/flamingo-stack/openframe-cli/internal/shared/executor" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" +) + +// Provider provisions and manages EKS clusters. +type Provider struct { + engine *tfengine.Engine + registry *tfengine.Registry + executor executor.CommandExecutor +} + +// New builds the production provider. The registry defaults to +// ~/.openframe/clusters. +func New(exec executor.CommandExecutor, verbose bool) (*Provider, error) { + registry, err := tfengine.DefaultRegistry() + if err != nil { + return nil, err + } + return &Provider{ + engine: tfengine.NewEngine(verbose), + registry: registry, + executor: exec, + }, nil +} + +// NewWithDeps is the test constructor. +func NewWithDeps(engine *tfengine.Engine, registry *tfengine.Registry, exec executor.CommandExecutor) *Provider { + return &Provider{engine: engine, registry: registry, executor: exec} +} + +// preflightCredentials fails fast with an actionable message when the AWS +// identity is unusable — before any terraform runs. +func (p *Provider) preflightCredentials(ctx context.Context, profile string) error { + args := []string{"sts", "get-caller-identity", "--output", "json"} + if profile != "" { + args = append(args, "--profile", profile) + } + if _, err := p.executor.Execute(ctx, "aws", args...); err != nil { + which := "default credentials" + if profile != "" { + which = fmt.Sprintf("profile '%s'", profile) + } + return fmt.Errorf("AWS %s cannot authenticate (aws sts get-caller-identity failed): %w", which, err) + } + return nil +} + +// backendTF renders the s3 backend block for an EKS workspace. +func backendTF(cfg tfengine.BackendConfig, region string) []byte { + key := "terraform.tfstate" + if cfg.Prefix != "" { + key = cfg.Prefix + "/terraform.tfstate" + } + return []byte(fmt.Sprintf( + "terraform {\n backend \"s3\" {\n bucket = %q\n key = %q\n region = %q\n }\n}\n", + cfg.Bucket, key, region)) +} + +// parseBackend validates the optional --backend-config value for EKS. +func parseBackend(config models.ClusterConfig) (*tfengine.BackendConfig, error) { + if config.Cloud.BackendConfig == "" { + return nil, nil + } + cfg, err := tfengine.ParseBackendURL(config.Cloud.BackendConfig) + if err != nil { + return nil, models.NewInvalidConfigError("backend-config", config.Cloud.BackendConfig, err.Error()) + } + if cfg.Scheme != "s3" { + return nil, models.NewInvalidConfigError("backend-config", config.Cloud.BackendConfig, "EKS remote state must be s3://bucket/prefix") + } + return &cfg, nil +} + +// PlanCluster previews what CreateCluster would do — a real terraform plan — +// without registering the cluster or touching any state. A brand-new cluster +// is planned in a throwaway directory; an existing (failed/interrupted) +// workspace is planned in place to show what a resume would change. +func (p *Provider) PlanCluster(ctx context.Context, config models.ClusterConfig) (tfengine.PlanSummary, error) { + if err := validate(config); err != nil { + return tfengine.PlanSummary{}, err + } + if err := p.preflightCredentials(ctx, config.Cloud.Profile); err != nil { + return tfengine.PlanSummary{}, err + } + + dir := p.registry.Workspace(config.Name).TerraformDir() + if !p.registry.Workspace(config.Name).Exists() { + vars, err := tfvarsFor(config) + if err != nil { + return tfengine.PlanSummary{}, err + } + tmp, err := os.MkdirTemp("", "openframe-plan-*") + if err != nil { + return tfengine.PlanSummary{}, err + } + defer func() { _ = os.RemoveAll(tmp) }() + if err := tfengine.WriteModule(tmp, mainTF, vars); err != nil { + return tfengine.PlanSummary{}, err + } + dir = tmp + } + + if err := p.engine.Init(ctx, dir); err != nil { + return tfengine.PlanSummary{}, err + } + return p.engine.Plan(ctx, dir) +} + +// CreateCluster provisions the cluster and returns a rest.Config for it. +// Re-running after a failed apply resumes the same workspace: terraform apply +// is idempotent over the recorded state. +func (p *Provider) CreateCluster(ctx context.Context, config models.ClusterConfig) (*rest.Config, error) { + if err := validate(config); err != nil { + return nil, err + } + backend, err := parseBackend(config) + if err != nil { + return nil, err + } + if err := p.preflightCredentials(ctx, config.Cloud.Profile); err != nil { + return nil, err + } + + ws := p.registry.Workspace(config.Name) + if !ws.Exists() { + vars, err := tfvarsFor(config) + if err != nil { + return nil, err + } + record := tfengine.Record{ + Name: config.Name, + Type: models.ClusterTypeEKS, + Status: tfengine.StatusCreating, + Region: config.Cloud.Region, + Profile: config.Cloud.Profile, + K8sVersion: vars.KubernetesVersion, + NodeCount: config.NodeCount, + CreatedAt: time.Now().UTC(), + } + if err := ws.Scaffold(record, mainTF, vars); err != nil { + return nil, err + } + if backend != nil { + if err := ws.WriteBackend(backendTF(*backend, config.Cloud.Region)); err != nil { + return nil, err + } + } + } + // An existing workspace means a previous create failed or was interrupted; + // keep its tfvars (the state may reference them) and simply resume. + + if err := p.engine.Init(ctx, ws.TerraformDir()); err != nil { + _ = ws.SetStatus(tfengine.StatusFailed) + return nil, models.NewClusterOperationError("create", config.Name, err) + } + if err := p.engine.Apply(ctx, ws.TerraformDir()); err != nil { + _ = ws.SetStatus(tfengine.StatusFailed) + return nil, models.NewClusterOperationError("create", config.Name, + fmt.Errorf("%w\nThe terraform state is kept in %s; re-run create to resume or 'openframe cluster delete %s' to tear down", err, ws.Dir(), config.Name)) + } + + outputs, err := p.engine.Outputs(ctx, ws.TerraformDir()) + if err != nil { + _ = ws.SetStatus(tfengine.StatusFailed) + return nil, models.NewClusterOperationError("create", config.Name, err) + } + record, err := ws.ReadRecord() + if err != nil { + return nil, err + } + if record.Endpoint, err = tfengine.StringOutput(outputs, "cluster_endpoint"); err != nil { + return nil, models.NewClusterOperationError("create", config.Name, err) + } + if record.CACert, err = tfengine.StringOutput(outputs, "cluster_certificate_authority_data"); err != nil { + return nil, models.NewClusterOperationError("create", config.Name, err) + } + record.Status = tfengine.StatusReady + if err := ws.WriteRecord(record); err != nil { + return nil, err + } + + if err := mergeIntoDefaultKubeconfig(record); err != nil { + return nil, models.NewClusterOperationError("create", config.Name, err) + } + return restConfigFor(record) +} + +// DeleteCluster destroys the cluster's cloud resources, then removes the +// workspace and the kubeconfig context. The workspace survives a failed +// destroy — its state is the only pointer to still-billed resources. +func (p *Provider) DeleteCluster(ctx context.Context, name string, clusterType models.ClusterType, force bool) error { + if clusterType != models.ClusterTypeEKS { + return models.NewProviderNotFoundError(clusterType) + } + ws := p.registry.Workspace(name) + if !ws.Exists() { + return models.NewClusterNotFoundError(name) + } + if err := p.engine.Destroy(ctx, ws.TerraformDir()); err != nil { + return models.NewClusterOperationError("delete", name, + fmt.Errorf("%w\nThe terraform state is kept in %s; re-run delete to retry", err, ws.Dir())) + } + _ = removeFromDefaultKubeconfig(name) + return ws.Remove() +} + +// StartCluster is meaningless for a managed control plane. +func (p *Provider) StartCluster(ctx context.Context, name string, clusterType models.ClusterType) error { + return fmt.Errorf("starting is not supported for EKS clusters: the managed control plane is always running") +} + +// ListClusters returns the EKS clusters recorded in the local registry. +func (p *Provider) ListClusters(ctx context.Context) ([]models.ClusterInfo, error) { + records, err := p.registry.List() + if err != nil { + return nil, err + } + infos := make([]models.ClusterInfo, 0, len(records)) + for _, rec := range records { + if rec.Type != models.ClusterTypeEKS { + continue + } + infos = append(infos, infoFor(rec)) + } + return infos, nil +} + +// ListAllClusters is the same as ListClusters: the registry is this +// provider's full visibility. +func (p *Provider) ListAllClusters(ctx context.Context) ([]models.ClusterInfo, error) { + return p.ListClusters(ctx) +} + +// GetClusterStatus returns the recorded status for a single cluster. +func (p *Provider) GetClusterStatus(ctx context.Context, name string) (models.ClusterInfo, error) { + rec, err := p.registry.Get(name) + if err != nil || rec.Type != models.ClusterTypeEKS { + return models.ClusterInfo{}, models.NewClusterNotFoundError(name) + } + return infoFor(rec), nil +} + +// DetectClusterType reports eks for registry-recorded clusters. +func (p *Provider) DetectClusterType(ctx context.Context, name string) (models.ClusterType, error) { + rec, err := p.registry.Get(name) + if err != nil || rec.Type != models.ClusterTypeEKS { + return "", models.NewClusterNotFoundError(name) + } + return models.ClusterTypeEKS, nil +} + +// GetRestConfig builds a rest.Config from the recorded endpoint/CA — no +// terraform run needed. +func (p *Provider) GetRestConfig(ctx context.Context, name string) (*rest.Config, error) { + rec, err := p.registry.Get(name) + if err != nil { + return nil, err + } + if rec.Status != tfengine.StatusReady { + return nil, fmt.Errorf("cluster '%s' is not ready (status: %s)", name, rec.Status) + } + return restConfigFor(rec) +} + +// GetKubeconfig renders the cluster's kubeconfig as YAML. +func (p *Provider) GetKubeconfig(ctx context.Context, name string, clusterType models.ClusterType) (string, error) { + if clusterType != models.ClusterTypeEKS { + return "", models.NewProviderNotFoundError(clusterType) + } + rec, err := p.registry.Get(name) + if err != nil { + return "", err + } + cfg, err := kubeconfigFor(rec) + if err != nil { + return "", err + } + data, err := clientcmd.Write(*cfg) + if err != nil { + return "", err + } + return string(data), nil +} + +// infoFor maps a registry record onto the shared ClusterInfo shape. +func infoFor(rec tfengine.Record) models.ClusterInfo { + return models.ClusterInfo{ + Name: rec.Name, + Type: models.ClusterTypeEKS, + Status: strings.ToTitle(string(rec.Status[0:1])) + string(rec.Status[1:]), + NodeCount: rec.NodeCount, + K8sVersion: rec.K8sVersion, + CreatedAt: rec.CreatedAt, + } +} diff --git a/internal/cluster/providers/eks/provider_test.go b/internal/cluster/providers/eks/provider_test.go new file mode 100644 index 00000000..420d46e9 --- /dev/null +++ b/internal/cluster/providers/eks/provider_test.go @@ -0,0 +1,293 @@ +package eks + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "io" + "os" + "path/filepath" + "testing" + + "github.com/flamingo-stack/openframe-cli/internal/cluster/models" + tfengine "github.com/flamingo-stack/openframe-cli/internal/cluster/providers/terraform" + "github.com/flamingo-stack/openframe-cli/internal/shared/executor" + "github.com/hashicorp/terraform-exec/tfexec" + tfjson "github.com/hashicorp/terraform-json" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var testCA = base64.StdEncoding.EncodeToString([]byte("fake-ca-pem")) + +// fakeRunner is a canned tfexec stand-in. +type fakeRunner struct { + calls *[]string + applyErr error +} + +func (f *fakeRunner) Init(ctx context.Context, opts ...tfexec.InitOption) error { + *f.calls = append(*f.calls, "init") + return nil +} + +func (f *fakeRunner) Apply(ctx context.Context, opts ...tfexec.ApplyOption) error { + *f.calls = append(*f.calls, "apply") + return f.applyErr +} + +func (f *fakeRunner) ApplyJSON(ctx context.Context, w io.Writer, opts ...tfexec.ApplyOption) error { + return f.Apply(ctx) +} + +func (f *fakeRunner) Destroy(ctx context.Context, opts ...tfexec.DestroyOption) error { + *f.calls = append(*f.calls, "destroy") + return nil +} + +func (f *fakeRunner) DestroyJSON(ctx context.Context, w io.Writer, opts ...tfexec.DestroyOption) error { + return f.Destroy(ctx) +} + +func (f *fakeRunner) Plan(ctx context.Context, opts ...tfexec.PlanOption) (bool, error) { + *f.calls = append(*f.calls, "plan") + return true, nil +} + +func (f *fakeRunner) ShowPlanFile(ctx context.Context, planPath string, opts ...tfexec.ShowOption) (*tfjson.Plan, error) { + *f.calls = append(*f.calls, "show") + return &tfjson.Plan{ResourceChanges: []*tfjson.ResourceChange{ + {Change: &tfjson.Change{Actions: tfjson.Actions{tfjson.ActionCreate}}}, + }}, nil +} + +func (f *fakeRunner) Output(ctx context.Context, opts ...tfexec.OutputOption) (map[string]tfexec.OutputMeta, error) { + *f.calls = append(*f.calls, "output") + return map[string]tfexec.OutputMeta{ + "cluster_name": {Value: json.RawMessage(`"demo"`)}, + "cluster_endpoint": {Value: json.RawMessage(`"https://demo.eks.example"`)}, + "cluster_certificate_authority_data": {Value: json.RawMessage(`"` + testCA + `"`)}, + "region": {Value: json.RawMessage(`"us-east-1"`)}, + }, nil +} + +// newTestProvider wires the provider onto a temp registry, a fake runner, and +// an isolated kubeconfig. +func newTestProvider(t *testing.T, applyErr error) (*Provider, *[]string, *tfengine.Registry) { + t.Helper() + t.Setenv("KUBECONFIG", filepath.Join(t.TempDir(), "kubeconfig")) + + calls := &[]string{} + engine := tfengine.NewEngineWithRunner(func(workdir string) (tfengine.Runner, error) { + return &fakeRunner{calls: calls, applyErr: applyErr}, nil + }) + registry := tfengine.NewRegistry(t.TempDir()) + mock := executor.NewMockCommandExecutor() // aws sts get-caller-identity succeeds by default + return NewWithDeps(engine, registry, mock), calls, registry +} + +func eksConfig(name string) models.ClusterConfig { + return models.ClusterConfig{ + Name: name, + Type: models.ClusterTypeEKS, + NodeCount: 3, + Cloud: &models.CloudConfig{Region: "us-east-1", MachineType: "m6i.large"}, + } +} + +func TestCreateCluster_HappyPath(t *testing.T) { + p, calls, registry := newTestProvider(t, nil) + + restConfig, err := p.CreateCluster(context.Background(), eksConfig("demo")) + require.NoError(t, err) + assert.Equal(t, []string{"init", "apply", "output"}, *calls) + assert.Equal(t, "https://demo.eks.example", restConfig.Host) + assert.Equal(t, []byte("fake-ca-pem"), restConfig.CAData) + require.NotNil(t, restConfig.ExecProvider) + assert.Equal(t, "aws", restConfig.ExecProvider.Command) + assert.Contains(t, restConfig.ExecProvider.Args, "get-token") + + rec, err := registry.Get("demo") + require.NoError(t, err) + assert.Equal(t, tfengine.StatusReady, rec.Status) + assert.Equal(t, "https://demo.eks.example", rec.Endpoint) + + // The kubeconfig context is the plain cluster name. + kubeconfig, err := os.ReadFile(os.Getenv("KUBECONFIG")) + require.NoError(t, err) + assert.Contains(t, string(kubeconfig), "current-context: demo") +} + +func TestCreateCluster_FailedApplyKeepsWorkspace(t *testing.T) { + p, _, registry := newTestProvider(t, errors.New("quota exceeded")) + + _, err := p.CreateCluster(context.Background(), eksConfig("demo")) + require.Error(t, err) + assert.Contains(t, err.Error(), "re-run create to resume") + + rec, err := registry.Get("demo") + require.NoError(t, err) + assert.Equal(t, tfengine.StatusFailed, rec.Status) +} + +func TestCreateCluster_RequiresRegion(t *testing.T) { + p, calls, _ := newTestProvider(t, nil) + + config := eksConfig("demo") + config.Cloud = nil + _, err := p.CreateCluster(context.Background(), config) + + var invalid models.ErrInvalidClusterConfig + require.ErrorAs(t, err, &invalid) + assert.Empty(t, *calls, "terraform must not run without a region") +} + +func TestCreateCluster_CredentialPreflightFailsFast(t *testing.T) { + p, calls, _ := newTestProvider(t, nil) + mock := executor.NewMockCommandExecutor() + mock.SetShouldFail(true, "InvalidClientTokenId") + p.executor = mock + + _, err := p.CreateCluster(context.Background(), eksConfig("demo")) + require.Error(t, err) + assert.Contains(t, err.Error(), "cannot authenticate") + assert.Empty(t, *calls, "terraform must not run with broken credentials") +} + +func TestDeleteCluster_DestroysAndRemovesWorkspace(t *testing.T) { + p, calls, registry := newTestProvider(t, nil) + _, err := p.CreateCluster(context.Background(), eksConfig("demo")) + require.NoError(t, err) + + require.NoError(t, p.DeleteCluster(context.Background(), "demo", models.ClusterTypeEKS, false)) + assert.Contains(t, *calls, "destroy") + + _, err = registry.Get("demo") + var notFound models.ErrClusterNotFound + assert.ErrorAs(t, err, ¬Found) +} + +func TestDeleteCluster_MissingIsNotFound(t *testing.T) { + p, _, _ := newTestProvider(t, nil) + err := p.DeleteCluster(context.Background(), "ghost", models.ClusterTypeEKS, false) + var notFound models.ErrClusterNotFound + assert.ErrorAs(t, err, ¬Found) +} + +func TestStartCluster_Unsupported(t *testing.T) { + p, _, _ := newTestProvider(t, nil) + err := p.StartCluster(context.Background(), "demo", models.ClusterTypeEKS) + assert.ErrorContains(t, err, "not supported") +} + +func TestListAndDetect(t *testing.T) { + p, _, _ := newTestProvider(t, nil) + _, err := p.CreateCluster(context.Background(), eksConfig("demo")) + require.NoError(t, err) + + clusters, err := p.ListClusters(context.Background()) + require.NoError(t, err) + require.Len(t, clusters, 1) + assert.Equal(t, models.ClusterTypeEKS, clusters[0].Type) + assert.Equal(t, "Ready", clusters[0].Status) + + clusterType, err := p.DetectClusterType(context.Background(), "demo") + require.NoError(t, err) + assert.Equal(t, models.ClusterTypeEKS, clusterType) + + _, err = p.DetectClusterType(context.Background(), "ghost") + assert.Error(t, err) +} + +func TestGetKubeconfig_RendersExecAuth(t *testing.T) { + p, _, _ := newTestProvider(t, nil) + config := eksConfig("demo") + config.Cloud.Profile = "staging" + _, err := p.CreateCluster(context.Background(), config) + require.NoError(t, err) + + kubeconfig, err := p.GetKubeconfig(context.Background(), "demo", models.ClusterTypeEKS) + require.NoError(t, err) + assert.Contains(t, kubeconfig, "eks") + assert.Contains(t, kubeconfig, "get-token") + assert.Contains(t, kubeconfig, "--profile") + assert.Contains(t, kubeconfig, "staging") +} + +func TestTfvarsFor_VersionMapping(t *testing.T) { + base := eksConfig("demo") + + cases := []struct { + in string + want string + wantErr bool + }{ + {"", "", false}, + {"latest", "", false}, + {"1.33", "1.33", false}, + {"v1.33", "1.33", false}, + {"v1.31.5-k3s1", "", true}, // k3s-style versions are not EKS versions + } + for _, tc := range cases { + config := base + config.K8sVersion = tc.in + vars, err := tfvarsFor(config) + if tc.wantErr { + assert.Error(t, err, "input %q", tc.in) + continue + } + require.NoError(t, err, "input %q", tc.in) + assert.Equal(t, tc.want, vars.KubernetesVersion, "input %q", tc.in) + } +} + +func TestTemplateEmbedsModulePins(t *testing.T) { + tf := string(mainTF) + assert.Contains(t, tf, `source = "terraform-aws-modules/eks/aws"`) + assert.Contains(t, tf, `version = "~> 21.0"`) + assert.Contains(t, tf, `source = "terraform-aws-modules/vpc/aws"`) + assert.Contains(t, tf, `version = "~> 6.0"`) + assert.Contains(t, tf, "enable_cluster_creator_admin_permissions = true") +} + +func TestPlanCluster_NewClusterDoesNotRegister(t *testing.T) { + p, calls, registry := newTestProvider(t, nil) + + summary, err := p.PlanCluster(context.Background(), eksConfig("demo")) + require.NoError(t, err) + assert.True(t, summary.HasChanges()) + assert.Equal(t, []string{"init", "plan", "show"}, *calls) + + _, err = registry.Get("demo") + var notFound models.ErrClusterNotFound + assert.ErrorAs(t, err, ¬Found, "a plan preview must not register the cluster") +} + +func TestCreateCluster_WritesS3Backend(t *testing.T) { + p, _, registry := newTestProvider(t, nil) + config := eksConfig("demo") + config.Cloud.BackendConfig = "s3://my-bucket/clusters/demo" + + _, err := p.CreateCluster(context.Background(), config) + require.NoError(t, err) + + backend, err := os.ReadFile(filepath.Join(registry.Workspace("demo").TerraformDir(), "backend.tf")) + require.NoError(t, err) + assert.Contains(t, string(backend), `backend "s3"`) + assert.Contains(t, string(backend), `bucket = "my-bucket"`) + assert.Contains(t, string(backend), `key = "clusters/demo/terraform.tfstate"`) + assert.Contains(t, string(backend), `region = "us-east-1"`) +} + +func TestCreateCluster_RejectsGCSBackend(t *testing.T) { + p, calls, _ := newTestProvider(t, nil) + config := eksConfig("demo") + config.Cloud.BackendConfig = "gcs://my-bucket/prefix" + + _, err := p.CreateCluster(context.Background(), config) + var invalid models.ErrInvalidClusterConfig + require.ErrorAs(t, err, &invalid) + assert.Contains(t, err.Error(), "must be s3://") + assert.Empty(t, *calls) +} diff --git a/internal/cluster/providers/eks/template.go b/internal/cluster/providers/eks/template.go new file mode 100644 index 00000000..663f82fd --- /dev/null +++ b/internal/cluster/providers/eks/template.go @@ -0,0 +1,84 @@ +package eks + +import ( + _ "embed" + "fmt" + "regexp" + "strings" + + "github.com/flamingo-stack/openframe-cli/internal/cluster/models" +) + +// mainTF is the generated root module. It is static — all per-cluster values +// travel through terraform.tfvars.json, so there is no HCL templating (and no +// HCL-escaping bugs). +// +//go:embed templates/main.tf +var mainTF []byte + +// tfvars mirrors the variables block of templates/main.tf. +type tfvars struct { + ClusterName string `json:"cluster_name"` + Region string `json:"region"` + Profile string `json:"profile,omitempty"` + KubernetesVersion string `json:"kubernetes_version,omitempty"` + InstanceType string `json:"instance_type,omitempty"` + MinNodes int `json:"min_nodes,omitempty"` + MaxNodes int `json:"max_nodes,omitempty"` + DesiredNodes int `json:"desired_nodes,omitempty"` + Spot bool `json:"spot,omitempty"` +} + +// eksVersionRE matches the . form EKS expects (e.g. "1.33"). +var eksVersionRE = regexp.MustCompile(`^\d+\.\d+$`) + +// tfvarsFor maps a validated ClusterConfig onto the template variables. +func tfvarsFor(config models.ClusterConfig) (tfvars, error) { + cloud := config.Cloud + + version := strings.TrimPrefix(config.K8sVersion, "v") + if version == "latest" { + version = "" // template maps empty to the EKS default (its latest) + } + if version != "" && !eksVersionRE.MatchString(version) { + return tfvars{}, models.NewInvalidConfigError("version", config.K8sVersion, + "EKS expects . (e.g. 1.33)") + } + + vars := tfvars{ + ClusterName: config.Name, + Region: cloud.Region, + Profile: cloud.Profile, + KubernetesVersion: version, + InstanceType: cloud.MachineType, + MinNodes: cloud.MinNodes, + MaxNodes: cloud.MaxNodes, + DesiredNodes: config.NodeCount, + Spot: cloud.Spot, + } + return vars, nil +} + +// validate enforces the EKS-specific config invariants at the domain boundary. +func validate(config models.ClusterConfig) error { + if err := models.ValidateClusterName(config.Name); err != nil { + return models.NewInvalidConfigError("name", config.Name, err.Error()) + } + if config.Type != models.ClusterTypeEKS { + return models.NewProviderNotFoundError(config.Type) + } + if config.Cloud == nil || config.Cloud.Region == "" { + return models.NewInvalidConfigError("region", "", "a region is required for EKS clusters (--region)") + } + if config.NodeCount < 1 { + return models.NewInvalidConfigError("nodeCount", config.NodeCount, "node count must be at least 1") + } + c := config.Cloud + if c.MinNodes < 0 || c.MaxNodes < 0 { + return models.NewInvalidConfigError("nodes", fmt.Sprintf("min=%d max=%d", c.MinNodes, c.MaxNodes), "node bounds must not be negative") + } + if c.MinNodes > 0 && c.MaxNodes > 0 && c.MinNodes > c.MaxNodes { + return models.NewInvalidConfigError("nodes", fmt.Sprintf("min=%d max=%d", c.MinNodes, c.MaxNodes), "min nodes must not exceed max nodes") + } + return nil +} diff --git a/internal/cluster/providers/eks/template_validate_test.go b/internal/cluster/providers/eks/template_validate_test.go new file mode 100644 index 00000000..38841214 --- /dev/null +++ b/internal/cluster/providers/eks/template_validate_test.go @@ -0,0 +1,64 @@ +//go:build tfvalidate + +package eks + +// Opt-in template validation: `make test-tfvalidate`. Runs a real +// `terraform init` (downloads the pinned modules + providers, needs network) +// followed by `terraform validate`, which statically checks the generated +// root module against the downloaded module schemas — wrong input names or +// types fail here without any cloud credentials. This is the strongest check +// available before a real (billed) e2e apply. + +import ( + "context" + "testing" + "time" + + "github.com/flamingo-stack/openframe-cli/internal/cluster/models" + tfengine "github.com/flamingo-stack/openframe-cli/internal/cluster/providers/terraform" + "github.com/hashicorp/terraform-exec/tfexec" +) + +func TestTerraformValidate(t *testing.T) { + bin, err := tfengine.FindTerraform() + if err != nil { + t.Fatalf("the tfvalidate tag requires a terraform binary: %v", err) + } + + vars, err := tfvarsFor(models.ClusterConfig{ + Name: "validate-only", + Type: models.ClusterTypeEKS, + NodeCount: 3, + Cloud: &models.CloudConfig{Region: "us-east-1"}, + }) + if err != nil { + t.Fatal(err) + } + + dir := t.TempDir() + if err := tfengine.WriteModule(dir, mainTF, vars); err != nil { + t.Fatal(err) + } + + tf, err := tfexec.NewTerraform(dir, bin) + if err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) + defer cancel() + if err := tf.Init(ctx, tfexec.Upgrade(false)); err != nil { + t.Fatalf("terraform init: %v", err) + } + + out, err := tf.Validate(ctx) + if err != nil { + t.Fatalf("terraform validate: %v", err) + } + for _, d := range out.Diagnostics { + t.Logf("%s: %s — %s", d.Severity, d.Summary, d.Detail) + } + if !out.Valid || out.ErrorCount > 0 { + t.Fatalf("EKS template is invalid: %d error(s)", out.ErrorCount) + } +} diff --git a/internal/cluster/providers/eks/templates/main.tf b/internal/cluster/providers/eks/templates/main.tf new file mode 100644 index 00000000..d1b8e6f3 --- /dev/null +++ b/internal/cluster/providers/eks/templates/main.tf @@ -0,0 +1,140 @@ +# Root module generated by the OpenFrame CLI (do not edit by hand — the CLI +# owns this workspace). Provisions a self-contained EKS cluster: dedicated VPC +# (2 AZs, single NAT) + EKS with one managed node group. All inputs come from +# terraform.tfvars.json next to this file. + +terraform { + required_version = ">= 1.15.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 6.52" + } + } +} + +variable "cluster_name" { type = string } +variable "region" { type = string } + +variable "profile" { + type = string + default = "" +} + +# Kubernetes . (e.g. "1.33"); empty means the EKS default. +variable "kubernetes_version" { + type = string + default = "" +} + +variable "instance_type" { + type = string + default = "m6i.large" +} + +variable "min_nodes" { + type = number + default = 1 +} + +variable "max_nodes" { + type = number + default = 4 +} + +variable "desired_nodes" { + type = number + default = 3 +} + +variable "spot" { + type = bool + default = false +} + +provider "aws" { + region = var.region + profile = var.profile != "" ? var.profile : null + + default_tags { + tags = { + "openframe:cluster" = var.cluster_name + "openframe:managedBy" = "openframe-cli" + } + } +} + +data "aws_availability_zones" "available" { + state = "available" +} + +locals { + azs = slice(data.aws_availability_zones.available.names, 0, 2) +} + +module "vpc" { + source = "terraform-aws-modules/vpc/aws" + version = "~> 6.0" + + name = "${var.cluster_name}-vpc" + cidr = "10.0.0.0/16" + + azs = local.azs + private_subnets = ["10.0.1.0/24", "10.0.2.0/24"] + public_subnets = ["10.0.101.0/24", "10.0.102.0/24"] + + enable_nat_gateway = true + single_nat_gateway = true + + public_subnet_tags = { + "kubernetes.io/role/elb" = "1" + } + private_subnet_tags = { + "kubernetes.io/role/internal-elb" = "1" + } +} + +module "eks" { + source = "terraform-aws-modules/eks/aws" + version = "~> 21.0" + + name = var.cluster_name + kubernetes_version = var.kubernetes_version != "" ? var.kubernetes_version : null + + vpc_id = module.vpc.vpc_id + subnet_ids = module.vpc.private_subnets + + # The CLI reaches the API server from the operator's machine. + endpoint_public_access = true + + # The identity running terraform must be able to use the cluster afterwards + # (kubeconfig auth goes through `aws eks get-token` with the same identity). + enable_cluster_creator_admin_permissions = true + + eks_managed_node_groups = { + default = { + instance_types = [var.instance_type] + min_size = var.min_nodes + max_size = var.max_nodes + desired_size = var.desired_nodes + capacity_type = var.spot ? "SPOT" : "ON_DEMAND" + } + } +} + +output "cluster_name" { + value = module.eks.cluster_name +} + +output "cluster_endpoint" { + value = module.eks.cluster_endpoint +} + +output "cluster_certificate_authority_data" { + value = module.eks.cluster_certificate_authority_data +} + +output "region" { + value = var.region +} diff --git a/internal/cluster/providers/gke/kubeconfig.go b/internal/cluster/providers/gke/kubeconfig.go new file mode 100644 index 00000000..a17f9278 --- /dev/null +++ b/internal/cluster/providers/gke/kubeconfig.go @@ -0,0 +1,103 @@ +package gke + +import ( + "encoding/base64" + "fmt" + + tfengine "github.com/flamingo-stack/openframe-cli/internal/cluster/providers/terraform" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" + clientcmdapi "k8s.io/client-go/tools/clientcmd/api" +) + +// GKE kubeconfig entries carry no static credentials: authentication runs +// through the client-go exec plugin (gke-gcloud-auth-plugin), so tokens are +// short-lived and minted from the operator's gcloud identity on every call. + +func execConfig() *clientcmdapi.ExecConfig { + return &clientcmdapi.ExecConfig{ + APIVersion: "client.authentication.k8s.io/v1beta1", + Command: "gke-gcloud-auth-plugin", + InteractiveMode: clientcmdapi.NeverExecInteractiveMode, + ProvideClusterInfo: true, + } +} + +// caData decodes the base64 CA bundle the GKE module outputs. +func caData(rec tfengine.Record) ([]byte, error) { + ca, err := base64.StdEncoding.DecodeString(rec.CACert) + if err != nil { + return nil, fmt.Errorf("decoding cluster CA for %s: %w", rec.Name, err) + } + return ca, nil +} + +// kubeconfigFor renders an in-memory kubeconfig with a single context named +// after the cluster — the plain name so the rest of the CLI resolves it by +// exact match. +func kubeconfigFor(rec tfengine.Record) (*clientcmdapi.Config, error) { + ca, err := caData(rec) + if err != nil { + return nil, err + } + cfg := clientcmdapi.NewConfig() + cfg.Clusters[rec.Name] = &clientcmdapi.Cluster{ + Server: rec.Endpoint, + CertificateAuthorityData: ca, + } + cfg.AuthInfos[rec.Name] = &clientcmdapi.AuthInfo{Exec: execConfig()} + cfg.Contexts[rec.Name] = &clientcmdapi.Context{Cluster: rec.Name, AuthInfo: rec.Name} + cfg.CurrentContext = rec.Name + return cfg, nil +} + +// restConfigFor builds a rest.Config straight from the record. +func restConfigFor(rec tfengine.Record) (*rest.Config, error) { + ca, err := caData(rec) + if err != nil { + return nil, err + } + return &rest.Config{ + Host: rec.Endpoint, + TLSClientConfig: rest.TLSClientConfig{CAData: ca}, + ExecProvider: execConfig(), + }, nil +} + +// mergeIntoDefaultKubeconfig writes the cluster's context into the user's +// kubeconfig (honoring $KUBECONFIG) and switches the current context to it. +func mergeIntoDefaultKubeconfig(rec tfengine.Record) error { + pathOpts := clientcmd.NewDefaultPathOptions() + existing, err := pathOpts.GetStartingConfig() + if err != nil { + return fmt.Errorf("loading kubeconfig: %w", err) + } + generated, err := kubeconfigFor(rec) + if err != nil { + return err + } + existing.Clusters[rec.Name] = generated.Clusters[rec.Name] + existing.AuthInfos[rec.Name] = generated.AuthInfos[rec.Name] + existing.Contexts[rec.Name] = generated.Contexts[rec.Name] + existing.CurrentContext = rec.Name + if err := clientcmd.ModifyConfig(pathOpts, *existing, true); err != nil { + return fmt.Errorf("writing kubeconfig: %w", err) + } + return nil +} + +// removeFromDefaultKubeconfig drops the cluster's context after a destroy. +func removeFromDefaultKubeconfig(name string) error { + pathOpts := clientcmd.NewDefaultPathOptions() + existing, err := pathOpts.GetStartingConfig() + if err != nil { + return err + } + delete(existing.Clusters, name) + delete(existing.AuthInfos, name) + delete(existing.Contexts, name) + if existing.CurrentContext == name { + existing.CurrentContext = "" + } + return clientcmd.ModifyConfig(pathOpts, *existing, true) +} diff --git a/internal/cluster/providers/gke/provider.go b/internal/cluster/providers/gke/provider.go new file mode 100644 index 00000000..6a573b11 --- /dev/null +++ b/internal/cluster/providers/gke/provider.go @@ -0,0 +1,308 @@ +// Package gke implements the cluster Provider for Google Kubernetes Engine. +// Provisioning runs through the shared terraform engine: the provider +// generates a root module (public terraform-google-modules/kubernetes-engine +// + network modules, pinned) into the cluster's workspace and drives +// init/apply/destroy there. See the package comment in +// internal/cluster/providers/terraform for the workspace layout. +package gke + +import ( + "context" + "fmt" + "os" + "strings" + "time" + + "github.com/flamingo-stack/openframe-cli/internal/cluster/models" + tfengine "github.com/flamingo-stack/openframe-cli/internal/cluster/providers/terraform" + "github.com/flamingo-stack/openframe-cli/internal/shared/executor" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" +) + +// Provider provisions and manages GKE clusters. +type Provider struct { + engine *tfengine.Engine + registry *tfengine.Registry + executor executor.CommandExecutor +} + +// New builds the production provider. The registry defaults to +// ~/.openframe/clusters. +func New(exec executor.CommandExecutor, verbose bool) (*Provider, error) { + registry, err := tfengine.DefaultRegistry() + if err != nil { + return nil, err + } + return &Provider{ + engine: tfengine.NewEngine(verbose), + registry: registry, + executor: exec, + }, nil +} + +// NewWithDeps is the test constructor. +func NewWithDeps(engine *tfengine.Engine, registry *tfengine.Registry, exec executor.CommandExecutor) *Provider { + return &Provider{engine: engine, registry: registry, executor: exec} +} + +// preflightCredentials fails fast with an actionable message when the gcloud +// identity or project access is unusable — before any terraform runs. +func (p *Provider) preflightCredentials(ctx context.Context, project string) error { + if _, err := p.executor.Execute(ctx, "gcloud", "auth", "print-access-token", "--quiet"); err != nil { + return fmt.Errorf("gcloud is not authenticated (run 'gcloud auth login' and 'gcloud auth application-default login'): %w", err) + } + if _, err := p.executor.Execute(ctx, "gcloud", "projects", "describe", project, "--format=value(projectId)"); err != nil { + return fmt.Errorf("GCP project '%s' is not accessible with the current gcloud identity: %w", project, err) + } + return nil +} + +// backendTF renders the gcs backend block for a GKE workspace. +func backendTF(cfg tfengine.BackendConfig) []byte { + return []byte(fmt.Sprintf( + "terraform {\n backend \"gcs\" {\n bucket = %q\n prefix = %q\n }\n}\n", + cfg.Bucket, cfg.Prefix)) +} + +// parseBackend validates the optional --backend-config value for GKE. +func parseBackend(config models.ClusterConfig) (*tfengine.BackendConfig, error) { + if config.Cloud.BackendConfig == "" { + return nil, nil + } + cfg, err := tfengine.ParseBackendURL(config.Cloud.BackendConfig) + if err != nil { + return nil, models.NewInvalidConfigError("backend-config", config.Cloud.BackendConfig, err.Error()) + } + if cfg.Scheme != "gcs" { + return nil, models.NewInvalidConfigError("backend-config", config.Cloud.BackendConfig, "GKE remote state must be gcs://bucket/prefix") + } + return &cfg, nil +} + +// PlanCluster previews what CreateCluster would do — a real terraform plan — +// without registering the cluster or touching any state. A brand-new cluster +// is planned in a throwaway directory; an existing (failed/interrupted) +// workspace is planned in place to show what a resume would change. +func (p *Provider) PlanCluster(ctx context.Context, config models.ClusterConfig) (tfengine.PlanSummary, error) { + if err := validate(config); err != nil { + return tfengine.PlanSummary{}, err + } + if err := p.preflightCredentials(ctx, config.Cloud.Project); err != nil { + return tfengine.PlanSummary{}, err + } + + dir := p.registry.Workspace(config.Name).TerraformDir() + if !p.registry.Workspace(config.Name).Exists() { + vars, err := tfvarsFor(config) + if err != nil { + return tfengine.PlanSummary{}, err + } + tmp, err := os.MkdirTemp("", "openframe-plan-*") + if err != nil { + return tfengine.PlanSummary{}, err + } + defer func() { _ = os.RemoveAll(tmp) }() + if err := tfengine.WriteModule(tmp, mainTF, vars); err != nil { + return tfengine.PlanSummary{}, err + } + dir = tmp + } + + if err := p.engine.Init(ctx, dir); err != nil { + return tfengine.PlanSummary{}, err + } + return p.engine.Plan(ctx, dir) +} + +// CreateCluster provisions the cluster and returns a rest.Config for it. +// Re-running after a failed apply resumes the same workspace. +func (p *Provider) CreateCluster(ctx context.Context, config models.ClusterConfig) (*rest.Config, error) { + if err := validate(config); err != nil { + return nil, err + } + backend, err := parseBackend(config) + if err != nil { + return nil, err + } + if err := p.preflightCredentials(ctx, config.Cloud.Project); err != nil { + return nil, err + } + + ws := p.registry.Workspace(config.Name) + if !ws.Exists() { + vars, err := tfvarsFor(config) + if err != nil { + return nil, err + } + record := tfengine.Record{ + Name: config.Name, + Type: models.ClusterTypeGKE, + Status: tfengine.StatusCreating, + Region: config.Cloud.Region, + Project: config.Cloud.Project, + K8sVersion: vars.KubernetesVersion, + NodeCount: config.NodeCount, + CreatedAt: time.Now().UTC(), + } + if err := ws.Scaffold(record, mainTF, vars); err != nil { + return nil, err + } + if backend != nil { + if err := ws.WriteBackend(backendTF(*backend)); err != nil { + return nil, err + } + } + } + // An existing workspace means a previous create failed or was interrupted; + // keep its tfvars (the state may reference them) and simply resume. + + if err := p.engine.Init(ctx, ws.TerraformDir()); err != nil { + _ = ws.SetStatus(tfengine.StatusFailed) + return nil, models.NewClusterOperationError("create", config.Name, err) + } + if err := p.engine.Apply(ctx, ws.TerraformDir()); err != nil { + _ = ws.SetStatus(tfengine.StatusFailed) + return nil, models.NewClusterOperationError("create", config.Name, + fmt.Errorf("%w\nThe terraform state is kept in %s; re-run create to resume or 'openframe cluster delete %s' to tear down", err, ws.Dir(), config.Name)) + } + + outputs, err := p.engine.Outputs(ctx, ws.TerraformDir()) + if err != nil { + _ = ws.SetStatus(tfengine.StatusFailed) + return nil, models.NewClusterOperationError("create", config.Name, err) + } + record, err := ws.ReadRecord() + if err != nil { + return nil, err + } + endpoint, err := tfengine.StringOutput(outputs, "cluster_endpoint") + if err != nil { + return nil, models.NewClusterOperationError("create", config.Name, err) + } + // The GKE module emits a bare host; kubeconfig/rest need a URL. + if !strings.HasPrefix(endpoint, "https://") { + endpoint = "https://" + endpoint + } + record.Endpoint = endpoint + if record.CACert, err = tfengine.StringOutput(outputs, "cluster_certificate_authority_data"); err != nil { + return nil, models.NewClusterOperationError("create", config.Name, err) + } + record.Status = tfengine.StatusReady + if err := ws.WriteRecord(record); err != nil { + return nil, err + } + + if err := mergeIntoDefaultKubeconfig(record); err != nil { + return nil, models.NewClusterOperationError("create", config.Name, err) + } + return restConfigFor(record) +} + +// DeleteCluster destroys the cluster's cloud resources, then removes the +// workspace and the kubeconfig context. The workspace survives a failed +// destroy — its state is the only pointer to still-billed resources. +func (p *Provider) DeleteCluster(ctx context.Context, name string, clusterType models.ClusterType, force bool) error { + if clusterType != models.ClusterTypeGKE { + return models.NewProviderNotFoundError(clusterType) + } + ws := p.registry.Workspace(name) + if !ws.Exists() { + return models.NewClusterNotFoundError(name) + } + if err := p.engine.Destroy(ctx, ws.TerraformDir()); err != nil { + return models.NewClusterOperationError("delete", name, + fmt.Errorf("%w\nThe terraform state is kept in %s; re-run delete to retry", err, ws.Dir())) + } + _ = removeFromDefaultKubeconfig(name) + return ws.Remove() +} + +// StartCluster is meaningless for a managed control plane. +func (p *Provider) StartCluster(ctx context.Context, name string, clusterType models.ClusterType) error { + return fmt.Errorf("starting is not supported for GKE clusters: the managed control plane is always running") +} + +// ListClusters returns the GKE clusters recorded in the local registry. +func (p *Provider) ListClusters(ctx context.Context) ([]models.ClusterInfo, error) { + records, err := p.registry.List() + if err != nil { + return nil, err + } + infos := make([]models.ClusterInfo, 0, len(records)) + for _, rec := range records { + if rec.Type != models.ClusterTypeGKE { + continue + } + infos = append(infos, infoFor(rec)) + } + return infos, nil +} + +// ListAllClusters is the same as ListClusters: the registry is this +// provider's full visibility. +func (p *Provider) ListAllClusters(ctx context.Context) ([]models.ClusterInfo, error) { + return p.ListClusters(ctx) +} + +// GetClusterStatus returns the recorded status for a single cluster. +func (p *Provider) GetClusterStatus(ctx context.Context, name string) (models.ClusterInfo, error) { + rec, err := p.registry.Get(name) + if err != nil || rec.Type != models.ClusterTypeGKE { + return models.ClusterInfo{}, models.NewClusterNotFoundError(name) + } + return infoFor(rec), nil +} + +// DetectClusterType reports gke for registry-recorded clusters. +func (p *Provider) DetectClusterType(ctx context.Context, name string) (models.ClusterType, error) { + rec, err := p.registry.Get(name) + if err != nil || rec.Type != models.ClusterTypeGKE { + return "", models.NewClusterNotFoundError(name) + } + return models.ClusterTypeGKE, nil +} + +// GetRestConfig builds a rest.Config from the recorded endpoint/CA. +func (p *Provider) GetRestConfig(ctx context.Context, name string) (*rest.Config, error) { + rec, err := p.registry.Get(name) + if err != nil { + return nil, err + } + if rec.Status != tfengine.StatusReady { + return nil, fmt.Errorf("cluster '%s' is not ready (status: %s)", name, rec.Status) + } + return restConfigFor(rec) +} + +// GetKubeconfig renders the cluster's kubeconfig as YAML. +func (p *Provider) GetKubeconfig(ctx context.Context, name string, clusterType models.ClusterType) (string, error) { + if clusterType != models.ClusterTypeGKE { + return "", models.NewProviderNotFoundError(clusterType) + } + rec, err := p.registry.Get(name) + if err != nil { + return "", err + } + cfg, err := kubeconfigFor(rec) + if err != nil { + return "", err + } + data, err := clientcmd.Write(*cfg) + if err != nil { + return "", err + } + return string(data), nil +} + +// infoFor maps a registry record onto the shared ClusterInfo shape. +func infoFor(rec tfengine.Record) models.ClusterInfo { + return models.ClusterInfo{ + Name: rec.Name, + Type: models.ClusterTypeGKE, + Status: strings.ToTitle(string(rec.Status[0:1])) + string(rec.Status[1:]), + NodeCount: rec.NodeCount, + K8sVersion: rec.K8sVersion, + CreatedAt: rec.CreatedAt, + } +} diff --git a/internal/cluster/providers/gke/provider_test.go b/internal/cluster/providers/gke/provider_test.go new file mode 100644 index 00000000..92be8487 --- /dev/null +++ b/internal/cluster/providers/gke/provider_test.go @@ -0,0 +1,289 @@ +package gke + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "io" + "os" + "path/filepath" + "testing" + + "github.com/flamingo-stack/openframe-cli/internal/cluster/models" + tfengine "github.com/flamingo-stack/openframe-cli/internal/cluster/providers/terraform" + "github.com/flamingo-stack/openframe-cli/internal/shared/executor" + "github.com/hashicorp/terraform-exec/tfexec" + tfjson "github.com/hashicorp/terraform-json" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var testCA = base64.StdEncoding.EncodeToString([]byte("fake-ca-pem")) + +// fakeRunner is a canned tfexec stand-in. +type fakeRunner struct { + calls *[]string + applyErr error +} + +func (f *fakeRunner) Init(ctx context.Context, opts ...tfexec.InitOption) error { + *f.calls = append(*f.calls, "init") + return nil +} + +func (f *fakeRunner) Apply(ctx context.Context, opts ...tfexec.ApplyOption) error { + *f.calls = append(*f.calls, "apply") + return f.applyErr +} + +func (f *fakeRunner) ApplyJSON(ctx context.Context, w io.Writer, opts ...tfexec.ApplyOption) error { + return f.Apply(ctx) +} + +func (f *fakeRunner) Destroy(ctx context.Context, opts ...tfexec.DestroyOption) error { + *f.calls = append(*f.calls, "destroy") + return nil +} + +func (f *fakeRunner) DestroyJSON(ctx context.Context, w io.Writer, opts ...tfexec.DestroyOption) error { + return f.Destroy(ctx) +} + +func (f *fakeRunner) Plan(ctx context.Context, opts ...tfexec.PlanOption) (bool, error) { + *f.calls = append(*f.calls, "plan") + return true, nil +} + +func (f *fakeRunner) ShowPlanFile(ctx context.Context, planPath string, opts ...tfexec.ShowOption) (*tfjson.Plan, error) { + *f.calls = append(*f.calls, "show") + return &tfjson.Plan{ResourceChanges: []*tfjson.ResourceChange{ + {Change: &tfjson.Change{Actions: tfjson.Actions{tfjson.ActionCreate}}}, + }}, nil +} + +func (f *fakeRunner) Output(ctx context.Context, opts ...tfexec.OutputOption) (map[string]tfexec.OutputMeta, error) { + *f.calls = append(*f.calls, "output") + return map[string]tfexec.OutputMeta{ + "cluster_name": {Value: json.RawMessage(`"demo"`)}, + // The GKE module emits a bare host, no scheme — the provider must add it. + "cluster_endpoint": {Value: json.RawMessage(`"34.10.20.30"`)}, + "cluster_certificate_authority_data": {Value: json.RawMessage(`"` + testCA + `"`)}, + "region": {Value: json.RawMessage(`"us-central1"`)}, + }, nil +} + +// newTestProvider wires the provider onto a temp registry, a fake runner, and +// an isolated kubeconfig. +func newTestProvider(t *testing.T, applyErr error) (*Provider, *[]string, *tfengine.Registry) { + t.Helper() + t.Setenv("KUBECONFIG", filepath.Join(t.TempDir(), "kubeconfig")) + + calls := &[]string{} + engine := tfengine.NewEngineWithRunner(func(workdir string) (tfengine.Runner, error) { + return &fakeRunner{calls: calls, applyErr: applyErr}, nil + }) + registry := tfengine.NewRegistry(t.TempDir()) + mock := executor.NewMockCommandExecutor() // gcloud preflight succeeds by default + return NewWithDeps(engine, registry, mock), calls, registry +} + +func gkeConfig(name string) models.ClusterConfig { + return models.ClusterConfig{ + Name: name, + Type: models.ClusterTypeGKE, + NodeCount: 3, + Cloud: &models.CloudConfig{ + Region: "us-central1", + Project: "my-project", + MachineType: "e2-standard-4", + }, + } +} + +func TestCreateCluster_HappyPath(t *testing.T) { + p, calls, registry := newTestProvider(t, nil) + + restConfig, err := p.CreateCluster(context.Background(), gkeConfig("demo")) + require.NoError(t, err) + assert.Equal(t, []string{"init", "apply", "output"}, *calls) + assert.Equal(t, "https://34.10.20.30", restConfig.Host, "bare module endpoint must be prefixed") + assert.Equal(t, []byte("fake-ca-pem"), restConfig.CAData) + require.NotNil(t, restConfig.ExecProvider) + assert.Equal(t, "gke-gcloud-auth-plugin", restConfig.ExecProvider.Command) + assert.True(t, restConfig.ExecProvider.ProvideClusterInfo) + + rec, err := registry.Get("demo") + require.NoError(t, err) + assert.Equal(t, tfengine.StatusReady, rec.Status) + assert.Equal(t, "my-project", rec.Project) + + kubeconfig, err := os.ReadFile(os.Getenv("KUBECONFIG")) + require.NoError(t, err) + assert.Contains(t, string(kubeconfig), "current-context: demo") +} + +func TestCreateCluster_FailedApplyKeepsWorkspace(t *testing.T) { + p, _, registry := newTestProvider(t, errors.New("quota exceeded")) + + _, err := p.CreateCluster(context.Background(), gkeConfig("demo")) + require.Error(t, err) + assert.Contains(t, err.Error(), "re-run create to resume") + + rec, err := registry.Get("demo") + require.NoError(t, err) + assert.Equal(t, tfengine.StatusFailed, rec.Status) +} + +func TestCreateCluster_RequiresProjectAndRegion(t *testing.T) { + p, calls, _ := newTestProvider(t, nil) + + noProject := gkeConfig("demo") + noProject.Cloud.Project = "" + _, err := p.CreateCluster(context.Background(), noProject) + var invalid models.ErrInvalidClusterConfig + require.ErrorAs(t, err, &invalid) + + noCloud := gkeConfig("demo") + noCloud.Cloud = nil + _, err = p.CreateCluster(context.Background(), noCloud) + require.ErrorAs(t, err, &invalid) + + assert.Empty(t, *calls, "terraform must not run without project/region") +} + +func TestCreateCluster_CredentialPreflightFailsFast(t *testing.T) { + p, calls, _ := newTestProvider(t, nil) + mock := executor.NewMockCommandExecutor() + mock.SetShouldFail(true, "not logged in") + p.executor = mock + + _, err := p.CreateCluster(context.Background(), gkeConfig("demo")) + require.Error(t, err) + assert.Contains(t, err.Error(), "not authenticated") + assert.Empty(t, *calls, "terraform must not run with broken credentials") +} + +func TestDeleteCluster_DestroysAndRemovesWorkspace(t *testing.T) { + p, calls, registry := newTestProvider(t, nil) + _, err := p.CreateCluster(context.Background(), gkeConfig("demo")) + require.NoError(t, err) + + require.NoError(t, p.DeleteCluster(context.Background(), "demo", models.ClusterTypeGKE, false)) + assert.Contains(t, *calls, "destroy") + + _, err = registry.Get("demo") + var notFound models.ErrClusterNotFound + assert.ErrorAs(t, err, ¬Found) +} + +func TestStartCluster_Unsupported(t *testing.T) { + p, _, _ := newTestProvider(t, nil) + err := p.StartCluster(context.Background(), "demo", models.ClusterTypeGKE) + assert.ErrorContains(t, err, "not supported") +} + +func TestListAndDetect_FiltersByType(t *testing.T) { + p, _, registry := newTestProvider(t, nil) + _, err := p.CreateCluster(context.Background(), gkeConfig("demo")) + require.NoError(t, err) + + // An EKS record in the same registry must not leak into the GKE provider. + eksRecord := tfengine.Record{Name: "other", Type: models.ClusterTypeEKS, Status: tfengine.StatusReady} + require.NoError(t, registry.Workspace("other").Scaffold(eksRecord, nil, nil)) + + clusters, err := p.ListClusters(context.Background()) + require.NoError(t, err) + require.Len(t, clusters, 1) + assert.Equal(t, models.ClusterTypeGKE, clusters[0].Type) + + _, err = p.DetectClusterType(context.Background(), "other") + assert.Error(t, err, "an eks cluster must not detect as gke") +} + +func TestGetKubeconfig_RendersExecAuth(t *testing.T) { + p, _, _ := newTestProvider(t, nil) + _, err := p.CreateCluster(context.Background(), gkeConfig("demo")) + require.NoError(t, err) + + kubeconfig, err := p.GetKubeconfig(context.Background(), "demo", models.ClusterTypeGKE) + require.NoError(t, err) + assert.Contains(t, kubeconfig, "gke-gcloud-auth-plugin") + assert.Contains(t, kubeconfig, "provideClusterInfo: true") +} + +func TestTfvarsFor_VersionMapping(t *testing.T) { + base := gkeConfig("demo") + + cases := []struct { + in string + want string + wantErr bool + }{ + {"", "", false}, + {"latest", "", false}, + {"1.33", "1.33", false}, + {"v1.33", "1.33", false}, + {"v1.31.5-k3s1", "", true}, + } + for _, tc := range cases { + config := base + config.K8sVersion = tc.in + vars, err := tfvarsFor(config) + if tc.wantErr { + assert.Error(t, err, "input %q", tc.in) + continue + } + require.NoError(t, err, "input %q", tc.in) + assert.Equal(t, tc.want, vars.KubernetesVersion, "input %q", tc.in) + } +} + +func TestTemplateEmbedsModulePins(t *testing.T) { + tf := string(mainTF) + assert.Contains(t, tf, `source = "terraform-google-modules/kubernetes-engine/google"`) + assert.Contains(t, tf, `version = "~> 44.0"`) + assert.Contains(t, tf, `source = "terraform-google-modules/network/google"`) + assert.Contains(t, tf, `version = "~> 18.0"`) + assert.Contains(t, tf, "deletion_protection = false") +} + +func TestPlanCluster_NewClusterDoesNotRegister(t *testing.T) { + p, calls, registry := newTestProvider(t, nil) + + summary, err := p.PlanCluster(context.Background(), gkeConfig("demo")) + require.NoError(t, err) + assert.True(t, summary.HasChanges()) + assert.Equal(t, []string{"init", "plan", "show"}, *calls) + + _, err = registry.Get("demo") + var notFound models.ErrClusterNotFound + assert.ErrorAs(t, err, ¬Found, "a plan preview must not register the cluster") +} + +func TestCreateCluster_WritesGCSBackend(t *testing.T) { + p, _, registry := newTestProvider(t, nil) + config := gkeConfig("demo") + config.Cloud.BackendConfig = "gcs://my-bucket/clusters/demo" + + _, err := p.CreateCluster(context.Background(), config) + require.NoError(t, err) + + backend, err := os.ReadFile(filepath.Join(registry.Workspace("demo").TerraformDir(), "backend.tf")) + require.NoError(t, err) + assert.Contains(t, string(backend), `backend "gcs"`) + assert.Contains(t, string(backend), `bucket = "my-bucket"`) + assert.Contains(t, string(backend), `prefix = "clusters/demo"`) +} + +func TestCreateCluster_RejectsS3Backend(t *testing.T) { + p, calls, _ := newTestProvider(t, nil) + config := gkeConfig("demo") + config.Cloud.BackendConfig = "s3://my-bucket/prefix" + + _, err := p.CreateCluster(context.Background(), config) + var invalid models.ErrInvalidClusterConfig + require.ErrorAs(t, err, &invalid) + assert.Contains(t, err.Error(), "must be gcs://") + assert.Empty(t, *calls) +} diff --git a/internal/cluster/providers/gke/template.go b/internal/cluster/providers/gke/template.go new file mode 100644 index 00000000..57e422da --- /dev/null +++ b/internal/cluster/providers/gke/template.go @@ -0,0 +1,85 @@ +package gke + +import ( + _ "embed" + "fmt" + "regexp" + "strings" + + "github.com/flamingo-stack/openframe-cli/internal/cluster/models" +) + +// mainTF is the generated root module. It is static — all per-cluster values +// travel through terraform.tfvars.json, so there is no HCL templating. +// +//go:embed templates/main.tf +var mainTF []byte + +// tfvars mirrors the variables block of templates/main.tf. +type tfvars struct { + ClusterName string `json:"cluster_name"` + Project string `json:"project"` + Region string `json:"region"` + KubernetesVersion string `json:"kubernetes_version,omitempty"` + InstanceType string `json:"instance_type,omitempty"` + MinNodes int `json:"min_nodes,omitempty"` + MaxNodes int `json:"max_nodes,omitempty"` + DesiredNodes int `json:"desired_nodes,omitempty"` + Spot bool `json:"spot,omitempty"` +} + +// gkeVersionRE matches the . form GKE expects (e.g. "1.33"). +var gkeVersionRE = regexp.MustCompile(`^\d+\.\d+$`) + +// tfvarsFor maps a validated ClusterConfig onto the template variables. +func tfvarsFor(config models.ClusterConfig) (tfvars, error) { + cloud := config.Cloud + + version := strings.TrimPrefix(config.K8sVersion, "v") + if version == "latest" { + version = "" // template maps empty to the GKE default + } + if version != "" && !gkeVersionRE.MatchString(version) { + return tfvars{}, models.NewInvalidConfigError("version", config.K8sVersion, + "GKE expects . (e.g. 1.33)") + } + + return tfvars{ + ClusterName: config.Name, + Project: cloud.Project, + Region: cloud.Region, + KubernetesVersion: version, + InstanceType: cloud.MachineType, + MinNodes: cloud.MinNodes, + MaxNodes: cloud.MaxNodes, + DesiredNodes: config.NodeCount, + Spot: cloud.Spot, + }, nil +} + +// validate enforces the GKE-specific config invariants at the domain boundary. +func validate(config models.ClusterConfig) error { + if err := models.ValidateClusterName(config.Name); err != nil { + return models.NewInvalidConfigError("name", config.Name, err.Error()) + } + if config.Type != models.ClusterTypeGKE { + return models.NewProviderNotFoundError(config.Type) + } + if config.Cloud == nil || config.Cloud.Region == "" { + return models.NewInvalidConfigError("region", "", "a region is required for GKE clusters (--region)") + } + if config.Cloud.Project == "" { + return models.NewInvalidConfigError("project", "", "a GCP project is required for GKE clusters (--project)") + } + if config.NodeCount < 1 { + return models.NewInvalidConfigError("nodeCount", config.NodeCount, "node count must be at least 1") + } + c := config.Cloud + if c.MinNodes < 0 || c.MaxNodes < 0 { + return models.NewInvalidConfigError("nodes", fmt.Sprintf("min=%d max=%d", c.MinNodes, c.MaxNodes), "node bounds must not be negative") + } + if c.MinNodes > 0 && c.MaxNodes > 0 && c.MinNodes > c.MaxNodes { + return models.NewInvalidConfigError("nodes", fmt.Sprintf("min=%d max=%d", c.MinNodes, c.MaxNodes), "min nodes must not exceed max nodes") + } + return nil +} diff --git a/internal/cluster/providers/gke/template_validate_test.go b/internal/cluster/providers/gke/template_validate_test.go new file mode 100644 index 00000000..5b4d9ab5 --- /dev/null +++ b/internal/cluster/providers/gke/template_validate_test.go @@ -0,0 +1,61 @@ +//go:build tfvalidate + +package gke + +// Opt-in template validation: `make test-tfvalidate`. See the EKS twin for +// the rationale — this statically verifies the generated root module against +// the real downloaded module schemas, without cloud credentials. + +import ( + "context" + "testing" + "time" + + "github.com/flamingo-stack/openframe-cli/internal/cluster/models" + tfengine "github.com/flamingo-stack/openframe-cli/internal/cluster/providers/terraform" + "github.com/hashicorp/terraform-exec/tfexec" +) + +func TestTerraformValidate(t *testing.T) { + bin, err := tfengine.FindTerraform() + if err != nil { + t.Fatalf("the tfvalidate tag requires a terraform binary: %v", err) + } + + vars, err := tfvarsFor(models.ClusterConfig{ + Name: "validate-only", + Type: models.ClusterTypeGKE, + NodeCount: 3, + Cloud: &models.CloudConfig{Region: "us-central1", Project: "validate-only"}, + }) + if err != nil { + t.Fatal(err) + } + + dir := t.TempDir() + if err := tfengine.WriteModule(dir, mainTF, vars); err != nil { + t.Fatal(err) + } + + tf, err := tfexec.NewTerraform(dir, bin) + if err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) + defer cancel() + if err := tf.Init(ctx, tfexec.Upgrade(false)); err != nil { + t.Fatalf("terraform init: %v", err) + } + + out, err := tf.Validate(ctx) + if err != nil { + t.Fatalf("terraform validate: %v", err) + } + for _, d := range out.Diagnostics { + t.Logf("%s: %s — %s", d.Severity, d.Summary, d.Detail) + } + if !out.Valid || out.ErrorCount > 0 { + t.Fatalf("GKE template is invalid: %d error(s)", out.ErrorCount) + } +} diff --git a/internal/cluster/providers/gke/templates/main.tf b/internal/cluster/providers/gke/templates/main.tf new file mode 100644 index 00000000..585cd4ba --- /dev/null +++ b/internal/cluster/providers/gke/templates/main.tf @@ -0,0 +1,155 @@ +# Root module generated by the OpenFrame CLI (do not edit by hand — the CLI +# owns this workspace). Provisions a self-contained GKE cluster: required +# project APIs, a dedicated VPC with pod/service secondary ranges, and a +# regional GKE cluster with one autoscaling node pool. All inputs come from +# terraform.tfvars.json next to this file. + +terraform { + required_version = ">= 1.15.0" + + required_providers { + google = { + source = "hashicorp/google" + version = ">= 7.17.0, < 8" + } + } +} + +variable "cluster_name" { type = string } +variable "project" { type = string } +variable "region" { type = string } + +# Kubernetes . (e.g. "1.33"); empty means the GKE default. +variable "kubernetes_version" { + type = string + default = "" +} + +variable "instance_type" { + type = string + default = "e2-standard-4" +} + +variable "min_nodes" { + type = number + default = 1 +} + +variable "max_nodes" { + type = number + default = 4 +} + +variable "desired_nodes" { + type = number + default = 3 +} + +variable "spot" { + type = bool + default = false +} + +provider "google" { + project = var.project + region = var.region + + default_labels = { + openframe-cluster = var.cluster_name + openframe-managed-by = "openframe-cli" + } +} + +# GKE needs these services active in the project; disable_on_destroy=false so +# a cluster teardown never switches off APIs other workloads may use. +resource "google_project_service" "required" { + for_each = toset(["compute.googleapis.com", "container.googleapis.com"]) + + service = each.value + disable_on_destroy = false +} + +module "network" { + source = "terraform-google-modules/network/google" + version = "~> 18.0" + + project_id = var.project + network_name = "${var.cluster_name}-vpc" + + subnets = [ + { + subnet_name = "${var.cluster_name}-subnet" + subnet_ip = "10.0.0.0/20" + subnet_region = var.region + } + ] + + secondary_ranges = { + "${var.cluster_name}-subnet" = [ + { + range_name = "pods" + ip_cidr_range = "10.4.0.0/14" + }, + { + range_name = "services" + ip_cidr_range = "10.8.0.0/20" + } + ] + } + + depends_on = [google_project_service.required] +} + +module "gke" { + source = "terraform-google-modules/kubernetes-engine/google" + version = "~> 44.0" + + project_id = var.project + name = var.cluster_name + region = var.region + regional = true + + network = module.network.network_name + subnetwork = "${var.cluster_name}-subnet" + ip_range_pods = "pods" + ip_range_services = "services" + + kubernetes_version = var.kubernetes_version != "" ? var.kubernetes_version : "latest" + + # The CLI owns this cluster's lifecycle; deletion protection would break + # 'openframe cluster delete'. + deletion_protection = false + + remove_default_node_pool = true + + node_pools = [ + { + name = "default" + machine_type = var.instance_type + min_count = var.min_nodes + max_count = var.max_nodes + initial_node_count = var.desired_nodes + spot = var.spot + disk_size_gb = 100 + } + ] +} + +output "cluster_name" { + value = module.gke.name +} + +# Bare host — the provider prefixes https:// when building kubeconfigs. +output "cluster_endpoint" { + value = module.gke.endpoint + sensitive = true +} + +output "cluster_certificate_authority_data" { + value = module.gke.ca_certificate + sensitive = true +} + +output "region" { + value = var.region +} diff --git a/internal/cluster/providers/k3d/manager.go b/internal/cluster/providers/k3d/manager.go index 1c09bda9..ab344c2a 100644 --- a/internal/cluster/providers/k3d/manager.go +++ b/internal/cluster/providers/k3d/manager.go @@ -382,6 +382,9 @@ func (m *K3dManager) validateClusterConfig(config models.ClusterConfig) error { if config.NodeCount < 1 { return models.NewInvalidConfigError("nodeCount", config.NodeCount, "node count must be at least 1") } + if config.Cloud != nil { + return models.NewInvalidConfigError("cloud", config.Cloud, "cloud settings are not valid for k3d clusters") + } return nil } diff --git a/internal/cluster/providers/k3d/manager_test.go b/internal/cluster/providers/k3d/manager_test.go index b1dcff02..f5c28572 100644 --- a/internal/cluster/providers/k3d/manager_test.go +++ b/internal/cluster/providers/k3d/manager_test.go @@ -198,6 +198,16 @@ func TestK3dManager_CreateCluster(t *testing.T) { }, expectedError: "node count must be at least 1", }, + { + name: "cloud settings rejected for k3d", + config: models.ClusterConfig{ + Name: "test-cluster", + Type: models.ClusterTypeK3d, + NodeCount: 3, + Cloud: &models.CloudConfig{Region: "us-east-1"}, + }, + expectedError: "cloud settings are not valid for k3d clusters", + }, { name: "k3d command fails", config: models.ClusterConfig{ diff --git a/internal/cluster/providers/terraform/backend.go b/internal/cluster/providers/terraform/backend.go new file mode 100644 index 00000000..0b5167aa --- /dev/null +++ b/internal/cluster/providers/terraform/backend.go @@ -0,0 +1,47 @@ +package terraform + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "strings" +) + +// Remote state is opt-in: --backend-config s3://bucket/prefix (EKS) or +// gcs://bucket/prefix (GKE). The default stays local state in the workspace — +// remote state protects against losing the machine that created the cluster. + +// BackendConfig is a parsed --backend-config value. +type BackendConfig struct { + Scheme string // "s3" or "gcs" + Bucket string + Prefix string +} + +// backendPartRE constrains bucket/prefix to characters that are safe to +// interpolate into generated HCL (defense in depth — values also come from a +// validated flag). +var backendPartRE = regexp.MustCompile(`^[A-Za-z0-9._/-]+$`) + +// ParseBackendURL parses scheme://bucket[/prefix]. +func ParseBackendURL(raw string) (BackendConfig, error) { + scheme, rest, found := strings.Cut(raw, "://") + if !found || (scheme != "s3" && scheme != "gcs") { + return BackendConfig{}, fmt.Errorf("invalid backend %q: expected s3://bucket/prefix or gcs://bucket/prefix", raw) + } + bucket, prefix, _ := strings.Cut(rest, "/") + if bucket == "" || !backendPartRE.MatchString(bucket) || (prefix != "" && !backendPartRE.MatchString(prefix)) { + return BackendConfig{}, fmt.Errorf("invalid backend %q: bucket/prefix may contain only letters, digits, '.', '_', '-' and '/'", raw) + } + return BackendConfig{Scheme: scheme, Bucket: bucket, Prefix: prefix}, nil +} + +// WriteBackend writes backend.tf next to the generated main.tf. Callers pass +// the rendered HCL for their provider's backend type. +func (w *Workspace) WriteBackend(backendTF []byte) error { + if err := os.MkdirAll(w.TerraformDir(), 0o750); err != nil { + return err + } + return os.WriteFile(filepath.Join(w.TerraformDir(), "backend.tf"), backendTF, 0o600) +} diff --git a/internal/cluster/providers/terraform/backend_test.go b/internal/cluster/providers/terraform/backend_test.go new file mode 100644 index 00000000..d3cfad6c --- /dev/null +++ b/internal/cluster/providers/terraform/backend_test.go @@ -0,0 +1,44 @@ +package terraform + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseBackendURL(t *testing.T) { + cases := []struct { + in string + want BackendConfig + wantErr bool + }{ + {"s3://bucket/some/prefix", BackendConfig{Scheme: "s3", Bucket: "bucket", Prefix: "some/prefix"}, false}, + {"gcs://bucket", BackendConfig{Scheme: "gcs", Bucket: "bucket"}, false}, + {"gcs://bucket/prefix", BackendConfig{Scheme: "gcs", Bucket: "bucket", Prefix: "prefix"}, false}, + {"http://bucket/prefix", BackendConfig{}, true}, + {"s3://", BackendConfig{}, true}, + {"just-a-bucket", BackendConfig{}, true}, + {`s3://bu"cket/prefix`, BackendConfig{}, true}, // HCL-hostile characters rejected + } + for _, tc := range cases { + got, err := ParseBackendURL(tc.in) + if tc.wantErr { + assert.Error(t, err, "input %q", tc.in) + continue + } + require.NoError(t, err, "input %q", tc.in) + assert.Equal(t, tc.want, got, "input %q", tc.in) + } +} + +func TestWorkspace_WriteBackend(t *testing.T) { + ws := OpenWorkspace(t.TempDir(), "demo") + require.NoError(t, ws.WriteBackend([]byte("terraform {}\n"))) + + data, err := os.ReadFile(filepath.Join(ws.TerraformDir(), "backend.tf")) + require.NoError(t, err) + assert.Equal(t, "terraform {}\n", string(data)) +} diff --git a/internal/cluster/providers/terraform/engine.go b/internal/cluster/providers/terraform/engine.go new file mode 100644 index 00000000..bfc4617b --- /dev/null +++ b/internal/cluster/providers/terraform/engine.go @@ -0,0 +1,196 @@ +package terraform + +import ( + "context" + "encoding/json" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + + "github.com/flamingo-stack/openframe-cli/internal/shared/download" + "github.com/hashicorp/terraform-exec/tfexec" + tfjson "github.com/hashicorp/terraform-json" +) + +// Runner is the subset of *tfexec.Terraform the engine uses; an interface so +// engine logic is testable without a terraform binary. +type Runner interface { + Init(ctx context.Context, opts ...tfexec.InitOption) error + Apply(ctx context.Context, opts ...tfexec.ApplyOption) error + ApplyJSON(ctx context.Context, w io.Writer, opts ...tfexec.ApplyOption) error + Destroy(ctx context.Context, opts ...tfexec.DestroyOption) error + DestroyJSON(ctx context.Context, w io.Writer, opts ...tfexec.DestroyOption) error + Plan(ctx context.Context, opts ...tfexec.PlanOption) (bool, error) + ShowPlanFile(ctx context.Context, planPath string, opts ...tfexec.ShowOption) (*tfjson.Plan, error) + Output(ctx context.Context, opts ...tfexec.OutputOption) (map[string]tfexec.OutputMeta, error) +} + +// Engine drives terraform init/plan/apply/destroy/output in a workspace's +// terraform directory via the terraform-exec library. +type Engine struct { + verbose bool + // newRunner is the construction seam for tests; production builds a + // *tfexec.Terraform on the resolved binary. + newRunner func(workdir string) (Runner, error) +} + +// FindTerraform resolves the terraform binary, preferring the CLI-managed +// pinned install in ~/.openframe/bin over whatever is on PATH. +func FindTerraform() (string, error) { + if binDir, err := download.UserBinDir(); err == nil { + download.PrependToPath(binDir) + } + path, err := exec.LookPath("terraform") + if err != nil { + return "", fmt.Errorf("terraform binary not found (the prerequisite installer provides a verified %s): %w", download.Terraform.Version, err) + } + return path, nil +} + +// NewEngine builds the production engine. Verbose streams terraform's own +// human output to the terminal; otherwise the engine stays quiet and the +// caller's spinner owns the UX. +func NewEngine(verbose bool) *Engine { + return &Engine{ + verbose: verbose, + newRunner: func(workdir string) (Runner, error) { + bin, err := FindTerraform() + if err != nil { + return nil, err + } + tf, err := tfexec.NewTerraform(workdir, bin) + if err != nil { + return nil, fmt.Errorf("initializing terraform runner: %w", err) + } + if verbose { + tf.SetStdout(os.Stdout) + tf.SetStderr(os.Stderr) + } + return tf, nil + }, + } +} + +// NewEngineWithRunner is the test constructor. +func NewEngineWithRunner(newRunner func(workdir string) (Runner, error)) *Engine { + return &Engine{newRunner: newRunner} +} + +// Init runs terraform init in dir. +func (e *Engine) Init(ctx context.Context, dir string) error { + tf, err := e.newRunner(dir) + if err != nil { + return err + } + if err := tf.Init(ctx, tfexec.Upgrade(false)); err != nil { + return fmt.Errorf("terraform init failed: %w", err) + } + return nil +} + +// Apply runs terraform apply in dir, streaming per-resource progress lines +// (via terraform's machine-readable -json output) so a 15-minute cloud apply +// is never a silent wait. It is idempotent: re-running after a partial +// failure resumes from the recorded state. +func (e *Engine) Apply(ctx context.Context, dir string) error { + tf, err := e.newRunner(dir) + if err != nil { + return err + } + if err := tf.ApplyJSON(ctx, newProgressWriter(e.verbose)); err != nil { + return fmt.Errorf("terraform apply failed: %w", err) + } + return nil +} + +// Destroy runs terraform destroy in dir, streaming progress like Apply. +func (e *Engine) Destroy(ctx context.Context, dir string) error { + tf, err := e.newRunner(dir) + if err != nil { + return err + } + if err := tf.DestroyJSON(ctx, newProgressWriter(e.verbose)); err != nil { + return fmt.Errorf("terraform destroy failed: %w", err) + } + return nil +} + +// PlanSummary is the resource-change footprint of a terraform plan. +type PlanSummary struct { + Add int + Change int + Destroy int +} + +// HasChanges reports whether the plan would modify anything. +func (s PlanSummary) HasChanges() bool { return s.Add+s.Change+s.Destroy > 0 } + +// Plan runs terraform plan in dir and summarizes the pending changes by +// resource action (create/update/delete). +func (e *Engine) Plan(ctx context.Context, dir string) (PlanSummary, error) { + tf, err := e.newRunner(dir) + if err != nil { + return PlanSummary{}, err + } + planFile := filepath.Join(dir, "tfplan") + defer func() { _ = os.Remove(planFile) }() + + changes, err := tf.Plan(ctx, tfexec.Out(planFile)) + if err != nil { + return PlanSummary{}, fmt.Errorf("terraform plan failed: %w", err) + } + if !changes { + return PlanSummary{}, nil + } + plan, err := tf.ShowPlanFile(ctx, planFile) + if err != nil { + return PlanSummary{}, fmt.Errorf("terraform show failed: %w", err) + } + var summary PlanSummary + for _, rc := range plan.ResourceChanges { + switch { + case rc.Change.Actions.Create(): + summary.Add++ + case rc.Change.Actions.Update(): + summary.Change++ + case rc.Change.Actions.Delete(): + summary.Destroy++ + case rc.Change.Actions.Replace(): + summary.Add++ + summary.Destroy++ + } + } + return summary, nil +} + +// Outputs returns the root-module outputs of dir as raw JSON values. +func (e *Engine) Outputs(ctx context.Context, dir string) (map[string]json.RawMessage, error) { + tf, err := e.newRunner(dir) + if err != nil { + return nil, err + } + metas, err := tf.Output(ctx) + if err != nil { + return nil, fmt.Errorf("terraform output failed: %w", err) + } + out := make(map[string]json.RawMessage, len(metas)) + for k, v := range metas { + out[k] = v.Value + } + return out, nil +} + +// StringOutput decodes a string-typed output value. +func StringOutput(outputs map[string]json.RawMessage, key string) (string, error) { + raw, ok := outputs[key] + if !ok { + return "", fmt.Errorf("terraform output %q missing", key) + } + var s string + if err := json.Unmarshal(raw, &s); err != nil { + return "", fmt.Errorf("terraform output %q is not a string: %w", key, err) + } + return s, nil +} diff --git a/internal/cluster/providers/terraform/engine_test.go b/internal/cluster/providers/terraform/engine_test.go new file mode 100644 index 00000000..6818a2f6 --- /dev/null +++ b/internal/cluster/providers/terraform/engine_test.go @@ -0,0 +1,178 @@ +package terraform + +import ( + "context" + "encoding/json" + "errors" + "io" + "testing" + + "github.com/hashicorp/terraform-exec/tfexec" + tfjson "github.com/hashicorp/terraform-json" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// fakeRunner records calls and returns canned results. +type fakeRunner struct { + calls []string + initErr error + apply error + outputs map[string]tfexec.OutputMeta + planChanges bool + plan *tfjson.Plan + applyJSON string // lines streamed into the writer by ApplyJSON +} + +func (f *fakeRunner) Init(ctx context.Context, opts ...tfexec.InitOption) error { + f.calls = append(f.calls, "init") + return f.initErr +} + +func (f *fakeRunner) Apply(ctx context.Context, opts ...tfexec.ApplyOption) error { + f.calls = append(f.calls, "apply") + return f.apply +} + +func (f *fakeRunner) ApplyJSON(ctx context.Context, w io.Writer, opts ...tfexec.ApplyOption) error { + f.calls = append(f.calls, "apply") + if f.applyJSON != "" { + _, _ = w.Write([]byte(f.applyJSON)) + } + return f.apply +} + +func (f *fakeRunner) Destroy(ctx context.Context, opts ...tfexec.DestroyOption) error { + f.calls = append(f.calls, "destroy") + return nil +} + +func (f *fakeRunner) DestroyJSON(ctx context.Context, w io.Writer, opts ...tfexec.DestroyOption) error { + f.calls = append(f.calls, "destroy") + return nil +} + +func (f *fakeRunner) Plan(ctx context.Context, opts ...tfexec.PlanOption) (bool, error) { + f.calls = append(f.calls, "plan") + return f.planChanges, nil +} + +func (f *fakeRunner) ShowPlanFile(ctx context.Context, planPath string, opts ...tfexec.ShowOption) (*tfjson.Plan, error) { + f.calls = append(f.calls, "show") + return f.plan, nil +} + +func (f *fakeRunner) Output(ctx context.Context, opts ...tfexec.OutputOption) (map[string]tfexec.OutputMeta, error) { + f.calls = append(f.calls, "output") + return f.outputs, nil +} + +func engineWith(f *fakeRunner) *Engine { + return NewEngineWithRunner(func(workdir string) (Runner, error) { return f, nil }) +} + +func TestEngine_LifecycleCalls(t *testing.T) { + f := &fakeRunner{outputs: map[string]tfexec.OutputMeta{ + "cluster_endpoint": {Value: json.RawMessage(`"https://example.eks"`)}, + }} + e := engineWith(f) + ctx := context.Background() + + require.NoError(t, e.Init(ctx, "dir")) + require.NoError(t, e.Apply(ctx, "dir")) + changes, err := e.Plan(ctx, t.TempDir()) + require.NoError(t, err) + assert.False(t, changes.HasChanges(), "planChanges=false must summarize to no changes") + require.NoError(t, e.Destroy(ctx, "dir")) + + outputs, err := e.Outputs(ctx, "dir") + require.NoError(t, err) + endpoint, err := StringOutput(outputs, "cluster_endpoint") + require.NoError(t, err) + assert.Equal(t, "https://example.eks", endpoint) + + assert.Equal(t, []string{"init", "apply", "plan", "destroy", "output"}, f.calls) +} + +// action builds a tfjson resource change with the given actions. +func action(actions ...tfjson.Action) *tfjson.ResourceChange { + return &tfjson.ResourceChange{Change: &tfjson.Change{Actions: actions}} +} + +func TestEngine_PlanSummaryCountsActions(t *testing.T) { + f := &fakeRunner{ + planChanges: true, + plan: &tfjson.Plan{ResourceChanges: []*tfjson.ResourceChange{ + action(tfjson.ActionCreate), + action(tfjson.ActionCreate), + action(tfjson.ActionUpdate), + action(tfjson.ActionDelete), + action(tfjson.ActionDelete, tfjson.ActionCreate), // replace + }}, + } + summary, err := engineWith(f).Plan(context.Background(), t.TempDir()) + require.NoError(t, err) + assert.Equal(t, PlanSummary{Add: 3, Change: 1, Destroy: 2}, summary) + assert.True(t, summary.HasChanges()) + assert.Contains(t, f.calls, "show") +} + +func TestProgressLine(t *testing.T) { + cases := []struct { + name string + line string + verbose bool + want string + ok bool + }{ + {"apply start shown", `{"@message":"aws_eks_cluster.this: Creating...","type":"apply_start"}`, false, "aws_eks_cluster.this: Creating...", true}, + {"apply complete shown", `{"@message":"aws_eks_cluster.this: Creation complete after 9m2s","type":"apply_complete"}`, false, "aws_eks_cluster.this: Creation complete after 9m2s", true}, + {"change summary shown", `{"@message":"Apply complete! Resources: 47 added.","type":"change_summary"}`, false, "Apply complete! Resources: 47 added.", true}, + {"progress ticks dropped", `{"@message":"still creating...","type":"apply_progress"}`, false, "", false}, + {"refresh noise dropped", `{"@message":"Refreshing state...","type":"refresh_start"}`, false, "", false}, + {"error diagnostics shown", `{"@level":"error","@message":"Error: quota exceeded","type":"diagnostic"}`, false, "Error: quota exceeded", true}, + {"warning diagnostics dropped", `{"@level":"warn","@message":"deprecation","type":"diagnostic"}`, false, "", false}, + {"verbose forwards everything", `{"@message":"still creating...","type":"apply_progress"}`, true, "still creating...", true}, + {"garbage dropped", `not json`, false, "", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, ok := progressLine([]byte(tc.line), tc.verbose) + assert.Equal(t, tc.ok, ok) + if tc.ok { + assert.Equal(t, tc.want, got) + } + }) + } +} + +func TestProgressWriter_HandlesPartialLines(t *testing.T) { + w := newProgressWriter(false).(*progressWriter) + line := `{"@message":"done","type":"apply_complete"}` + "\n" + half := len(line) / 2 + + n, err := w.Write([]byte(line[:half])) + require.NoError(t, err) + assert.Equal(t, half, n) + n, err = w.Write([]byte(line[half:])) + require.NoError(t, err) + assert.Equal(t, len(line)-half, n) + assert.Zero(t, w.buf.Len(), "a completed line must be fully consumed") +} + +func TestEngine_WrapsErrors(t *testing.T) { + f := &fakeRunner{initErr: errors.New("boom")} + err := engineWith(f).Init(context.Background(), "dir") + require.Error(t, err) + assert.Contains(t, err.Error(), "terraform init failed") +} + +func TestStringOutput_Missing(t *testing.T) { + _, err := StringOutput(map[string]json.RawMessage{}, "nope") + assert.ErrorContains(t, err, `terraform output "nope" missing`) +} + +func TestStringOutput_WrongType(t *testing.T) { + _, err := StringOutput(map[string]json.RawMessage{"n": json.RawMessage(`42`)}, "n") + assert.ErrorContains(t, err, "is not a string") +} diff --git a/internal/cluster/providers/terraform/progress.go b/internal/cluster/providers/terraform/progress.go new file mode 100644 index 00000000..7d10d1b1 --- /dev/null +++ b/internal/cluster/providers/terraform/progress.go @@ -0,0 +1,69 @@ +package terraform + +import ( + "bytes" + "encoding/json" + "io" + + "github.com/pterm/pterm" +) + +// Terraform's -json output is one JSON object per line (machine-readable UI +// protocol). progressWriter turns the interesting ones into short pterm lines +// so long applies show per-resource progress; pterm printers respect --silent. + +// applyEvent is the subset of the terraform JSON-UI schema the writer reads. +type applyEvent struct { + Level string `json:"@level"` + Message string `json:"@message"` + Type string `json:"type"` +} + +// progressLine converts one JSON event line into a display string; ok=false +// means the event is noise (refresh/progress ticks) and prints nothing. +// Verbose forwards every event message instead. +func progressLine(line []byte, verbose bool) (string, bool) { + var ev applyEvent + if err := json.Unmarshal(line, &ev); err != nil { + return "", false // not an event line (e.g. blank) — drop + } + if verbose { + return ev.Message, ev.Message != "" + } + switch ev.Type { + case "apply_start", "apply_complete", "apply_errored", "change_summary": + return ev.Message, ev.Message != "" + case "diagnostic": + // Errors surface through the returned error too, but printing them in + // stream order preserves the context of what was being created. + return ev.Message, ev.Level == "error" && ev.Message != "" + default: + return "", false + } +} + +// progressWriter buffers partial writes into lines and prints each event. +type progressWriter struct { + verbose bool + buf bytes.Buffer +} + +func newProgressWriter(verbose bool) io.Writer { + return &progressWriter{verbose: verbose} +} + +func (w *progressWriter) Write(p []byte) (int, error) { + w.buf.Write(p) + for { + line, err := w.buf.ReadBytes('\n') + if err != nil { + // No full line yet — keep the partial for the next Write. + w.buf.Write(line) + break + } + if msg, ok := progressLine(bytes.TrimSpace(line), w.verbose); ok { + pterm.DefaultBasicText.Printf(" %s\n", msg) + } + } + return len(p), nil +} diff --git a/internal/cluster/providers/terraform/workspace.go b/internal/cluster/providers/terraform/workspace.go new file mode 100644 index 00000000..19e1487c --- /dev/null +++ b/internal/cluster/providers/terraform/workspace.go @@ -0,0 +1,203 @@ +// Package terraform is the provider-neutral engine behind the cloud cluster +// backends (EKS now, GKE later). It owns the per-cluster workspace layout +// under ~/.openframe/clusters//: +// +// cluster.json — registry record (type, status, region, outputs) +// terraform/main.tf — generated root module (embedded template) +// terraform/terraform.tfvars.json +// terraform/terraform.tfstate — local state (the cluster's source of truth) +// +// The workspace is deliberately never deleted on a failed apply: the state +// file is the only reliable pointer to partially-created (billed!) cloud +// resources. Only a successful destroy removes it. +package terraform + +import ( + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "time" + + "github.com/flamingo-stack/openframe-cli/internal/cluster/models" +) + +// Status is the lifecycle state recorded in cluster.json. +type Status string + +const ( + StatusCreating Status = "creating" + StatusReady Status = "ready" + StatusFailed Status = "failed" +) + +// Record is the persisted registry entry for one cloud cluster. Endpoint and +// CACert are captured from terraform outputs after a successful apply so that +// status/kubeconfig operations never need to run terraform again. +type Record struct { + Name string `json:"name"` + Type models.ClusterType `json:"type"` + Status Status `json:"status"` + Region string `json:"region"` + Profile string `json:"profile,omitempty"` // AWS + Project string `json:"project,omitempty"` // GCP + K8sVersion string `json:"k8s_version,omitempty"` + NodeCount int `json:"node_count"` + CreatedAt time.Time `json:"created_at"` + Endpoint string `json:"endpoint,omitempty"` + CACert string `json:"ca_cert,omitempty"` // base64, as EKS emits it +} + +const recordFile = "cluster.json" + +// Workspace is one cluster's directory under the registry base. +type Workspace struct { + dir string +} + +// DefaultBaseDir returns ~/.openframe/clusters, or $OPENFRAME_CLUSTERS_DIR +// when set (tests and non-standard homes). +func DefaultBaseDir() (string, error) { + if dir := os.Getenv("OPENFRAME_CLUSTERS_DIR"); dir != "" { + return dir, nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("resolving home directory: %w", err) + } + return filepath.Join(home, ".openframe", "clusters"), nil +} + +// OpenWorkspace addresses (without creating) the workspace for name. +func OpenWorkspace(base, name string) *Workspace { + return &Workspace{dir: filepath.Join(base, name)} +} + +func (w *Workspace) Dir() string { return w.dir } +func (w *Workspace) TerraformDir() string { return filepath.Join(w.dir, "terraform") } + +// Exists reports whether the workspace has a registry record on disk. +func (w *Workspace) Exists() bool { + _, err := os.Stat(filepath.Join(w.dir, recordFile)) + return err == nil +} + +// WriteModule writes a generated root module (main.tf + terraform.tfvars.json) +// into dir. Used by Scaffold for real workspaces and by plan previews for +// throwaway directories. +func WriteModule(dir string, mainTF []byte, tfvars any) error { + if err := os.MkdirAll(dir, 0o750); err != nil { + return fmt.Errorf("creating module dir %s: %w", dir, err) + } + varsJSON, err := json.MarshalIndent(tfvars, "", " ") + if err != nil { + return fmt.Errorf("encoding tfvars: %w", err) + } + if err := os.WriteFile(filepath.Join(dir, "main.tf"), mainTF, 0o600); err != nil { + return err + } + return os.WriteFile(filepath.Join(dir, "terraform.tfvars.json"), varsJSON, 0o600) +} + +// Scaffold creates the workspace directories and writes the generated root +// module, tfvars, and the initial record. It refuses to overwrite an existing +// terraform state (a partially-created cluster must be resumed or destroyed, +// never silently re-scaffolded over). +func (w *Workspace) Scaffold(record Record, mainTF []byte, tfvars any) error { + if err := WriteModule(w.TerraformDir(), mainTF, tfvars); err != nil { + return err + } + return w.WriteRecord(record) +} + +// ReadRecord loads cluster.json; a missing record maps to fs.ErrNotExist. +func (w *Workspace) ReadRecord() (Record, error) { + data, err := os.ReadFile(filepath.Join(w.dir, recordFile)) // #nosec G304 -- path is CLI-managed under ~/.openframe + if err != nil { + return Record{}, err + } + var r Record + if err := json.Unmarshal(data, &r); err != nil { + return Record{}, fmt.Errorf("corrupt %s in %s: %w", recordFile, w.dir, err) + } + return r, nil +} + +// WriteRecord persists cluster.json atomically enough for a single-user CLI. +func (w *Workspace) WriteRecord(r Record) error { + data, err := json.MarshalIndent(r, "", " ") + if err != nil { + return err + } + return os.WriteFile(filepath.Join(w.dir, recordFile), data, 0o600) +} + +// SetStatus updates only the lifecycle status in the record. +func (w *Workspace) SetStatus(s Status) error { + r, err := w.ReadRecord() + if err != nil { + return err + } + r.Status = s + return w.WriteRecord(r) +} + +// Remove deletes the whole workspace. Callers must only invoke it after a +// successful terraform destroy — the state file inside is the only pointer to +// live cloud resources. +func (w *Workspace) Remove() error { + return os.RemoveAll(w.dir) +} + +// Registry lists the cloud-cluster workspaces under a base directory. +type Registry struct { + base string +} + +func NewRegistry(base string) *Registry { return &Registry{base: base} } + +// DefaultRegistry opens the registry at ~/.openframe/clusters. +func DefaultRegistry() (*Registry, error) { + base, err := DefaultBaseDir() + if err != nil { + return nil, err + } + return NewRegistry(base), nil +} + +func (r *Registry) Workspace(name string) *Workspace { return OpenWorkspace(r.base, name) } + +// List returns the records of every workspace with a readable cluster.json. +// A missing base directory is an empty registry, not an error. +func (r *Registry) List() ([]Record, error) { + entries, err := os.ReadDir(r.base) + if errors.Is(err, fs.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("reading cluster registry %s: %w", r.base, err) + } + var records []Record + for _, e := range entries { + if !e.IsDir() { + continue + } + rec, err := OpenWorkspace(r.base, e.Name()).ReadRecord() + if err != nil { + continue // not a cluster workspace (or unreadable) — skip, don't fail the listing + } + records = append(records, rec) + } + return records, nil +} + +// Get returns the record for name, or models.ErrClusterNotFound. +func (r *Registry) Get(name string) (Record, error) { + ws := r.Workspace(name) + if !ws.Exists() { + return Record{}, models.NewClusterNotFoundError(name) + } + return ws.ReadRecord() +} diff --git a/internal/cluster/providers/terraform/workspace_test.go b/internal/cluster/providers/terraform/workspace_test.go new file mode 100644 index 00000000..aa4e0175 --- /dev/null +++ b/internal/cluster/providers/terraform/workspace_test.go @@ -0,0 +1,95 @@ +package terraform + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + "time" + + "github.com/flamingo-stack/openframe-cli/internal/cluster/models" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func testRecord(name string) Record { + return Record{ + Name: name, + Type: models.ClusterTypeEKS, + Status: StatusCreating, + Region: "us-east-1", + NodeCount: 3, + CreatedAt: time.Date(2026, 7, 15, 0, 0, 0, 0, time.UTC), + } +} + +func TestWorkspace_ScaffoldAndReadBack(t *testing.T) { + base := t.TempDir() + ws := OpenWorkspace(base, "demo") + + require.False(t, ws.Exists()) + require.NoError(t, ws.Scaffold(testRecord("demo"), []byte("# tf"), map[string]any{"region": "us-east-1"})) + require.True(t, ws.Exists()) + + rec, err := ws.ReadRecord() + require.NoError(t, err) + assert.Equal(t, "demo", rec.Name) + assert.Equal(t, StatusCreating, rec.Status) + + mainTF, err := os.ReadFile(filepath.Join(ws.TerraformDir(), "main.tf")) + require.NoError(t, err) + assert.Equal(t, "# tf", string(mainTF)) + + var vars map[string]any + data, err := os.ReadFile(filepath.Join(ws.TerraformDir(), "terraform.tfvars.json")) + require.NoError(t, err) + require.NoError(t, json.Unmarshal(data, &vars)) + assert.Equal(t, "us-east-1", vars["region"]) +} + +func TestWorkspace_SetStatus(t *testing.T) { + ws := OpenWorkspace(t.TempDir(), "demo") + require.NoError(t, ws.Scaffold(testRecord("demo"), nil, nil)) + + require.NoError(t, ws.SetStatus(StatusReady)) + rec, err := ws.ReadRecord() + require.NoError(t, err) + assert.Equal(t, StatusReady, rec.Status) +} + +func TestWorkspace_Remove(t *testing.T) { + base := t.TempDir() + ws := OpenWorkspace(base, "demo") + require.NoError(t, ws.Scaffold(testRecord("demo"), nil, nil)) + + require.NoError(t, ws.Remove()) + assert.False(t, ws.Exists()) +} + +func TestRegistry_ListSkipsForeignDirs(t *testing.T) { + base := t.TempDir() + reg := NewRegistry(base) + + require.NoError(t, reg.Workspace("one").Scaffold(testRecord("one"), nil, nil)) + require.NoError(t, reg.Workspace("two").Scaffold(testRecord("two"), nil, nil)) + // A directory without cluster.json must not break or pollute the listing. + require.NoError(t, os.MkdirAll(filepath.Join(base, "not-a-cluster"), 0o750)) + + records, err := reg.List() + require.NoError(t, err) + assert.Len(t, records, 2) +} + +func TestRegistry_EmptyBaseIsEmptyRegistry(t *testing.T) { + reg := NewRegistry(filepath.Join(t.TempDir(), "does-not-exist")) + records, err := reg.List() + require.NoError(t, err) + assert.Empty(t, records) +} + +func TestRegistry_GetMissingIsClusterNotFound(t *testing.T) { + reg := NewRegistry(t.TempDir()) + _, err := reg.Get("ghost") + var notFound models.ErrClusterNotFound + assert.ErrorAs(t, err, ¬Found) +} diff --git a/internal/cluster/service.go b/internal/cluster/service.go index 5541aead..2683538f 100644 --- a/internal/cluster/service.go +++ b/internal/cluster/service.go @@ -11,11 +11,9 @@ import ( "github.com/flamingo-stack/openframe-cli/internal/cluster/models" "github.com/flamingo-stack/openframe-cli/internal/cluster/prerequisites" "github.com/flamingo-stack/openframe-cli/internal/cluster/provider" - "github.com/flamingo-stack/openframe-cli/internal/cluster/providers/k3d" uiCluster "github.com/flamingo-stack/openframe-cli/internal/cluster/ui" "github.com/flamingo-stack/openframe-cli/internal/k8s" "github.com/flamingo-stack/openframe-cli/internal/platform" - sharedconfig "github.com/flamingo-stack/openframe-cli/internal/shared/config" "github.com/flamingo-stack/openframe-cli/internal/shared/executor" "github.com/flamingo-stack/openframe-cli/internal/shared/ui" "github.com/flamingo-stack/openframe-cli/internal/shared/ui/spinner" @@ -26,6 +24,12 @@ import ( "k8s.io/client-go/rest" ) +// bootstrapNodeCount is the k3d node count for `openframe bootstrap` — one +// more than `cluster create`'s default of 3 because bootstrap immediately +// installs the full platform. Kept as a named constant so the discrepancy is +// a decision, not an accident (audit follow-up). +const bootstrapNodeCount = 4 + // ApplicationCleaner removes the ArgoCD Application CRs that own the platform // workloads, and strips the resources-finalizer from any left in Terminating. // @@ -73,7 +77,7 @@ func isTerminalEnvironment() bool { // NewClusterService creates a new cluster service with default configuration func NewClusterService(exec executor.CommandExecutor) *ClusterService { - manager := k3d.CreateClusterManagerWithExecutor(exec) + manager, _ := provider.New(models.ClusterTypeK3d, exec) // k3d never fails to construct return &ClusterService{ manager: manager, executor: exec, @@ -83,7 +87,7 @@ func NewClusterService(exec executor.CommandExecutor) *ClusterService { // NewClusterServiceSuppressed creates a cluster service with UI suppression func NewClusterServiceSuppressed(exec executor.CommandExecutor) *ClusterService { - manager := k3d.CreateClusterManagerWithExecutor(exec) + manager, _ := provider.New(models.ClusterTypeK3d, exec) // k3d never fails to construct return &ClusterService{ manager: manager, executor: exec, @@ -91,11 +95,29 @@ func NewClusterServiceSuppressed(exec executor.CommandExecutor) *ClusterService } } +// providerFor resolves the backend for a cluster type. The k3d manager is the +// service default (list/status/cleanup are k3d-scoped until cloud backends +// land); anything else goes through the factory, which today yields +// ErrProviderNotFound for the recognized cloud types. +func (s *ClusterService) providerFor(clusterType models.ClusterType) (provider.Provider, error) { + if clusterType == models.ClusterTypeK3d || clusterType == "" { + return s.manager, nil + } + return provider.New(clusterType, s.executor) +} + // CreateCluster handles cluster creation operations // Returns the *rest.Config for the created cluster that can be used to interact with it func (s *ClusterService) CreateCluster(ctx context.Context, config models.ClusterConfig) (*rest.Config, error) { + // Resolve the backend first: an unsupported type must fail here, before any + // k3d-specific existence checks run. + mgr, err := s.providerFor(config.Type) + if err != nil { + return nil, err + } + // Check if cluster already exists - if existingInfo, err := s.manager.GetClusterStatus(ctx, config.Name); err == nil { + if existingInfo, err := mgr.GetClusterStatus(ctx, config.Name); err == nil { // Cluster already exists - show friendly message // Show warning for existing cluster @@ -130,24 +152,25 @@ func (s *ClusterService) CreateCluster(ctx context.Context, config models.Cluste } // Return the rest.Config for the existing cluster - restConfig, err := s.manager.GetRestConfig(ctx, config.Name) + restConfig, err := mgr.GetRestConfig(ctx, config.Name) if err != nil { return nil, fmt.Errorf("cluster exists but failed to get REST config: %w", err) } return restConfig, nil // Exit gracefully without error } - // Cluster doesn't exist, proceed with creation + // Cluster doesn't exist, proceed with creation. Cloud creates stream + // per-resource progress lines from the terraform engine, which a spinner's + // redraws would garble — so the spinner is k3d-only. var sp *spinner.Spinner - if !s.suppressUI { + if !s.suppressUI && config.Type == models.ClusterTypeK3d { sp = spinner.New() sp.Start(fmt.Sprintf("Creating %s cluster '%s'...", config.Type, config.Name)) } else { - // In non-interactive mode, just show a simple info message pterm.Info.Printf("Creating %s cluster '%s'...\n", config.Type, config.Name) } - restConfig, err := s.manager.CreateCluster(ctx, config) + restConfig, err := mgr.CreateCluster(ctx, config) if err != nil { if sp != nil { sp.Fail(fmt.Sprintf("Failed to create cluster '%s'", config.Name)) @@ -162,7 +185,7 @@ func (s *ClusterService) CreateCluster(ctx context.Context, config models.Cluste } // Get and display cluster status - if clusterInfo, statusErr := s.manager.GetClusterStatus(ctx, config.Name); statusErr == nil { + if clusterInfo, statusErr := mgr.GetClusterStatus(ctx, config.Name); statusErr == nil { s.displayClusterCreationSummary(clusterInfo) } @@ -174,16 +197,22 @@ func (s *ClusterService) CreateCluster(ctx context.Context, config models.Cluste // DeleteCluster handles cluster deletion business logic func (s *ClusterService) DeleteCluster(ctx context.Context, name string, clusterType models.ClusterType, force bool) error { - // Show deletion progress + mgr, err := s.providerFor(clusterType) + if err != nil { + return err + } + + // Show deletion progress. Cloud destroys stream terraform progress lines, + // so the spinner is k3d-only (same reasoning as CreateCluster). var sp *spinner.Spinner - if !s.suppressUI { + if !s.suppressUI && clusterType == models.ClusterTypeK3d { sp = spinner.New() sp.Start(fmt.Sprintf("Deleting %s cluster '%s'...", clusterType, name)) } else { pterm.Info.Printf("Deleting %s cluster '%s'...\n", clusterType, name) } - err := s.manager.DeleteCluster(ctx, name, clusterType, force) + err = mgr.DeleteCluster(ctx, name, clusterType, force) if err != nil { if sp != nil { sp.Fail(fmt.Sprintf("Failed to delete cluster '%s'", name)) @@ -200,27 +229,69 @@ func (s *ClusterService) DeleteCluster(ctx context.Context, name string, cluster return nil } -// ListClusters handles cluster listing business logic +// cloudProviders returns the cloud backends (EKS, GKE). Their registries are +// plain files under ~/.openframe, so construction practically never fails; a +// failed one is skipped, degrading the CLI to reduced visibility rather than +// blocking local operations. +func (s *ClusterService) cloudProviders() []provider.Provider { + var providers []provider.Provider + for _, t := range []models.ClusterType{models.ClusterTypeEKS, models.ClusterTypeGKE} { + if p, err := s.providerFor(t); err == nil { + providers = append(providers, p) + } + } + return providers +} + +// ListClusters merges the local k3d clusters with the cloud clusters recorded +// in the workspace registry. func (s *ClusterService) ListClusters() ([]models.ClusterInfo, error) { ctx := context.Background() - return s.manager.ListAllClusters(ctx) + clusters, err := s.manager.ListAllClusters(ctx) + if err != nil { + return nil, err + } + for _, cloud := range s.cloudProviders() { + cloudClusters, err := cloud.ListAllClusters(ctx) + if err != nil { + return nil, err + } + clusters = append(clusters, cloudClusters...) + } + return clusters, nil } // GetClusterStatus handles cluster status business logic func (s *ClusterService) GetClusterStatus(name string) (models.ClusterInfo, error) { ctx := context.Background() + for _, cloud := range s.cloudProviders() { + if info, err := cloud.GetClusterStatus(ctx, name); err == nil { + return info, nil + } + } return s.manager.GetClusterStatus(ctx, name) } // GetRestConfig returns the rest.Config for an existing cluster func (s *ClusterService) GetRestConfig(name string) (*rest.Config, error) { ctx := context.Background() + for _, cloud := range s.cloudProviders() { + if _, err := cloud.DetectClusterType(ctx, name); err == nil { + return cloud.GetRestConfig(ctx, name) + } + } return s.manager.GetRestConfig(ctx, name) } -// DetectClusterType handles cluster type detection business logic +// DetectClusterType consults the cloud registry first (a cheap local file +// read), then falls back to k3d discovery. func (s *ClusterService) DetectClusterType(name string) (models.ClusterType, error) { ctx := context.Background() + for _, cloud := range s.cloudProviders() { + if t, err := cloud.DetectClusterType(ctx, name); err == nil { + return t, nil + } + } return s.manager.DetectClusterType(ctx, name) } @@ -231,6 +302,8 @@ func (s *ClusterService) CleanupCluster(ctx context.Context, name string, cluste switch clusterType { case models.ClusterTypeK3d: return s.cleanupK3dCluster(ctx, name, verbose, force) + case models.ClusterTypeEKS, models.ClusterTypeGKE: + return models.CleanupResult{}, fmt.Errorf("cleanup is not supported for cloud clusters; use 'openframe cluster delete %s' to tear the cluster down", name) default: return models.CleanupResult{}, fmt.Errorf("cleanup not supported for cluster type: %s", clusterType) } @@ -434,11 +507,14 @@ func (s *ClusterService) cleanupKubernetesResources(ctx context.Context, cluster return 0, err } + // TLS policy is the provider's mint-time decision: k3d marks its local + // rest.Config insecure itself (verify.go), and a future cloud provider's + // config must NOT be downgraded here. restConfig, err := s.manager.GetRestConfig(ctx, clusterName) if err != nil { return 0, fmt.Errorf("failed to get cluster config for cleanup: %w", err) } - client, err := kubernetes.NewForConfig(sharedconfig.ApplyInsecureTLSConfig(restConfig)) + client, err := kubernetes.NewForConfig(restConfig) if err != nil { return 0, fmt.Errorf("failed to create kubernetes client: %w", err) } @@ -893,12 +969,15 @@ func CreateClusterWithPrerequisitesNonInteractive(ctx context.Context, clusterNa service = NewClusterService(exec) } - // Build cluster configuration + // Build cluster configuration. Bootstrap deliberately uses one node MORE + // than `cluster create`'s default of 3: it immediately installs the full + // platform (17 ArgoCD apps), which needs the extra headroom — a bare + // cluster does not. config := models.ClusterConfig{ Name: clusterName, Type: models.ClusterTypeK3d, K8sVersion: "", - NodeCount: 4, + NodeCount: bootstrapNodeCount, } if clusterName == "" { config.Name = "openframe-dev" // default name diff --git a/internal/cluster/service_test.go b/internal/cluster/service_test.go index 7e540f9d..49189a94 100644 --- a/internal/cluster/service_test.go +++ b/internal/cluster/service_test.go @@ -2,9 +2,12 @@ package cluster import ( "context" + "errors" + "os" "testing" "github.com/flamingo-stack/openframe-cli/internal/cluster/models" + tfengine "github.com/flamingo-stack/openframe-cli/internal/cluster/providers/terraform" "github.com/flamingo-stack/openframe-cli/internal/shared/executor" ) @@ -58,6 +61,83 @@ func TestClusterService_CreateCluster(t *testing.T) { _ = err } +func TestClusterService_CreateCluster_CloudWithoutRegionFailsBeforeAnyCommand(t *testing.T) { + t.Setenv("OPENFRAME_CLUSTERS_DIR", t.TempDir()) + for _, clusterType := range []models.ClusterType{models.ClusterTypeEKS, models.ClusterTypeGKE} { + mock := executor.NewMockCommandExecutor() + service := NewClusterService(mock) + + _, err := service.CreateCluster(context.Background(), models.ClusterConfig{ + Name: "cloud-cluster", + Type: clusterType, + NodeCount: 1, + }) + + var invalid models.ErrInvalidClusterConfig + if !errors.As(err, &invalid) { + t.Fatalf("expected ErrInvalidClusterConfig for %s without region, got %v", clusterType, err) + } + if mock.GetCommandCount() != 0 { + t.Errorf("no commands should run before validation passes, got: %v", mock.GetExecutedCommands()) + } + } +} + +func TestClusterService_DeleteCluster_UnknownCloudClusterIsNotFound(t *testing.T) { + t.Setenv("OPENFRAME_CLUSTERS_DIR", t.TempDir()) + for _, clusterType := range []models.ClusterType{models.ClusterTypeEKS, models.ClusterTypeGKE} { + mock := executor.NewMockCommandExecutor() + service := NewClusterService(mock) + + err := service.DeleteCluster(context.Background(), "cloud-cluster", clusterType, false) + + var notFound models.ErrClusterNotFound + if !errors.As(err, ¬Found) { + t.Fatalf("expected ErrClusterNotFound for %s, got %v", clusterType, err) + } + if mock.GetCommandCount() != 0 { + t.Errorf("no commands should run for a missing cluster, got: %v", mock.GetExecutedCommands()) + } + } +} + +func TestClusterService_ListClusters_MergesCloudRegistry(t *testing.T) { + t.Setenv("OPENFRAME_CLUSTERS_DIR", t.TempDir()) + service := NewClusterService(createTestExecutor()) + + clusters, err := service.ListClusters() + if err != nil { + t.Fatalf("ListClusters: %v", err) + } + baseline := len(clusters) + + // Drop a cloud record into the registry and expect it to appear. + reg := tfengine.NewRegistry(os.Getenv("OPENFRAME_CLUSTERS_DIR")) + record := tfengine.Record{ + Name: "cloudy", + Type: models.ClusterTypeEKS, + Status: tfengine.StatusReady, + Region: "us-east-1", + NodeCount: 3, + } + if err := reg.Workspace("cloudy").Scaffold(record, nil, nil); err != nil { + t.Fatal(err) + } + + clusters, err = service.ListClusters() + if err != nil { + t.Fatalf("ListClusters: %v", err) + } + if len(clusters) != baseline+1 { + t.Fatalf("expected %d clusters after adding a cloud record, got %d", baseline+1, len(clusters)) + } + + clusterType, err := service.DetectClusterType("cloudy") + if err != nil || clusterType != models.ClusterTypeEKS { + t.Fatalf("expected eks for cloudy, got %s / %v", clusterType, err) + } +} + func TestClusterService_DeleteCluster(t *testing.T) { exec := createTestExecutor() service := NewClusterService(exec) diff --git a/internal/cluster/types_test.go b/internal/cluster/types_test.go index 22d2fc92..c2073941 100644 --- a/internal/cluster/types_test.go +++ b/internal/cluster/types_test.go @@ -156,21 +156,6 @@ func TestNodeInfo(t *testing.T) { }) } -func TestProviderOptions(t *testing.T) { - t.Run("can create and use provider options", func(t *testing.T) { - options := models.ProviderOptions{ - K3d: &models.K3dOptions{ - PortMappings: []string{"8080:80@loadbalancer", "8443:443@loadbalancer"}, - }, - Verbose: true, - } - - assert.NotNil(t, options.K3d) - assert.Equal(t, []string{"8080:80@loadbalancer", "8443:443@loadbalancer"}, options.K3d.PortMappings) - assert.True(t, options.Verbose) - }) -} - func TestErrorTypes(t *testing.T) { t.Run("cluster not found error", func(t *testing.T) { err := models.NewClusterNotFoundError("test-cluster") diff --git a/internal/cluster/ui/operations.go b/internal/cluster/ui/operations.go index e2778ede..ae213579 100644 --- a/internal/cluster/ui/operations.go +++ b/internal/cluster/ui/operations.go @@ -336,6 +336,16 @@ func (ui *OperationsUI) ShowConfigurationSummary(config models.ClusterConfig, dr if config.K8sVersion != "" { pterm.DefaultBasicText.Printf("Version: %s\n", config.K8sVersion) } + if config.Cloud != nil { + if config.Cloud.Project != "" { + pterm.DefaultBasicText.Printf("Project: %s\n", config.Cloud.Project) + } + pterm.DefaultBasicText.Printf(" Region: %s\n", config.Cloud.Region) + if config.Cloud.MachineType != "" { + pterm.DefaultBasicText.Printf("Instance: %s\n", config.Cloud.MachineType) + } + pterm.Warning.Println(CostHint(config.Type)) + } pterm.DefaultBasicText.Println() diff --git a/internal/cluster/ui/prompts.go b/internal/cluster/ui/prompts.go index 9ba27680..bf2d8f1e 100644 --- a/internal/cluster/ui/prompts.go +++ b/internal/cluster/ui/prompts.go @@ -1,8 +1,12 @@ package ui import ( + "fmt" + "strings" + "github.com/flamingo-stack/openframe-cli/internal/cluster/models" sharedUI "github.com/flamingo-stack/openframe-cli/internal/shared/ui" + "github.com/manifoldco/promptui" "github.com/pterm/pterm" ) @@ -13,7 +17,6 @@ type ClusterInfo = models.ClusterInfo // Re-export domain constants for UI convenience const ( ClusterTypeK3d = models.ClusterTypeK3d - ClusterTypeGKE = models.ClusterTypeGKE ) // UI should not depend on business logic interfaces @@ -50,3 +53,31 @@ func selectFromList(prompt string, items []string) (int, string, error) { // Use common UI function return sharedUI.SelectFromList(prompt, items) } + +// CostHint is the running-cost warning shown for cloud cluster types. The +// figures are deliberately rough baselines, not quotes. +func CostHint(clusterType models.ClusterType) string { + switch clusterType { + case models.ClusterTypeEKS: + return "This creates billed AWS resources: EKS control plane (~$73/mo), EC2 nodes, and a NAT gateway (~$33/mo + traffic)" + case models.ClusterTypeGKE: + return "This creates billed GCP resources: GKE cluster management fee (~$73/mo), VM nodes, and networking" + default: + return "Cloud clusters create resources that incur costs" + } +} + +// ConfirmTypedClusterName requires the user to re-type the cluster name +// before a cloud destroy — a stronger gate than yes/no, because the action +// deletes billed infrastructure irreversibly. +func ConfirmTypedClusterName(name string) (bool, error) { + pterm.Warning.Printf("Deleting a cloud cluster destroys all its cloud resources.\n") + prompt := promptui.Prompt{ + Label: fmt.Sprintf("Type the cluster name (%s) to confirm", name), + } + entered, err := prompt.Run() + if err != nil { + return false, err + } + return strings.TrimSpace(entered) == name, nil +} diff --git a/internal/cluster/ui/prompts_test.go b/internal/cluster/ui/prompts_test.go index af4ccf78..71614c46 100644 --- a/internal/cluster/ui/prompts_test.go +++ b/internal/cluster/ui/prompts_test.go @@ -101,6 +101,5 @@ func TestValidationLogic(t *testing.T) { func TestConstants(t *testing.T) { t.Run("cluster type constants are correctly defined", func(t *testing.T) { assert.Equal(t, string(ClusterTypeK3d), "k3d") - assert.Equal(t, string(ClusterTypeGKE), "gke") }) } diff --git a/internal/cluster/ui/wizard.go b/internal/cluster/ui/wizard.go index 3cb22612..3d025d03 100644 --- a/internal/cluster/ui/wizard.go +++ b/internal/cluster/ui/wizard.go @@ -14,6 +14,29 @@ type ClusterConfig struct { Type models.ClusterType NodeCount int K8sVersion string + // Cloud-only answers (EKS/GKE) + Region string + Project string + MachineType string +} + +// ToDomain converts the wizard answers into the domain config, attaching the +// cloud block only for cloud types. +func (c ClusterConfig) ToDomain() models.ClusterConfig { + domain := models.ClusterConfig{ + Name: c.Name, + Type: c.Type, + NodeCount: c.NodeCount, + K8sVersion: c.K8sVersion, + } + if c.Type == models.ClusterTypeEKS || c.Type == models.ClusterTypeGKE { + domain.Cloud = &models.CloudConfig{ + Region: c.Region, + Project: c.Project, + MachineType: c.MachineType, + } + } + return domain } // ConfigWizard provides interactive configuration for cluster creation @@ -63,27 +86,53 @@ func (w *ConfigWizard) Run() (ClusterConfig, error) { } w.config.Type = clusterType - // Step 3: Node count + // Step 3 (cloud only): project/region + instance type. The k3s version + // list below is meaningless for cloud clusters, whose version comes from + // the module default. + if clusterType == models.ClusterTypeEKS || clusterType == models.ClusterTypeGKE { + defaultRegion, defaultMachine := "us-east-1", "m6i.large" + if clusterType == models.ClusterTypeGKE { + defaultRegion, defaultMachine = "us-central1", "e2-standard-4" + + project, err := steps.PromptProject() + if err != nil { + return ClusterConfig{}, err + } + w.config.Project = project + } + + region, err := steps.PromptRegion(defaultRegion) + if err != nil { + return ClusterConfig{}, err + } + w.config.Region = region + + machineType, err := steps.PromptMachineType(defaultMachine) + if err != nil { + return ClusterConfig{}, err + } + w.config.MachineType = machineType + w.config.K8sVersion = "" + } + + // Step 4: Node count nodeCount, err := steps.PromptNodeCount(w.config.NodeCount) if err != nil { return ClusterConfig{}, err } w.config.NodeCount = nodeCount - // Step 4: Kubernetes version - k8sVersion, err := steps.PromptK8sVersion() - if err != nil { - return ClusterConfig{}, err + // Step 5 (k3d only): Kubernetes version + if clusterType == models.ClusterTypeK3d { + k8sVersion, err := steps.PromptK8sVersion() + if err != nil { + return ClusterConfig{}, err + } + w.config.K8sVersion = k8sVersion } - w.config.K8sVersion = k8sVersion - // Step 5: Confirmation - domainConfig := models.ClusterConfig{ - Name: w.config.Name, - Type: w.config.Type, - NodeCount: w.config.NodeCount, - K8sVersion: w.config.K8sVersion, - } + // Step 6: Confirmation + domainConfig := w.config.ToDomain() confirmed, err := steps.ConfirmConfiguration(domainConfig) if err != nil { return ClusterConfig{}, err @@ -178,11 +227,5 @@ func (h *ConfigurationHandler) getWizardConfig(clusterName string) (models.Clust return models.ClusterConfig{}, err } - // Convert wizard config to domain config - return models.ClusterConfig{ - Name: wizardConfig.Name, - Type: wizardConfig.Type, - K8sVersion: wizardConfig.K8sVersion, - NodeCount: wizardConfig.NodeCount, - }, nil + return wizardConfig.ToDomain(), nil } diff --git a/internal/cluster/ui/wizard_steps.go b/internal/cluster/ui/wizard_steps.go index b87b62fa..d78d88f6 100644 --- a/internal/cluster/ui/wizard_steps.go +++ b/internal/cluster/ui/wizard_steps.go @@ -41,11 +41,15 @@ func (ws *WizardSteps) PromptClusterName(defaultName string) (string, error) { return strings.TrimSpace(result), nil } -// PromptClusterType prompts for cluster type selection +// PromptClusterType prompts for cluster type selection. func (ws *WizardSteps) PromptClusterType() (models.ClusterType, error) { prompt := promptui.Select{ Label: "Cluster Type", - Items: []string{"k3d (Recommended for local development)", "gke (Google Kubernetes Engine - Coming Soon)"}, + Items: []string{ + "k3d (Recommended for local development)", + "eks (AWS Elastic Kubernetes Service — provisions cloud resources that cost money)", + "gke (Google Kubernetes Engine — provisions cloud resources that cost money)", + }, Templates: &promptui.SelectTemplates{ Label: "{{ . }}:", Active: "→ {{ . | cyan }}", @@ -58,17 +62,57 @@ func (ws *WizardSteps) PromptClusterType() (models.ClusterType, error) { if err != nil { return "", err } - switch idx { - case 0: - return models.ClusterTypeK3d, nil case 1: + return models.ClusterTypeEKS, nil + case 2: return models.ClusterTypeGKE, nil default: return models.ClusterTypeK3d, nil } } +// PromptProject prompts for the GCP project a GKE cluster lands in. +func (ws *WizardSteps) PromptProject() (string, error) { + prompt := promptui.Prompt{ + Label: "GCP Project", + Validate: sharedUI.ValidateNonEmpty("project"), + } + result, err := prompt.Run() + if err != nil { + return "", err + } + return strings.TrimSpace(result), nil +} + +// PromptRegion prompts for the AWS region an EKS cluster lands in. +func (ws *WizardSteps) PromptRegion(defaultRegion string) (string, error) { + prompt := promptui.Prompt{ + Label: "AWS Region", + Default: defaultRegion, + Validate: sharedUI.ValidateNonEmpty("region"), + } + result, err := prompt.Run() + if err != nil { + return "", err + } + return strings.TrimSpace(result), nil +} + +// PromptMachineType prompts for the node instance type of a cloud cluster. +func (ws *WizardSteps) PromptMachineType(defaultType string) (string, error) { + prompt := promptui.Prompt{ + Label: "Node Instance Type", + Default: defaultType, + Validate: sharedUI.ValidateNonEmpty("instance type"), + } + result, err := prompt.Run() + if err != nil { + return "", err + } + return strings.TrimSpace(result), nil +} + // PromptNodeCount prompts for number of worker nodes func (ws *WizardSteps) PromptNodeCount(defaultCount int) (int, error) { prompt := promptui.Prompt{ @@ -123,6 +167,19 @@ func (ws *WizardSteps) ConfirmConfiguration(config models.ClusterConfig) (bool, {"Node Count", strconv.Itoa(config.NodeCount)}, {"Kubernetes Version", config.K8sVersion}, } + if config.Cloud != nil { + if config.Cloud.Project != "" { + data = append(data, []string{"Project", config.Cloud.Project}) + } + data = append(data, []string{"Region", config.Cloud.Region}) + if config.Cloud.MachineType != "" { + data = append(data, []string{"Instance Type", config.Cloud.MachineType}) + } + } + + if config.Cloud != nil { + pterm.Warning.Println(CostHint(config.Type)) + } // Use pterm for consistent styling if err := renderConfigurationTable(data); err != nil { diff --git a/internal/cluster/ui/wizard_steps_test.go b/internal/cluster/ui/wizard_steps_test.go index 59508152..29a5d572 100644 --- a/internal/cluster/ui/wizard_steps_test.go +++ b/internal/cluster/ui/wizard_steps_test.go @@ -12,15 +12,6 @@ func TestWizardSteps(t *testing.T) { assert.NotNil(t, steps, "NewWizardSteps should return a non-nil instance") } -func TestWizardSteps_PromptClusterType(t *testing.T) { - steps := NewWizardSteps() - - t.Run("should have cluster type prompt", func(t *testing.T) { - // We can't easily test the interactive part, but we can test the method exists - assert.NotNil(t, steps.PromptClusterType) - }) -} - func TestWizardSteps_PromptK8sVersion(t *testing.T) { steps := NewWizardSteps() diff --git a/internal/cluster/ui/wizard_test.go b/internal/cluster/ui/wizard_test.go index e14e11ca..c2065f7f 100644 --- a/internal/cluster/ui/wizard_test.go +++ b/internal/cluster/ui/wizard_test.go @@ -40,7 +40,6 @@ func TestClusterConfig(t *testing.T) { clusterType ClusterType }{ {"k3d cluster", ClusterTypeK3d}, - {"gke cluster", ClusterTypeGKE}, } for _, tt := range tests { diff --git a/internal/platform/platform.go b/internal/platform/platform.go index 6e451dd6..b1850f1d 100644 --- a/internal/platform/platform.go +++ b/internal/platform/platform.go @@ -84,6 +84,30 @@ var toolDocs = map[string]InstallDocs{ Windows: "Certificates: Please install mkcert manually from https://github.com/FiloSottile/mkcert and run 'mkcert localhost 127.0.0.1'", Default: "Certificates: Please install mkcert from https://github.com/FiloSottile/mkcert", }, + "terraform": { + Darwin: "terraform: A verified pinned binary is installed automatically to ~/.openframe/bin, or download from https://developer.hashicorp.com/terraform/install", + Linux: "terraform: A verified pinned binary is installed automatically to ~/.openframe/bin, or download from https://developer.hashicorp.com/terraform/install", + Windows: "terraform: A verified pinned binary is installed automatically to ~/.openframe/bin, or download from https://developer.hashicorp.com/terraform/install", + Default: "terraform: Please install terraform from https://developer.hashicorp.com/terraform/install", + }, + "gcloud": { + Darwin: "gcloud: Run 'brew install --cask google-cloud-sdk' or download from https://cloud.google.com/sdk/docs/install", + Linux: "gcloud: Install from https://cloud.google.com/sdk/docs/install (distribution packages: apt/dnf repos are documented there)", + Windows: "gcloud: Download from https://cloud.google.com/sdk/docs/install", + Default: "gcloud: Please install the Google Cloud SDK from https://cloud.google.com/sdk/docs/install", + }, + "gke-gcloud-auth-plugin": { + Darwin: "gke-gcloud-auth-plugin: Run 'gcloud components install gke-gcloud-auth-plugin'", + Linux: "gke-gcloud-auth-plugin: Run 'gcloud components install gke-gcloud-auth-plugin' (or install the google-cloud-cli-gke-gcloud-auth-plugin OS package)", + Windows: "gke-gcloud-auth-plugin: Run 'gcloud components install gke-gcloud-auth-plugin'", + Default: "gke-gcloud-auth-plugin: See https://cloud.google.com/kubernetes-engine/docs/how-to/cluster-access-for-kubectl", + }, + "aws": { + Darwin: "AWS CLI: Run 'brew install awscli' or download from https://aws.amazon.com/cli/", + Linux: "AWS CLI: Install via your package manager or from https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html", + Windows: "AWS CLI: Download from https://aws.amazon.com/cli/", + Default: "AWS CLI: Please install the AWS CLI from https://aws.amazon.com/cli/", + }, } // InstallHint returns installation guidance for the named tool on the current diff --git a/internal/shared/config/transport_test.go b/internal/shared/config/transport_test.go index 38b14d0b..fd7a1745 100644 --- a/internal/shared/config/transport_test.go +++ b/internal/shared/config/transport_test.go @@ -4,6 +4,7 @@ import ( "testing" "k8s.io/client-go/rest" + clientcmdapi "k8s.io/client-go/tools/clientcmd/api" ) func TestIsLocalAPIServer(t *testing.T) { @@ -60,6 +61,33 @@ func TestApplyInsecureTLSConfig_BypassesLocalOnly(t *testing.T) { } } +// TestApplyInsecureTLSConfig_CloudExecConfigsUntouched locks the cloud-cluster +// guarantee: EKS/GKE rest.Configs (public endpoint + CA + exec-plugin auth) +// pass through the chart subsystem's "defense-in-depth" insecure wraps without +// any TLS downgrade — the local-only guard must keep covering them. +func TestApplyInsecureTLSConfig_CloudExecConfigsUntouched(t *testing.T) { + hosts := []string{ + "https://ABCDEF123.gr7.us-east-1.eks.amazonaws.com", // EKS + "https://34.10.20.30", // GKE (bare public IP) + } + for _, host := range hosts { + cfg := ApplyInsecureTLSConfig(&rest.Config{ + Host: host, + TLSClientConfig: rest.TLSClientConfig{CAData: []byte("ca")}, + ExecProvider: &clientcmdapi.ExecConfig{Command: "aws"}, + }) + if cfg.Insecure { + t.Errorf("%s: TLS must NOT be bypassed for a cloud endpoint", host) + } + if string(cfg.CAData) != "ca" { + t.Errorf("%s: CA must be preserved for a cloud endpoint", host) + } + if cfg.ExecProvider == nil { + t.Errorf("%s: exec auth must be preserved", host) + } + } +} + func TestApplyInsecureTLSConfig_NilSafe(t *testing.T) { if ApplyInsecureTLSConfig(nil) != nil { t.Error("nil config must return nil") diff --git a/internal/shared/download/pins.go b/internal/shared/download/pins.go index b713a7fb..cced3462 100644 --- a/internal/shared/download/pins.go +++ b/internal/shared/download/pins.go @@ -94,6 +94,38 @@ var Helm = PinnedTool{ }, } +// Terraform is the pinned Terraform CLI, used by the cloud cluster providers +// (EKS/GKE). Upstream: https://releases.hashicorp.com/terraform/ — assets are +// .zip archives with the bare terraform binary (terraform.exe on Windows) +// inside; SHA256 from the release's SHA256SUMS file. Unlike k3d/helm (which +// run inside WSL on Windows), terraform is pinned for Windows too: the +// provisioning engine runs it natively. +const ( + terraformVersion = "1.15.8" + terraformBaseURL = "https://releases.hashicorp.com/terraform/" + terraformVersion + "/terraform_" + terraformVersion + "_" + + terraformSHA256LinuxAMD64 = "d25ce7b6902013ad905db3d2eab0be4cd905887fe88b81a6171b8d5503c31f3d" + terraformSHA256LinuxARM64 = "8891e9dcedc9e3b8950bc6af9d4d8af1f4cfade3062f53b9dc403a89f6ce8c9c" + terraformSHA256DarwinAMD64 = "e2e812e783771159bf758fd4e55d6dc9bb08f63e2af2c63d212721807a02c5dc" + terraformSHA256DarwinARM64 = "f210110c5698b94d803a7a63cdb0251b5455c150841478808e2bbb343f95ed68" + terraformSHA256WindowsAMD64 = "2ff41d2129afb1982733c132c61a8d6ef038f879f3aeede7fc28b8b8b24acf02" + terraformSHA256WindowsARM64 = "ffd9399a37ee8123263d84ec5c00d09d7704f9997cb345d7c9cac56c3fc1348e" +) + +var Terraform = PinnedTool{ + Name: "terraform", + Version: terraformVersion, + Zip: true, + Assets: map[string]PinnedAsset{ + "linux/amd64": {URL: terraformBaseURL + "linux_amd64.zip", SHA256: terraformSHA256LinuxAMD64}, + "linux/arm64": {URL: terraformBaseURL + "linux_arm64.zip", SHA256: terraformSHA256LinuxARM64}, + "darwin/amd64": {URL: terraformBaseURL + "darwin_amd64.zip", SHA256: terraformSHA256DarwinAMD64}, + "darwin/arm64": {URL: terraformBaseURL + "darwin_arm64.zip", SHA256: terraformSHA256DarwinARM64}, + "windows/amd64": {URL: terraformBaseURL + "windows_amd64.zip", SHA256: terraformSHA256WindowsAMD64}, + "windows/arm64": {URL: terraformBaseURL + "windows_arm64.zip", SHA256: terraformSHA256WindowsARM64}, + }, +} + // UserBinDir returns the CLI-managed bin directory (~/.openframe/bin) where // verified tool binaries are installed. It does not create the directory. func UserBinDir() (string, error) { @@ -116,7 +148,7 @@ func (d Downloader) InstallPinnedTool(ctx context.Context, tool PinnedTool, binD if err := os.MkdirAll(binDir, 0o750); err != nil { return "", fmt.Errorf("creating %s: %w", binDir, err) } - dest := filepath.Join(binDir, tool.Name) + dest := filepath.Join(binDir, exeName(tool.Name)) if tool.Tarball { member := fmt.Sprintf("%s-%s/%s", runtime.GOOS, runtime.GOARCH, tool.Name) if err := d.InstallVerifiedTarGz(ctx, asset, member, dest, 0o750); err != nil { @@ -124,12 +156,28 @@ func (d Downloader) InstallPinnedTool(ctx context.Context, tool PinnedTool, binD } return dest, nil } + if tool.Zip { + if err := d.InstallVerifiedZipMember(ctx, asset, exeName(tool.Name), dest, 0o750); err != nil { + return "", err + } + return dest, nil + } if err := d.InstallVerified(ctx, asset, dest, 0o750); err != nil { return "", err } return dest, nil } +// exeName appends the Windows executable suffix where the OS requires it — +// archive members and installed binaries are named tool.exe on Windows +// (e.g. terraform.exe inside HashiCorp's windows zips). +func exeName(name string) string { + if runtime.GOOS == "windows" { + return name + ".exe" + } + return name +} + // PrependToPath puts dir at the front of the current process PATH when it is // not already present, so tools installed there are found by later exec calls // in this process. It only affects this process's environment, never the diff --git a/internal/shared/download/pins_test.go b/internal/shared/download/pins_test.go index e3928b69..d69f9f14 100644 --- a/internal/shared/download/pins_test.go +++ b/internal/shared/download/pins_test.go @@ -1,6 +1,8 @@ package download import ( + "archive/zip" + "bytes" "context" "crypto/sha256" "encoding/hex" @@ -140,10 +142,13 @@ func TestPinnedAssets_RealDownload(t *testing.T) { if testing.Short() { t.Skip("network test skipped under -short") } - if runtime.GOOS == "windows" { - t.Skip("no windows pins: on Windows the CLI runs the linux binary inside WSL") + tools := []PinnedTool{Terraform} + if runtime.GOOS != "windows" { + // k3d/mkcert/helm have no windows pins: on Windows they run inside WSL. + // Terraform is pinned for all platforms. + tools = append(tools, K3d, Mkcert, Helm) } - for _, tool := range []PinnedTool{K3d, Mkcert, Helm} { + for _, tool := range tools { asset, ok := tool.Asset(runtime.GOOS, runtime.GOARCH) if !ok { t.Errorf("%s: no asset for %s/%s", tool.Name, runtime.GOOS, runtime.GOARCH) @@ -181,6 +186,82 @@ func TestHelm_Pins(t *testing.T) { } } +// TestTerraform_Pins locks the terraform pin shape: a versioned .zip + +// non-empty SHA256 for every supported platform — including Windows, where +// terraform (unlike k3d/helm) runs natively rather than inside WSL. +func TestTerraform_Pins(t *testing.T) { + if Terraform.Version == "" { + t.Fatal("Terraform.Version must be set") + } + if !Terraform.Zip { + t.Error("Terraform assets are .zip — Zip must be true") + } + for _, p := range []struct{ os, arch string }{ + {"linux", "amd64"}, {"linux", "arm64"}, + {"darwin", "amd64"}, {"darwin", "arm64"}, + {"windows", "amd64"}, {"windows", "arm64"}, + } { + asset, ok := Terraform.Asset(p.os, p.arch) + if !ok { + t.Errorf("no terraform asset for %s/%s", p.os, p.arch) + continue + } + if len(asset.SHA256) != 64 { + t.Errorf("%s/%s: SHA256 must be 64 hex chars, got %q", p.os, p.arch, asset.SHA256) + } + if !strings.Contains(asset.URL, Terraform.Version) || !strings.HasSuffix(asset.URL, p.os+"_"+p.arch+".zip") { + t.Errorf("%s/%s: URL %q must contain version and end with platform.zip", p.os, p.arch, asset.URL) + } + } +} + +// TestInstallPinnedTool_ZipTool covers the .zip install path (terraform's +// asset format): the binary is extracted from the archive root by name. +func TestInstallPinnedTool_ZipTool(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Unix file modes (0750) aren't honoured on Windows") + } + payload := []byte("#!/bin/sh\necho tf\n") + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + w, err := zw.Create("faketool") + if err != nil { + t.Fatal(err) + } + if _, err := w.Write(payload); err != nil { + t.Fatal(err) + } + if err := zw.Close(); err != nil { + t.Fatal(err) + } + archive := buf.Bytes() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write(archive) + })) + defer srv.Close() + + tool := PinnedTool{ + Name: "faketool", + Version: "v1.2.3", + Zip: true, + Assets: map[string]PinnedAsset{platformKey(): {URL: srv.URL, SHA256: hexSum(archive)}}, + } + binDir := t.TempDir() + + path, err := (Downloader{Client: srv.Client()}).InstallPinnedTool(context.Background(), tool, binDir) + if err != nil { + t.Fatalf("InstallPinnedTool(zip): %v", err) + } + got, err := os.ReadFile(path) // #nosec G304 -- test reads a path it just created under t.TempDir() + if err != nil { + t.Fatal(err) + } + if string(got) != string(payload) { + t.Fatalf("installed content = %q, want %q", got, payload) + } +} + // TestMkcert_Pins locks the mkcert pin shape: a versioned URL + non-empty SHA256 // for each supported linux/darwin platform. func TestMkcert_Pins(t *testing.T) { diff --git a/internal/shared/download/verify.go b/internal/shared/download/verify.go index d7cc9396..f32a6b17 100644 --- a/internal/shared/download/verify.go +++ b/internal/shared/download/verify.go @@ -8,6 +8,7 @@ package download import ( "archive/tar" + "archive/zip" "bytes" "compress/gzip" "context" @@ -44,6 +45,9 @@ type PinnedTool struct { // extracted from the archive member "-/" (the layout // helm and many Go tools ship). Bare-binary tools leave this false. Tarball bool + // Zip marks the assets as .zip archives with the bare binary named + // at the archive root (the layout HashiCorp releases ship, e.g. terraform). + Zip bool } // Asset returns the pinned asset for the given platform. @@ -142,6 +146,21 @@ func (d Downloader) InstallVerifiedTarGz(ctx context.Context, asset PinnedAsset, return writeFileAtomic(extracted, destPath, perm) } +// InstallVerifiedZipMember downloads and verifies a .zip asset, extracts the +// regular file named member (a slash path within the archive, e.g. +// "terraform"), and installs it to destPath with mode perm (atomic). +func (d Downloader) InstallVerifiedZipMember(ctx context.Context, asset PinnedAsset, member, destPath string, perm os.FileMode) error { + body, err := d.FetchVerified(ctx, asset) + if err != nil { + return err + } + extracted, err := extractZipMember(body, member) + if err != nil { + return err + } + return writeFileAtomic(extracted, destPath, perm) +} + // FetchVerifiedTarGzMember downloads and verifies a .tar.gz asset and returns // the bytes of the regular file named member — for callers that stream the // binary elsewhere (e.g. into WSL via stdin) instead of installing it locally. @@ -183,6 +202,32 @@ func extractTarGzMember(data []byte, member string) ([]byte, error) { } } +// extractZipMember returns the bytes of the regular file named member inside a +// zip archive. The member is matched by its cleaned path. +func extractZipMember(data []byte, member string) ([]byte, error) { + zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) + if err != nil { + return nil, fmt.Errorf("opening zip: %w", err) + } + want := path.Clean(member) + for _, f := range zr.File { + if f.Mode().IsRegular() && path.Clean(f.Name) == want { + rc, err := f.Open() + if err != nil { + return nil, fmt.Errorf("extracting %q: %w", member, err) + } + defer func() { _ = rc.Close() }() + // Cap extraction to guard against a decompression bomb. + b, err := io.ReadAll(io.LimitReader(rc, 200<<20)) + if err != nil { + return nil, fmt.Errorf("extracting %q: %w", member, err) + } + return b, nil + } + } + return nil, fmt.Errorf("member %q not found in archive", member) +} + // writeFileAtomic writes body to destPath with mode perm via a temp file in the // same directory + atomic rename. On any failure nothing partial remains. func writeFileAtomic(body []byte, destPath string, perm os.FileMode) error {