diff --git a/README.md b/README.md index 9b290b5a..03b7cd9f 100644 --- a/README.md +++ b/README.md @@ -180,7 +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 create my-eks --type eks --region us-east-1 --skip-wizard # EKS creation currently gated (coming soon), does not create billed resources openframe cluster list # add -o json|yaml for scripts openframe cluster status dev openframe cluster delete dev --force diff --git a/cmd/cluster/create.go b/cmd/cluster/create.go index 63a05841..c0ce2d5a 100644 --- a/cmd/cluster/create.go +++ b/cmd/cluster/create.go @@ -34,13 +34,14 @@ 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 k3d cluster or a cloud EKS cluster for OpenFrame. If a cluster +Creates a local k3d cluster or a cloud GKE 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. +components after creation. EKS cluster creation is temporarily disabled and +will be available soon. -EKS clusters are provisioned with Terraform (installed automatically) and -create AWS resources that incur costs; the workspace and state live under +GKE clusters are provisioned with Terraform (installed automatically) and +create GCP 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'. @@ -49,7 +50,7 @@ Examples: 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 my-eks --type eks --region us-east-1 --skip-wizard`, + openframe cluster create my-gke --type gke --project my-project --region us-central1 --skip-wizard`, Args: cobra.MaximumNArgs(1), PreRunE: func(cmd *cobra.Command, args []string) error { utils.SyncGlobalFlags() @@ -300,7 +301,7 @@ func runCreateCluster(cmd *cobra.Command, args []string) error { // creates are gated. if config.Type == models.ClusterTypeEKS { showEKSComingSoonBanner() - return nil + return fmt.Errorf("EKS cluster creation is not yet available") } // Show configuration summary for dry-run or skip-wizard modes diff --git a/cmd/cluster/list.go b/cmd/cluster/list.go index e2fa46f4..65eee66f 100644 --- a/cmd/cluster/list.go +++ b/cmd/cluster/list.go @@ -123,7 +123,7 @@ func discoverExternalClusters(ctx context.Context, managed []models.ClusterInfo) isManaged := func(c models.ClusterInfo) bool { for _, m := range managed { - if m.Name == c.Name && (m.Project == "" || m.Project == c.Project) { + if m.Name == c.Name && m.Project == c.Project { return true } } diff --git a/cmd/cluster/use.go b/cmd/cluster/use.go index 332da138..944dba77 100644 --- a/cmd/cluster/use.go +++ b/cmd/cluster/use.go @@ -130,7 +130,7 @@ func useExternalGKE(ctx context.Context, exec executor.CommandExecutor, kubeconf // No kubeconfig entry yet — fetch credentials (adds the gke_* context). pterm.Info.Printf("Fetching credentials for '%s' (project %s, %s)...\n", name, found.Project, found.Region) if _, err := exec.Execute(ctx, "gcloud", "container", "clusters", "get-credentials", name, - "--project", found.Project, "--region", found.Region); err != nil { + "--project", found.Project, "--location", found.Region); err != nil { return fmt.Errorf("could not fetch credentials for '%s' (for private clusters try 'gcloud container fleet memberships get-credentials %s'): %w", name, name, err) } contextName = fmt.Sprintf("gke_%s_%s_%s", found.Project, found.Region, name) diff --git a/internal/cluster/prerequisites/aws/aws.go b/internal/cluster/prerequisites/aws/aws.go index ae5f6048..50e2e2f8 100644 --- a/internal/cluster/prerequisites/aws/aws.go +++ b/internal/cluster/prerequisites/aws/aws.go @@ -71,9 +71,7 @@ func (a *AwsInstaller) installLinux() error { } 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"}}, + {"pacman", []string{"pacman", "-S", "--noconfirm", "aws-cli-v2"}}, } for _, m := range managers { if !commandExists(m.name) { diff --git a/internal/cluster/provider/provider.go b/internal/cluster/provider/provider.go index 3db1e9da..2c77904b 100644 --- a/internal/cluster/provider/provider.go +++ b/internal/cluster/provider/provider.go @@ -1,10 +1,10 @@ // Package provider defines the unified cluster-provider abstraction. // -// A Provider creates and manages Kubernetes clusters. Today only k3d (local) is -// 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. +// A Provider creates and manages Kubernetes clusters. k3d (local), EKS, and +// GKE are implemented and routed to their respective backends (k3d.New, +// eks.New, gke.New) through the factory. New backends implement the same +// Provider interface, so the rest of the CLI never needs to know which backend +// is used. package provider import ( diff --git a/internal/cluster/providers/eks/provider.go b/internal/cluster/providers/eks/provider.go index 6d9fad99..3cd5caad 100644 --- a/internal/cluster/providers/eks/provider.go +++ b/internal/cluster/providers/eks/provider.go @@ -67,6 +67,25 @@ func (p *Provider) preflightCredentials(ctx context.Context, profile string) err return nil } +// preflightNameCollision refuses to create a cluster whose name already +// exists in the target region but has no openframe workspace: terraform would +// fail mid-apply on the duplicate cluster, leaving partial billed +// infrastructure. External clusters are strictly not ours to touch — the user +// must pick another name. +func (p *Provider) preflightNameCollision(ctx context.Context, region, profile, name string) error { + args := []string{"eks", "describe-cluster", "--name", name, "--region", region, "--output", "json"} + if profile != "" { + args = append(args, "--profile", profile) + } + result, err := p.executor.Execute(ctx, "aws", args...) + if err != nil || result == nil { + return nil // cluster does not exist or call failed — proceed + } + // If describe-cluster succeeds, the cluster exists + return fmt.Errorf("cluster '%s' already exists in region '%s' but is not managed by openframe — refusing to touch it; pick another cluster name", + name, region) +} + // backendTF renders the s3 backend block for an EKS workspace. func backendTF(cfg tfengine.BackendConfig, region string) []byte { key := "terraform.tfstate" @@ -150,6 +169,9 @@ func (p *Provider) CreateCluster(ctx context.Context, config models.ClusterConfi if err != nil { return nil, err } + if err := p.preflightNameCollision(ctx, config.Cloud.Region, config.Cloud.Profile, config.Name); err != nil { + return nil, err + } record := tfengine.Record{ Name: config.Name, Type: models.ClusterTypeEKS, diff --git a/internal/cluster/providers/gke/templates/main.tf b/internal/cluster/providers/gke/templates/main.tf index 7ddf4925..228c5581 100644 --- a/internal/cluster/providers/gke/templates/main.tf +++ b/internal/cluster/providers/gke/templates/main.tf @@ -162,6 +162,8 @@ module "gke" { disk_size_gb = 100 } ] + + depends_on = [google_compute_router_nat.nat] } output "cluster_name" { diff --git a/internal/cluster/providers/terraform/confirm.go b/internal/cluster/providers/terraform/confirm.go index bf83022e..b41455d7 100644 --- a/internal/cluster/providers/terraform/confirm.go +++ b/internal/cluster/providers/terraform/confirm.go @@ -25,7 +25,8 @@ func ConfirmApplyInteractive(summary PlanSummary) bool { } pterm.DefaultBasicText.Printf("\nPlan: %d to add, %d to change, %d to destroy\n\n", summary.Add, summary.Change, summary.Destroy) + total := summary.Add + summary.Change + summary.Destroy confirmed, err := sharedUI.ConfirmActionInteractive( - fmt.Sprintf("Apply this plan (%d resources to add)?", summary.Add), true) + fmt.Sprintf("Apply this plan (%d resources)?", total), true) return err == nil && confirmed } diff --git a/internal/cluster/providers/terraform/cost.go b/internal/cluster/providers/terraform/cost.go index 7e294501..e9fd8622 100644 --- a/internal/cluster/providers/terraform/cost.go +++ b/internal/cluster/providers/terraform/cost.go @@ -8,6 +8,7 @@ import ( "os/exec" "path/filepath" "strings" + "time" sharedexec "github.com/flamingo-stack/openframe-cli/internal/shared/executor" ) @@ -49,7 +50,11 @@ func EstimateMonthlyCost(ctx context.Context, execer sharedexec.CommandExecutor, return "", err } - result, err := execer.Execute(ctx, "infracost", "breakdown", + // Bound the infracost execution with a 30-second timeout to prevent hangs + costCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + + result, err := execer.Execute(costCtx, "infracost", "breakdown", "--path", planPath, "--format", "json", "--no-color") if err != nil { return "", fmt.Errorf("infracost breakdown failed: %w", err) diff --git a/internal/cluster/service.go b/internal/cluster/service.go index a5fff798..ad17d364 100644 --- a/internal/cluster/service.go +++ b/internal/cluster/service.go @@ -280,7 +280,9 @@ func (s *ClusterService) ListClusters() ([]models.ClusterInfo, error) { for _, cloud := range s.cloudProviders() { cloudClusters, err := cloud.ListAllClusters(ctx) if err != nil { - return nil, err + // Skip errors from individual cloud providers to preserve local + // clusters and results from other providers + continue } clusters = append(clusters, cloudClusters...) } diff --git a/internal/cluster/ui/wizard.go b/internal/cluster/ui/wizard.go index 3d025d03..e6861525 100644 --- a/internal/cluster/ui/wizard.go +++ b/internal/cluster/ui/wizard.go @@ -91,8 +91,10 @@ func (w *ConfigWizard) Run() (ClusterConfig, error) { // the module default. if clusterType == models.ClusterTypeEKS || clusterType == models.ClusterTypeGKE { defaultRegion, defaultMachine := "us-east-1", "m6i.large" + regionLabel := "AWS Region" if clusterType == models.ClusterTypeGKE { defaultRegion, defaultMachine = "us-central1", "e2-standard-4" + regionLabel = "GCP Region" project, err := steps.PromptProject() if err != nil { @@ -101,7 +103,7 @@ func (w *ConfigWizard) Run() (ClusterConfig, error) { w.config.Project = project } - region, err := steps.PromptRegion(defaultRegion) + region, err := steps.PromptRegion(regionLabel, defaultRegion) if err != nil { return ClusterConfig{}, err } diff --git a/internal/cluster/ui/wizard_steps.go b/internal/cluster/ui/wizard_steps.go index 7537110f..3ffa7868 100644 --- a/internal/cluster/ui/wizard_steps.go +++ b/internal/cluster/ui/wizard_steps.go @@ -90,10 +90,10 @@ func (ws *WizardSteps) PromptProject() (string, error) { return strings.TrimSpace(result), nil } -// PromptRegion prompts for the AWS region an EKS cluster lands in. -func (ws *WizardSteps) PromptRegion(defaultRegion string) (string, error) { +// PromptRegion prompts for the cloud region with a customizable label. +func (ws *WizardSteps) PromptRegion(label, defaultRegion string) (string, error) { prompt := promptui.Prompt{ - Label: "AWS Region", + Label: label, Default: defaultRegion, Validate: sharedUI.ValidateNonEmpty("region"), } diff --git a/internal/shared/download/pins.go b/internal/shared/download/pins.go index ad2fde35..8992b48a 100644 --- a/internal/shared/download/pins.go +++ b/internal/shared/download/pins.go @@ -147,6 +147,7 @@ const ( var Infracost = PinnedTool{ Name: "infracost", Version: infracostVersion, + Tarball: true, Assets: map[string]PinnedAsset{ "linux/amd64": {URL: infracostBaseURL + "linux-amd64.tar.gz", SHA256: infracostSHA256LinuxAMD64}, "linux/arm64": {URL: infracostBaseURL + "linux-arm64.tar.gz", SHA256: infracostSHA256LinuxARM64}, diff --git a/internal/shared/download/pins_test.go b/internal/shared/download/pins_test.go index 08d2a1f7..f9004379 100644 --- a/internal/shared/download/pins_test.go +++ b/internal/shared/download/pins_test.go @@ -316,6 +316,9 @@ func TestInfracost_Pins(t *testing.T) { if !strings.HasPrefix(Infracost.Version, "v") { t.Fatalf("Infracost.Version must be a v-prefixed tag, got %q", Infracost.Version) } + if !Infracost.Tarball { + t.Errorf("Infracost.Tarball must be true (assets are .tar.gz)") + } for _, p := range []struct{ os, arch string }{ {"linux", "amd64"}, {"linux", "arm64"}, {"darwin", "amd64"}, {"darwin", "arm64"},