From 9585e95668ddbfac2dfb40984f6b40797c7585df Mon Sep 17 00:00:00 2001 From: Oleg Tkachuk Date: Wed, 15 Jul 2026 13:26:39 +0300 Subject: [PATCH 01/38] chore(cluster): reintroduce provider factory and de-hardcode k3d seams (#222) --- cmd/cluster/cluster.go | 6 + cmd/cluster/create.go | 9 ++ cmd/cluster/create_behavior_test.go | 24 ++++ cmd/root.go | 2 +- internal/cluster/models/cluster.go | 33 +++-- internal/cluster/models/cluster_test.go | 117 ++++++------------ internal/cluster/models/flags.go | 12 +- internal/cluster/models/flags_test.go | 17 +++ internal/cluster/prerequisites/checker.go | 15 +++ .../cluster/prerequisites/checker_test.go | 11 ++ internal/cluster/provider/factory.go | 22 ++++ internal/cluster/provider/factory_test.go | 43 +++++++ internal/cluster/provider/provider.go | 16 ++- internal/cluster/providers/k3d/manager.go | 3 + .../cluster/providers/k3d/manager_test.go | 10 ++ internal/cluster/service.go | 44 +++++-- internal/cluster/service_test.go | 37 ++++++ internal/cluster/types_test.go | 15 --- internal/cluster/ui/prompts.go | 1 - internal/cluster/ui/prompts_test.go | 1 - internal/cluster/ui/wizard.go | 14 +-- internal/cluster/ui/wizard_steps.go | 28 ----- internal/cluster/ui/wizard_steps_test.go | 9 -- internal/cluster/ui/wizard_test.go | 1 - 24 files changed, 306 insertions(+), 184 deletions(-) create mode 100644 internal/cluster/provider/factory.go create mode 100644 internal/cluster/provider/factory_test.go diff --git a/cmd/cluster/cluster.go b/cmd/cluster/cluster.go index a4bf8639..b597def1 100644 --- a/cmd/cluster/cluster.go +++ b/cmd/cluster/cluster.go @@ -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..1b227554 100644 --- a/cmd/cluster/create.go +++ b/cmd/cluster/create.go @@ -5,6 +5,7 @@ import ( "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/ui" "github.com/flamingo-stack/openframe-cli/internal/cluster/utils" "github.com/spf13/cobra" @@ -130,6 +131,14 @@ func runCreateCluster(cmd *cobra.Command, args []string) error { } } + // 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..8fbc280d 100644 --- a/cmd/cluster/create_behavior_test.go +++ b/cmd/cluster/create_behavior_test.go @@ -1,6 +1,7 @@ package cluster import ( + "strings" "testing" "github.com/flamingo-stack/openframe-cli/internal/cluster/utils" @@ -72,6 +73,29 @@ func TestRunCreateCluster_DryRunDefaultsNameWhenNoArgs(t *testing.T) { } } +func TestRunCreateCluster_CloudTypeFailsWithProviderNotFound(t *testing.T) { + for _, clusterType := range []string{"gke", "eks"} { + t.Run(clusterType, func(t *testing.T) { + setupCreate(t) + cmd := getCreateCmd() + gf := utils.GetGlobalFlags() + gf.Create.SkipWizard = true + gf.Create.ClusterType = clusterType + + // The type passes flag validation (recognized) and the local-tool + // prerequisite gate (cloud types skip it), then fails at the provider + // factory — no cloud backend is implemented yet. + err := runCreateCluster(cmd, []string{"cloud-cluster"}) + if err == nil { + t.Fatal("expected ErrProviderNotFound for a cloud cluster type") + } + if !strings.Contains(err.Error(), "no provider available for cluster type") { + t.Fatalf("expected provider-not-found error, got: %v", err) + } + }) + } +} + // setupWithExecutor wires a specific mock executor into the command service. func setupWithExecutor(t *testing.T, exec *executor.MockCommandExecutor) { t.Helper() 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/internal/cluster/models/cluster.go b/internal/cluster/models/cluster.go index b903f857..e459f153 100644 --- a/internal/cluster/models/cluster.go +++ b/internal/cluster/models/cluster.go @@ -17,6 +17,21 @@ 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"` } // ClusterInfo represents information about a cluster @@ -41,21 +56,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..8bfe985c 100644 --- a/internal/cluster/models/flags.go +++ b/internal/cluster/models/flags.go @@ -56,7 +56,7 @@ 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)") 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") @@ -131,6 +131,16 @@ func ValidateCreateFlags(flags *CreateFlags) error { return err } + // Reject unknown --type values up front. Recognized-but-unimplemented cloud + // types (gke, eks) pass here and fail later with ErrProviderNotFound at the + // provider factory, so the two cases stay distinguishable. + switch ClusterType(flags.ClusterType) { + case "", ClusterTypeK3d, ClusterTypeGKE, ClusterTypeEKS: + // known + default: + return fmt.Errorf("unknown cluster type '%s' (supported: k3d)", flags.ClusterType) + } + // 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..575f7c29 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} diff --git a/internal/cluster/prerequisites/checker.go b/internal/cluster/prerequisites/checker.go index 0acb3d25..0538b73a 100644 --- a/internal/cluster/prerequisites/checker.go +++ b/internal/cluster/prerequisites/checker.go @@ -1,6 +1,7 @@ package prerequisites import ( + "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" @@ -66,3 +67,17 @@ func CheckPrerequisites() error { // A CI environment or a non-terminal stdin must not hit an interactive prompt. return NewInstaller().CheckAndInstallNonInteractive(ui.IsNonInteractive()) } + +// CheckForClusterType runs the prerequisite gate for the given cluster type. +// Docker/k3d/helm are only required for local k3d clusters; cloud types bring +// their own prerequisite sets with their backends (none implemented yet), so +// they pass through and fail later at the provider factory instead of +// demanding tools they will never use. +func CheckForClusterType(clusterType models.ClusterType) error { + switch clusterType { + case models.ClusterTypeK3d, "": + return CheckPrerequisites() + default: + return nil + } +} diff --git a/internal/cluster/prerequisites/checker_test.go b/internal/cluster/prerequisites/checker_test.go index 527b8a41..af87c1e9 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,16 @@ func containsAny(str string, substrings []string) bool { return false } +func TestCheckForClusterType_CloudTypesSkipLocalGate(t *testing.T) { + // The Docker/k3d/helm gate is for local clusters only; cloud types must + // pass through regardless of what is installed on this machine. + for _, clusterType := range []models.ClusterType{models.ClusterTypeGKE, models.ClusterTypeEKS} { + if err := CheckForClusterType(clusterType); err != nil { + t.Errorf("CheckForClusterType(%s) should not require local tools: %v", clusterType, err) + } + } +} + func TestCheckAllWithMissingTools(t *testing.T) { checker := NewPrerequisiteChecker() diff --git a/internal/cluster/provider/factory.go b/internal/cluster/provider/factory.go new file mode 100644 index 00000000..0736402c --- /dev/null +++ b/internal/cluster/provider/factory.go @@ -0,0 +1,22 @@ +package provider + +import ( + "github.com/flamingo-stack/openframe-cli/internal/cluster/models" + "github.com/flamingo-stack/openframe-cli/internal/cluster/providers/k3d" + "github.com/flamingo-stack/openframe-cli/internal/shared/executor" +) + +// New returns the Provider for the given cluster type. An empty type defaults +// to k3d (the local development default). GKE/EKS are recognized types whose +// backends are not implemented yet, so they return ErrProviderNotFound; 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.ClusterTypeGKE, models.ClusterTypeEKS: + return nil, models.NewProviderNotFoundError(clusterType) + 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..9b99fdbf --- /dev/null +++ b/internal/cluster/provider/factory_test.go @@ -0,0 +1,43 @@ +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("recognized cloud types return ErrProviderNotFound", func(t *testing.T) { + for _, clusterType := range []models.ClusterType{models.ClusterTypeGKE, models.ClusterTypeEKS} { + p, err := New(clusterType, exec) + assert.Nil(t, p) + var notFound models.ErrProviderNotFound + assert.True(t, errors.As(err, ¬Found), "expected ErrProviderNotFound for %s, got %v", clusterType, err) + assert.Equal(t, clusterType, notFound.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..3044474e 100644 --- a/internal/cluster/provider/provider.go +++ b/internal/cluster/provider/provider.go @@ -1,9 +1,10 @@ // 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 ( @@ -40,10 +41,7 @@ type Provider interface { // Compile-time assertion that the k3d manager satisfies 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. +// 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) 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/service.go b/internal/cluster/service.go index 5541aead..3e494e7b 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" @@ -73,7 +71,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 +81,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 +89,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,7 +146,7 @@ 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) } @@ -147,7 +163,7 @@ func (s *ClusterService) CreateCluster(ctx context.Context, config models.Cluste 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 +178,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,6 +190,11 @@ 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 { + mgr, err := s.providerFor(clusterType) + if err != nil { + return err + } + // Show deletion progress var sp *spinner.Spinner if !s.suppressUI { @@ -183,7 +204,7 @@ func (s *ClusterService) DeleteCluster(ctx context.Context, name string, cluster 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)) @@ -434,11 +455,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) } diff --git a/internal/cluster/service_test.go b/internal/cluster/service_test.go index 7e540f9d..6e605be3 100644 --- a/internal/cluster/service_test.go +++ b/internal/cluster/service_test.go @@ -2,6 +2,7 @@ package cluster import ( "context" + "errors" "testing" "github.com/flamingo-stack/openframe-cli/internal/cluster/models" @@ -58,6 +59,42 @@ func TestClusterService_CreateCluster(t *testing.T) { _ = err } +func TestClusterService_CreateCluster_CloudTypeFailsBeforeAnyCommand(t *testing.T) { + for _, clusterType := range []models.ClusterType{models.ClusterTypeGKE, models.ClusterTypeEKS} { + mock := executor.NewMockCommandExecutor() + service := NewClusterService(mock) + + _, err := service.CreateCluster(context.Background(), models.ClusterConfig{ + Name: "cloud-cluster", + Type: clusterType, + NodeCount: 1, + }) + + var notFound models.ErrProviderNotFound + if !errors.As(err, ¬Found) { + t.Fatalf("expected ErrProviderNotFound for %s, got %v", clusterType, err) + } + if mock.GetCommandCount() != 0 { + t.Errorf("no commands should run for unsupported type %s, got: %v", clusterType, mock.GetExecutedCommands()) + } + } +} + +func TestClusterService_DeleteCluster_CloudTypeFailsBeforeAnyCommand(t *testing.T) { + mock := executor.NewMockCommandExecutor() + service := NewClusterService(mock) + + err := service.DeleteCluster(context.Background(), "cloud-cluster", models.ClusterTypeEKS, false) + + var notFound models.ErrProviderNotFound + if !errors.As(err, ¬Found) { + t.Fatalf("expected ErrProviderNotFound, got %v", err) + } + if mock.GetCommandCount() != 0 { + t.Errorf("no commands should run for unsupported type, got: %v", mock.GetExecutedCommands()) + } +} + 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/prompts.go b/internal/cluster/ui/prompts.go index 9ba27680..5dabee64 100644 --- a/internal/cluster/ui/prompts.go +++ b/internal/cluster/ui/prompts.go @@ -13,7 +13,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 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..de168d25 100644 --- a/internal/cluster/ui/wizard.go +++ b/internal/cluster/ui/wizard.go @@ -56,28 +56,24 @@ func (w *ConfigWizard) Run() (ClusterConfig, error) { } w.config.Name = name - // Step 2: Cluster type - clusterType, err := steps.PromptClusterType() - if err != nil { - return ClusterConfig{}, err - } - w.config.Type = clusterType + // Cluster type is not prompted: k3d is the only implemented backend. When a + // cloud backend lands, reintroduce a type step here with real options. - // Step 3: Node count + // Step 2: Node count nodeCount, err := steps.PromptNodeCount(w.config.NodeCount) if err != nil { return ClusterConfig{}, err } w.config.NodeCount = nodeCount - // Step 4: Kubernetes version + // Step 3: Kubernetes version k8sVersion, err := steps.PromptK8sVersion() if err != nil { return ClusterConfig{}, err } w.config.K8sVersion = k8sVersion - // Step 5: Confirmation + // Step 4: Confirmation domainConfig := models.ClusterConfig{ Name: w.config.Name, Type: w.config.Type, diff --git a/internal/cluster/ui/wizard_steps.go b/internal/cluster/ui/wizard_steps.go index b87b62fa..1675035c 100644 --- a/internal/cluster/ui/wizard_steps.go +++ b/internal/cluster/ui/wizard_steps.go @@ -41,34 +41,6 @@ func (ws *WizardSteps) PromptClusterName(defaultName string) (string, error) { return strings.TrimSpace(result), nil } -// 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)"}, - Templates: &promptui.SelectTemplates{ - Label: "{{ . }}:", - Active: "→ {{ . | cyan }}", - Inactive: " {{ . }}", - Selected: "{{ . | green }}", - }, - } - - idx, _, err := prompt.Run() - if err != nil { - return "", err - } - - switch idx { - case 0: - return models.ClusterTypeK3d, nil - case 1: - return models.ClusterTypeGKE, nil - default: - return models.ClusterTypeK3d, nil - } -} - // PromptNodeCount prompts for number of worker nodes func (ws *WizardSteps) PromptNodeCount(defaultCount int) (int, error) { prompt := promptui.Prompt{ 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 { From 6655c86cfb44dcf0c5bd372c04c49dded0d93e3f Mon Sep 17 00:00:00 2001 From: Oleg Tkachuk Date: Wed, 15 Jul 2026 17:35:37 +0300 Subject: [PATCH 02/38] fix(argocd): raise controller throughput and repo-server CPU cap for cold install (#224) --- internal/chart/providers/argocd/argocd-values.yaml | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/internal/chart/providers/argocd/argocd-values.yaml b/internal/chart/providers/argocd/argocd-values.yaml index d1703fd6..41038ae0 100644 --- a/internal/chart/providers/argocd/argocd-values.yaml +++ b/internal/chart/providers/argocd/argocd-values.yaml @@ -66,9 +66,11 @@ configs: controller: + # 17 child apps flip status simultaneously during install; the defaults + # queued those transitions and slowed reconciliation on cold start. extraArgs: - - --status-processors=10 - - --operation-processors=5 + - --status-processors=20 + - --operation-processors=10 podAnnotations: loki.grafana.com/scrape: "true" prometheus.io/scrape: "true" @@ -99,12 +101,15 @@ server: repoServer: podAnnotations: loki.grafana.com/scrape: "true" + # 1500m (was 500m): on a cold install the repo-server renders all 17 helm + # charts back-to-back and the 500m cap throttled it — slow first renders + # surfaced as "connection refused" on the apps. resources: requests: cpu: 200m memory: 400Mi limits: - cpu: 500m + cpu: 1500m memory: 1Gi env: - name: ARGOCD_EXEC_TIMEOUT From 3b0a64af7e79f391c0eaa22d614e47555c94b013 Mon Sep 17 00:00:00 2001 From: Oleg Tkachuk Date: Wed, 15 Jul 2026 23:53:25 +0300 Subject: [PATCH 03/38] feat(cluster): provision EKS clusters via Terraform (#225) --- cmd/cluster/cluster.go | 2 +- cmd/cluster/create.go | 30 ++- cmd/cluster/create_behavior_test.go | 51 ++-- go.mod | 5 + go.sum | 12 + internal/cluster/models/flags.go | 35 ++- internal/cluster/prerequisites/aws/aws.go | 94 +++++++ internal/cluster/prerequisites/checker.go | 44 +++- internal/cluster/prerequisites/installer.go | 63 ++--- .../prerequisites/terraform/terraform.go | 82 ++++++ internal/cluster/provider/factory.go | 14 +- internal/cluster/provider/factory_test.go | 21 +- internal/cluster/provider/provider.go | 8 +- internal/cluster/providers/eks/kubeconfig.go | 115 +++++++++ internal/cluster/providers/eks/provider.go | 241 ++++++++++++++++++ .../cluster/providers/eks/provider_test.go | 235 +++++++++++++++++ internal/cluster/providers/eks/template.go | 84 ++++++ .../cluster/providers/eks/templates/main.tf | 140 ++++++++++ .../cluster/providers/terraform/engine.go | 153 +++++++++++ .../providers/terraform/engine_test.go | 89 +++++++ .../cluster/providers/terraform/workspace.go | 195 ++++++++++++++ .../providers/terraform/workspace_test.go | 95 +++++++ internal/cluster/service.go | 47 +++- internal/cluster/service_test.go | 97 +++++-- internal/cluster/ui/operations.go | 7 + internal/cluster/ui/wizard.go | 77 ++++-- internal/cluster/ui/wizard_steps.go | 60 +++++ internal/platform/platform.go | 12 + internal/shared/download/pins.go | 32 +++ internal/shared/download/verify.go | 45 ++++ 30 files changed, 2060 insertions(+), 125 deletions(-) create mode 100644 internal/cluster/prerequisites/aws/aws.go create mode 100644 internal/cluster/prerequisites/terraform/terraform.go create mode 100644 internal/cluster/providers/eks/kubeconfig.go create mode 100644 internal/cluster/providers/eks/provider.go create mode 100644 internal/cluster/providers/eks/provider_test.go create mode 100644 internal/cluster/providers/eks/template.go create mode 100644 internal/cluster/providers/eks/templates/main.tf create mode 100644 internal/cluster/providers/terraform/engine.go create mode 100644 internal/cluster/providers/terraform/engine_test.go create mode 100644 internal/cluster/providers/terraform/workspace.go create mode 100644 internal/cluster/providers/terraform/workspace_test.go diff --git a/cmd/cluster/cluster.go b/cmd/cluster/cluster.go index b597def1..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 diff --git a/cmd/cluster/create.go b/cmd/cluster/create.go index 1b227554..a30498f5 100644 --- a/cmd/cluster/create.go +++ b/cmd/cluster/create.go @@ -24,16 +24,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() @@ -118,6 +124,20 @@ 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 { + cf := globalFlags.Create + config.Cloud = &models.CloudConfig{ + Region: cf.Region, + Profile: cf.Profile, + MachineType: cf.MachineType, + MinNodes: cf.MinNodes, + MaxNodes: cf.MaxNodes, + Spot: cf.Spot, + } + } } // Show configuration summary for dry-run or skip-wizard modes diff --git a/cmd/cluster/create_behavior_test.go b/cmd/cluster/create_behavior_test.go index 8fbc280d..ccbdf5cc 100644 --- a/cmd/cluster/create_behavior_test.go +++ b/cmd/cluster/create_behavior_test.go @@ -73,26 +73,37 @@ func TestRunCreateCluster_DryRunDefaultsNameWhenNoArgs(t *testing.T) { } } -func TestRunCreateCluster_CloudTypeFailsWithProviderNotFound(t *testing.T) { - for _, clusterType := range []string{"gke", "eks"} { - t.Run(clusterType, func(t *testing.T) { - setupCreate(t) - cmd := getCreateCmd() - gf := utils.GetGlobalFlags() - gf.Create.SkipWizard = true - gf.Create.ClusterType = clusterType - - // The type passes flag validation (recognized) and the local-tool - // prerequisite gate (cloud types skip it), then fails at the provider - // factory — no cloud backend is implemented yet. - err := runCreateCluster(cmd, []string{"cloud-cluster"}) - if err == nil { - t.Fatal("expected ErrProviderNotFound for a cloud cluster type") - } - if !strings.Contains(err.Error(), "no provider available for cluster type") { - t.Fatalf("expected provider-not-found error, got: %v", err) - } - }) +func TestRunCreateCluster_GKEFailsWithProviderNotFound(t *testing.T) { + setupCreate(t) + cmd := getCreateCmd() + gf := utils.GetGlobalFlags() + gf.Create.SkipWizard = true + gf.Create.ClusterType = "gke" + + // gke passes flag validation (recognized) and the prerequisite gate (no + // backend → no tools), then fails at the provider factory. + err := runCreateCluster(cmd, []string{"cloud-cluster"}) + if err == nil { + t.Fatal("expected ErrProviderNotFound for gke") + } + if !strings.Contains(err.Error(), "no provider available for cluster type") { + t.Fatalf("expected provider-not-found error, got: %v", err) + } +} + +func TestRunCreateCluster_EKSDryRunShowsPlanAndExits(t *testing.T) { + setupCreate(t) + cmd := getCreateCmd() + gf := utils.GetGlobalFlags() + gf.Create.SkipWizard = true + gf.Create.DryRun = true + gf.Create.ClusterType = "eks" + gf.Create.Region = "us-east-1" + + // Dry-run exits after the summary — before the prerequisite gate (which may + // install tools) and before any terraform runs, so this is hermetic. + if err := runCreateCluster(cmd, []string{"cloud-cluster"}); err != nil { + t.Fatalf("eks dry-run should return nil, got %v", err) } } diff --git a/go.mod b/go.mod index fee54696..84d4ddce 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ 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/manifoldco/promptui v0.9.0 github.com/pterm/pterm v0.12.83 github.com/sigstore/sigstore-go v1.2.2 @@ -28,6 +29,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 +82,8 @@ 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/hashicorp/terraform-json v0.27.2 // 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/flags.go b/internal/cluster/models/flags.go index 8bfe985c..1c37308d 100644 --- a/internal/cluster/models/flags.go +++ b/internal/cluster/models/flags.go @@ -19,6 +19,14 @@ type CreateFlags struct { NodeCount int K8sVersion string SkipWizard bool + + // Cloud-only flags (EKS) + Region string + Profile string + MachineType string + MinNodes int + MaxNodes int + Spot bool } // ListFlags contains flags specific to list command @@ -56,10 +64,17 @@ 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)") + cmd.Flags().StringVarP(&flags.ClusterType, "type", "t", "", "Cluster type (k3d, eks)") 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 --type eks)") + cmd.Flags().StringVar(&flags.Profile, "profile", "", "AWS credentials profile (eks only)") + cmd.Flags().StringVar(&flags.MachineType, "machine-type", "", "Node instance type (eks only; default m6i.large)") + cmd.Flags().IntVar(&flags.MinNodes, "min-nodes", 0, "Node group minimum size (eks only)") + cmd.Flags().IntVar(&flags.MaxNodes, "max-nodes", 0, "Node group maximum size (eks only)") + cmd.Flags().BoolVar(&flags.Spot, "spot", false, "Use spot capacity for nodes (eks only)") } // AddListFlags adds list-specific flags to a command @@ -131,14 +146,26 @@ func ValidateCreateFlags(flags *CreateFlags) error { return err } - // Reject unknown --type values up front. Recognized-but-unimplemented cloud - // types (gke, eks) pass here and fail later with ErrProviderNotFound at the + // Reject unknown --type values up front. GKE is recognized but has no + // backend yet — it passes here and fails with ErrProviderNotFound at the // provider factory, so the two cases stay distinguishable. switch ClusterType(flags.ClusterType) { case "", ClusterTypeK3d, ClusterTypeGKE, ClusterTypeEKS: // known default: - return fmt.Errorf("unknown cluster type '%s' (supported: k3d)", flags.ClusterType) + return fmt.Errorf("unknown cluster type '%s' (supported: k3d, eks)", flags.ClusterType) + } + + // The wizard prompts for the region; in skip-wizard mode it must come from + // the flag. + if ClusterType(flags.ClusterType) == ClusterTypeEKS && flags.SkipWizard && flags.Region == "" { + return fmt.Errorf("--region is required for --type eks with --skip-wizard") + } + 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 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 0538b73a..0e0f530b 100644 --- a/internal/cluster/prerequisites/checker.go +++ b/internal/cluster/prerequisites/checker.go @@ -2,9 +2,11 @@ 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/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" ) @@ -17,8 +19,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{ @@ -32,18 +36,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() }, }, }, } @@ -68,15 +101,16 @@ func CheckPrerequisites() error { return NewInstaller().CheckAndInstallNonInteractive(ui.IsNonInteractive()) } -// CheckForClusterType runs the prerequisite gate for the given cluster type. -// Docker/k3d/helm are only required for local k3d clusters; cloud types bring -// their own prerequisite sets with their backends (none implemented yet), so -// they pass through and fail later at the provider factory instead of -// demanding tools they will never use. +// CheckForClusterType runs the prerequisite gate for the given cluster type: +// Docker/k3d/helm for local k3d clusters, terraform + AWS CLI for EKS. GKE has +// no backend yet, so it passes through and fails at the provider factory +// instead of demanding tools it will never use. func CheckForClusterType(clusterType models.ClusterType) error { switch clusterType { case models.ClusterTypeK3d, "": return CheckPrerequisites() + case models.ClusterTypeEKS: + return NewInstallerWithChecker(NewEKSPrerequisiteChecker()).CheckAndInstallNonInteractive(ui.IsNonInteractive()) default: 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/terraform/terraform.go b/internal/cluster/prerequisites/terraform/terraform.go new file mode 100644 index 00000000..e8a89bd2 --- /dev/null +++ b/internal/cluster/prerequisites/terraform/terraform.go @@ -0,0 +1,82 @@ +package terraform + +import ( + "context" + "fmt" + "os/exec" + "runtime" + "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 +} + +// IsInstalled reports whether a working terraform binary 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() + return exec.CommandContext(ctx, "terraform", "version").Run() == nil +} + +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). +func (t *TerraformInstaller) Install() error { + switch runtime.GOOS { + case "darwin", "linux": + return t.installVerified() + 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 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/provider/factory.go b/internal/cluster/provider/factory.go index 0736402c..45de1c40 100644 --- a/internal/cluster/provider/factory.go +++ b/internal/cluster/provider/factory.go @@ -2,19 +2,25 @@ 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/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). GKE/EKS are recognized types whose -// backends are not implemented yet, so they return ErrProviderNotFound; an -// unrecognized type is a configuration error. +// to k3d (the local development default). GKE is a recognized type without a +// backend yet, so it returns ErrProviderNotFound; 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.ClusterTypeGKE, models.ClusterTypeEKS: + 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 nil, models.NewProviderNotFoundError(clusterType) 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 index 9b99fdbf..e06f6b6e 100644 --- a/internal/cluster/provider/factory_test.go +++ b/internal/cluster/provider/factory_test.go @@ -24,14 +24,19 @@ func TestNew(t *testing.T) { assert.NotNil(t, p) }) - t.Run("recognized cloud types return ErrProviderNotFound", func(t *testing.T) { - for _, clusterType := range []models.ClusterType{models.ClusterTypeGKE, models.ClusterTypeEKS} { - p, err := New(clusterType, exec) - assert.Nil(t, p) - var notFound models.ErrProviderNotFound - assert.True(t, errors.As(err, ¬Found), "expected ErrProviderNotFound for %s, got %v", clusterType, err) - assert.Equal(t, clusterType, notFound.ClusterType) - } + t.Run("eks returns a provider", func(t *testing.T) { + t.Setenv("OPENFRAME_CLUSTERS_DIR", t.TempDir()) + p, err := New(models.ClusterTypeEKS, exec) + assert.NoError(t, err) + assert.NotNil(t, p) + }) + + t.Run("gke has no backend yet and returns ErrProviderNotFound", func(t *testing.T) { + p, err := New(models.ClusterTypeGKE, exec) + assert.Nil(t, p) + var notFound models.ErrProviderNotFound + assert.True(t, errors.As(err, ¬Found), "expected ErrProviderNotFound for gke, got %v", err) + assert.Equal(t, models.ClusterTypeGKE, notFound.ClusterType) }) t.Run("unknown type is a config error", func(t *testing.T) { diff --git a/internal/cluster/provider/provider.go b/internal/cluster/provider/provider.go index 3044474e..7d011b7d 100644 --- a/internal/cluster/provider/provider.go +++ b/internal/cluster/provider/provider.go @@ -11,6 +11,7 @@ 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/k3d" "k8s.io/client-go/rest" ) @@ -39,9 +40,12 @@ type Provider interface { GetKubeconfig(ctx context.Context, name string, clusterType models.ClusterType) (string, error) } -// Compile-time assertion that the k3d manager satisfies Provider. +// Compile-time assertions that the backends satisfy Provider. // // 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) +var ( + _ Provider = (*k3d.K3dManager)(nil) + _ Provider = (*eks.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..96ef8867 --- /dev/null +++ b/internal/cluster/providers/eks/provider.go @@ -0,0 +1,241 @@ +// 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" + "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 +} + +// 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 + } + 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 + } + } + // 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..388c4a5f --- /dev/null +++ b/internal/cluster/providers/eks/provider_test.go @@ -0,0 +1,235 @@ +package eks + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "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" + "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) Destroy(ctx context.Context, 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 true, 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") +} 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/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/terraform/engine.go b/internal/cluster/providers/terraform/engine.go new file mode 100644 index 00000000..4b65ddc7 --- /dev/null +++ b/internal/cluster/providers/terraform/engine.go @@ -0,0 +1,153 @@ +package terraform + +import ( + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + + "github.com/flamingo-stack/openframe-cli/internal/shared/download" + "github.com/hashicorp/terraform-exec/tfexec" +) + +// 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 + Destroy(ctx context.Context, opts ...tfexec.DestroyOption) error + Plan(ctx context.Context, opts ...tfexec.PlanOption) (bool, 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. 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.Apply(ctx); err != nil { + return fmt.Errorf("terraform apply failed: %w", err) + } + return nil +} + +// Destroy runs terraform destroy in dir. +func (e *Engine) Destroy(ctx context.Context, dir string) error { + tf, err := e.newRunner(dir) + if err != nil { + return err + } + if err := tf.Destroy(ctx); err != nil { + return fmt.Errorf("terraform destroy failed: %w", err) + } + return nil +} + +// Plan runs terraform plan in dir and reports whether changes are pending. +func (e *Engine) Plan(ctx context.Context, dir string) (bool, error) { + tf, err := e.newRunner(dir) + if err != nil { + return false, err + } + changes, err := tf.Plan(ctx) + if err != nil { + return false, fmt.Errorf("terraform plan failed: %w", err) + } + return changes, 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..4111f5cf --- /dev/null +++ b/internal/cluster/providers/terraform/engine_test.go @@ -0,0 +1,89 @@ +package terraform + +import ( + "context" + "encoding/json" + "errors" + "testing" + + "github.com/hashicorp/terraform-exec/tfexec" + "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 +} + +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) Destroy(ctx context.Context, 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 true, 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, "dir") + require.NoError(t, err) + assert.True(t, 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) +} + +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/workspace.go b/internal/cluster/providers/terraform/workspace.go new file mode 100644 index 00000000..6737a12b --- /dev/null +++ b/internal/cluster/providers/terraform/workspace.go @@ -0,0 +1,195 @@ +// 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"` + 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 +} + +// 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 := os.MkdirAll(w.TerraformDir(), 0o750); err != nil { + return fmt.Errorf("creating workspace %s: %w", w.dir, err) + } + varsJSON, err := json.MarshalIndent(tfvars, "", " ") + if err != nil { + return fmt.Errorf("encoding tfvars: %w", err) + } + if err := os.WriteFile(filepath.Join(w.TerraformDir(), "main.tf"), mainTF, 0o600); err != nil { + return err + } + if err := os.WriteFile(filepath.Join(w.TerraformDir(), "terraform.tfvars.json"), varsJSON, 0o600); 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 3e494e7b..812dae5c 100644 --- a/internal/cluster/service.go +++ b/internal/cluster/service.go @@ -221,27 +221,66 @@ func (s *ClusterService) DeleteCluster(ctx context.Context, name string, cluster return nil } -// ListClusters handles cluster listing business logic +// cloudProvider returns the EKS backend, or nil when it cannot be built (its +// registry is plain files under ~/.openframe, so this practically never +// fails; a nil just degrades the CLI to local-only visibility). +func (s *ClusterService) cloudProvider() provider.Provider { + p, err := s.providerFor(models.ClusterTypeEKS) + if err != nil { + return nil + } + return p +} + +// 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 + } + if cloud := s.cloudProvider(); cloud != nil { + 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() + if cloud := s.cloudProvider(); cloud != nil { + 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() + if cloud := s.cloudProvider(); cloud != nil { + 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() + if cloud := s.cloudProvider(); cloud != nil { + if t, err := cloud.DetectClusterType(ctx, name); err == nil { + return t, nil + } + } return s.manager.DetectClusterType(ctx, name) } @@ -252,6 +291,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) } diff --git a/internal/cluster/service_test.go b/internal/cluster/service_test.go index 6e605be3..23885ba1 100644 --- a/internal/cluster/service_test.go +++ b/internal/cluster/service_test.go @@ -3,9 +3,11 @@ 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" ) @@ -59,32 +61,50 @@ func TestClusterService_CreateCluster(t *testing.T) { _ = err } -func TestClusterService_CreateCluster_CloudTypeFailsBeforeAnyCommand(t *testing.T) { - for _, clusterType := range []models.ClusterType{models.ClusterTypeGKE, models.ClusterTypeEKS} { - mock := executor.NewMockCommandExecutor() - service := NewClusterService(mock) - - _, err := service.CreateCluster(context.Background(), models.ClusterConfig{ - Name: "cloud-cluster", - Type: clusterType, - NodeCount: 1, - }) - - var notFound models.ErrProviderNotFound - if !errors.As(err, ¬Found) { - t.Fatalf("expected ErrProviderNotFound for %s, got %v", clusterType, err) - } - if mock.GetCommandCount() != 0 { - t.Errorf("no commands should run for unsupported type %s, got: %v", clusterType, mock.GetExecutedCommands()) - } +func TestClusterService_CreateCluster_GKEFailsBeforeAnyCommand(t *testing.T) { + mock := executor.NewMockCommandExecutor() + service := NewClusterService(mock) + + _, err := service.CreateCluster(context.Background(), models.ClusterConfig{ + Name: "cloud-cluster", + Type: models.ClusterTypeGKE, + NodeCount: 1, + }) + + var notFound models.ErrProviderNotFound + if !errors.As(err, ¬Found) { + t.Fatalf("expected ErrProviderNotFound for gke, got %v", err) + } + if mock.GetCommandCount() != 0 { + t.Errorf("no commands should run for gke, got: %v", mock.GetExecutedCommands()) } } -func TestClusterService_DeleteCluster_CloudTypeFailsBeforeAnyCommand(t *testing.T) { +func TestClusterService_CreateCluster_EKSWithoutRegionFailsBeforeAnyCommand(t *testing.T) { + t.Setenv("OPENFRAME_CLUSTERS_DIR", t.TempDir()) mock := executor.NewMockCommandExecutor() service := NewClusterService(mock) - err := service.DeleteCluster(context.Background(), "cloud-cluster", models.ClusterTypeEKS, false) + _, err := service.CreateCluster(context.Background(), models.ClusterConfig{ + Name: "cloud-cluster", + Type: models.ClusterTypeEKS, + NodeCount: 1, + }) + + var invalid models.ErrInvalidClusterConfig + if !errors.As(err, &invalid) { + t.Fatalf("expected ErrInvalidClusterConfig for eks without region, got %v", err) + } + if mock.GetCommandCount() != 0 { + t.Errorf("no commands should run before validation passes, got: %v", mock.GetExecutedCommands()) + } +} + +func TestClusterService_DeleteCluster_GKEFailsBeforeAnyCommand(t *testing.T) { + mock := executor.NewMockCommandExecutor() + service := NewClusterService(mock) + + err := service.DeleteCluster(context.Background(), "cloud-cluster", models.ClusterTypeGKE, false) var notFound models.ErrProviderNotFound if !errors.As(err, ¬Found) { @@ -95,6 +115,43 @@ func TestClusterService_DeleteCluster_CloudTypeFailsBeforeAnyCommand(t *testing. } } +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/ui/operations.go b/internal/cluster/ui/operations.go index e2778ede..87e4a1bf 100644 --- a/internal/cluster/ui/operations.go +++ b/internal/cluster/ui/operations.go @@ -336,6 +336,13 @@ func (ui *OperationsUI) ShowConfigurationSummary(config models.ClusterConfig, dr if config.K8sVersion != "" { pterm.DefaultBasicText.Printf("Version: %s\n", config.K8sVersion) } + if config.Cloud != nil { + 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("Cloud clusters create AWS resources that incur costs (EKS control plane, EC2 nodes, NAT gateway)") + } pterm.DefaultBasicText.Println() diff --git a/internal/cluster/ui/wizard.go b/internal/cluster/ui/wizard.go index de168d25..eaff1273 100644 --- a/internal/cluster/ui/wizard.go +++ b/internal/cluster/ui/wizard.go @@ -14,6 +14,27 @@ type ClusterConfig struct { Type models.ClusterType NodeCount int K8sVersion string + // Cloud-only answers (EKS) + Region 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 { + domain.Cloud = &models.CloudConfig{ + Region: c.Region, + MachineType: c.MachineType, + } + } + return domain } // ConfigWizard provides interactive configuration for cluster creation @@ -56,30 +77,48 @@ func (w *ConfigWizard) Run() (ClusterConfig, error) { } w.config.Name = name - // Cluster type is not prompted: k3d is the only implemented backend. When a - // cloud backend lands, reintroduce a type step here with real options. - - // Step 2: Node count - nodeCount, err := steps.PromptNodeCount(w.config.NodeCount) + // Step 2: Cluster type + clusterType, err := steps.PromptClusterType() if err != nil { return ClusterConfig{}, err } - w.config.NodeCount = nodeCount + w.config.Type = clusterType + + // Step 3 (cloud only): region + instance type. The k3s version list below + // is meaningless for EKS, whose version comes from the module default. + if clusterType == models.ClusterTypeEKS { + region, err := steps.PromptRegion("us-east-1") + if err != nil { + return ClusterConfig{}, err + } + w.config.Region = region - // Step 3: Kubernetes version - k8sVersion, err := steps.PromptK8sVersion() + machineType, err := steps.PromptMachineType("m6i.large") + 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.K8sVersion = k8sVersion + w.config.NodeCount = nodeCount - // Step 4: Confirmation - domainConfig := models.ClusterConfig{ - Name: w.config.Name, - Type: w.config.Type, - NodeCount: w.config.NodeCount, - K8sVersion: w.config.K8sVersion, + // Step 5 (k3d only): Kubernetes version + if clusterType == models.ClusterTypeK3d { + k8sVersion, err := steps.PromptK8sVersion() + if err != nil { + return ClusterConfig{}, err + } + w.config.K8sVersion = k8sVersion } + + // Step 6: Confirmation + domainConfig := w.config.ToDomain() confirmed, err := steps.ConfirmConfiguration(domainConfig) if err != nil { return ClusterConfig{}, err @@ -174,11 +213,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 1675035c..525ef9b5 100644 --- a/internal/cluster/ui/wizard_steps.go +++ b/internal/cluster/ui/wizard_steps.go @@ -41,6 +41,60 @@ func (ws *WizardSteps) PromptClusterName(defaultName string) (string, error) { return strings.TrimSpace(result), nil } +// 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)", + "eks (AWS Elastic Kubernetes Service — provisions cloud resources that cost money)", + }, + Templates: &promptui.SelectTemplates{ + Label: "{{ . }}:", + Active: "→ {{ . | cyan }}", + Inactive: " {{ . }}", + Selected: "{{ . | green }}", + }, + } + + idx, _, err := prompt.Run() + if err != nil { + return "", err + } + if idx == 1 { + return models.ClusterTypeEKS, nil + } + return models.ClusterTypeK3d, 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{ @@ -95,6 +149,12 @@ func (ws *WizardSteps) ConfirmConfiguration(config models.ClusterConfig) (bool, {"Node Count", strconv.Itoa(config.NodeCount)}, {"Kubernetes Version", config.K8sVersion}, } + if config.Cloud != nil { + data = append(data, []string{"Region", config.Cloud.Region}) + if config.Cloud.MachineType != "" { + data = append(data, []string{"Instance Type", config.Cloud.MachineType}) + } + } // Use pterm for consistent styling if err := renderConfigurationTable(data); err != nil { diff --git a/internal/platform/platform.go b/internal/platform/platform.go index 6e451dd6..2b6a10ff 100644 --- a/internal/platform/platform.go +++ b/internal/platform/platform.go @@ -84,6 +84,18 @@ 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: Download from https://developer.hashicorp.com/terraform/install", + Default: "terraform: Please install terraform from https://developer.hashicorp.com/terraform/install", + }, + "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/download/pins.go b/internal/shared/download/pins.go index b713a7fb..755c7c38 100644 --- a/internal/shared/download/pins.go +++ b/internal/shared/download/pins.go @@ -94,6 +94,32 @@ 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 inside; SHA256 from the +// release's SHA256SUMS file. +const ( + terraformVersion = "1.15.8" + terraformBaseURL = "https://releases.hashicorp.com/terraform/" + terraformVersion + "/terraform_" + terraformVersion + "_" + + terraformSHA256LinuxAMD64 = "d25ce7b6902013ad905db3d2eab0be4cd905887fe88b81a6171b8d5503c31f3d" + terraformSHA256LinuxARM64 = "8891e9dcedc9e3b8950bc6af9d4d8af1f4cfade3062f53b9dc403a89f6ce8c9c" + terraformSHA256DarwinAMD64 = "e2e812e783771159bf758fd4e55d6dc9bb08f63e2af2c63d212721807a02c5dc" + terraformSHA256DarwinARM64 = "f210110c5698b94d803a7a63cdb0251b5455c150841478808e2bbb343f95ed68" +) + +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}, + }, +} + // 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) { @@ -124,6 +150,12 @@ func (d Downloader) InstallPinnedTool(ctx context.Context, tool PinnedTool, binD } return dest, nil } + if tool.Zip { + if err := d.InstallVerifiedZipMember(ctx, asset, tool.Name, dest, 0o750); err != nil { + return "", err + } + return dest, nil + } if err := d.InstallVerified(ctx, asset, dest, 0o750); err != nil { return "", err } 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 { From f2b2bee059a719ed1cbd98c0dda3defa2ac05336 Mon Sep 17 00:00:00 2001 From: Oleg Tkachuk Date: Thu, 16 Jul 2026 00:34:21 +0300 Subject: [PATCH 04/38] feat(cluster): provision GKE clusters via Terraform (#226) --- cmd/cluster/create.go | 3 +- cmd/cluster/create_behavior_test.go | 51 ++-- internal/cluster/models/flags.go | 39 +-- internal/cluster/prerequisites/checker.go | 40 ++- .../cluster/prerequisites/gcloud/gcloud.go | 86 +++++++ internal/cluster/provider/factory.go | 6 +- internal/cluster/provider/factory_test.go | 18 +- internal/cluster/provider/provider.go | 2 + internal/cluster/providers/gke/kubeconfig.go | 103 ++++++++ internal/cluster/providers/gke/provider.go | 241 ++++++++++++++++++ .../cluster/providers/gke/provider_test.go | 232 +++++++++++++++++ internal/cluster/providers/gke/template.go | 85 ++++++ .../cluster/providers/gke/templates/main.tf | 155 +++++++++++ .../cluster/providers/terraform/workspace.go | 3 +- internal/cluster/service.go | 27 +- internal/cluster/service_test.go | 80 +++--- internal/cluster/ui/operations.go | 5 +- internal/cluster/ui/wizard.go | 28 +- internal/cluster/ui/wizard_steps.go | 25 +- internal/platform/platform.go | 12 + 20 files changed, 1103 insertions(+), 138 deletions(-) create mode 100644 internal/cluster/prerequisites/gcloud/gcloud.go create mode 100644 internal/cluster/providers/gke/kubeconfig.go create mode 100644 internal/cluster/providers/gke/provider.go create mode 100644 internal/cluster/providers/gke/provider_test.go create mode 100644 internal/cluster/providers/gke/template.go create mode 100644 internal/cluster/providers/gke/templates/main.tf diff --git a/cmd/cluster/create.go b/cmd/cluster/create.go index a30498f5..dd5c846e 100644 --- a/cmd/cluster/create.go +++ b/cmd/cluster/create.go @@ -127,11 +127,12 @@ func runCreateCluster(cmd *cobra.Command, args []string) error { // Cloud settings only exist for cloud types; the k3d backend rejects a // non-nil Cloud by design. - if config.Type == models.ClusterTypeEKS { + 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, diff --git a/cmd/cluster/create_behavior_test.go b/cmd/cluster/create_behavior_test.go index ccbdf5cc..7bd988ba 100644 --- a/cmd/cluster/create_behavior_test.go +++ b/cmd/cluster/create_behavior_test.go @@ -1,7 +1,6 @@ package cluster import ( - "strings" "testing" "github.com/flamingo-stack/openframe-cli/internal/cluster/utils" @@ -73,37 +72,25 @@ func TestRunCreateCluster_DryRunDefaultsNameWhenNoArgs(t *testing.T) { } } -func TestRunCreateCluster_GKEFailsWithProviderNotFound(t *testing.T) { - setupCreate(t) - cmd := getCreateCmd() - gf := utils.GetGlobalFlags() - gf.Create.SkipWizard = true - gf.Create.ClusterType = "gke" - - // gke passes flag validation (recognized) and the prerequisite gate (no - // backend → no tools), then fails at the provider factory. - err := runCreateCluster(cmd, []string{"cloud-cluster"}) - if err == nil { - t.Fatal("expected ErrProviderNotFound for gke") - } - if !strings.Contains(err.Error(), "no provider available for cluster type") { - t.Fatalf("expected provider-not-found error, got: %v", err) - } -} - -func TestRunCreateCluster_EKSDryRunShowsPlanAndExits(t *testing.T) { - setupCreate(t) - cmd := getCreateCmd() - gf := utils.GetGlobalFlags() - gf.Create.SkipWizard = true - gf.Create.DryRun = true - gf.Create.ClusterType = "eks" - gf.Create.Region = "us-east-1" - - // Dry-run exits after the summary — before the prerequisite gate (which may - // install tools) and before any terraform runs, so this is hermetic. - if err := runCreateCluster(cmd, []string{"cloud-cluster"}); err != nil { - t.Fatalf("eks dry-run should return nil, got %v", err) +func TestRunCreateCluster_CloudDryRunShowsSummaryAndExits(t *testing.T) { + for _, clusterType := range []string{"eks", "gke"} { + t.Run(clusterType, func(t *testing.T) { + setupCreate(t) + 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" + + // Dry-run exits after the summary — before the prerequisite gate + // (which may install tools) and before any terraform runs, so this + // is hermetic. + if err := runCreateCluster(cmd, []string{"cloud-cluster"}); err != nil { + t.Fatalf("%s dry-run should return nil, got %v", clusterType, err) + } + }) } } diff --git a/internal/cluster/models/flags.go b/internal/cluster/models/flags.go index 1c37308d..6622fc58 100644 --- a/internal/cluster/models/flags.go +++ b/internal/cluster/models/flags.go @@ -20,9 +20,10 @@ type CreateFlags struct { K8sVersion string SkipWizard bool - // Cloud-only flags (EKS) + // Cloud-only flags (EKS/GKE) Region string - Profile string + Profile string // AWS + Project string // GCP MachineType string MinNodes int MaxNodes int @@ -64,17 +65,18 @@ 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, eks)") + 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 --type eks)") + 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.MachineType, "machine-type", "", "Node instance type (eks only; default m6i.large)") - cmd.Flags().IntVar(&flags.MinNodes, "min-nodes", 0, "Node group minimum size (eks only)") - cmd.Flags().IntVar(&flags.MaxNodes, "max-nodes", 0, "Node group maximum size (eks only)") - cmd.Flags().BoolVar(&flags.Spot, "spot", false, "Use spot capacity for nodes (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)") } // AddListFlags adds list-specific flags to a command @@ -146,20 +148,23 @@ func ValidateCreateFlags(flags *CreateFlags) error { return err } - // Reject unknown --type values up front. GKE is recognized but has no - // backend yet — it passes here and fails with ErrProviderNotFound at the - // provider factory, so the two cases stay distinguishable. - switch ClusterType(flags.ClusterType) { + // 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)", flags.ClusterType) + return fmt.Errorf("unknown cluster type '%s' (supported: k3d, eks, gke)", flags.ClusterType) } - // The wizard prompts for the region; in skip-wizard mode it must come from - // the flag. - if ClusterType(flags.ClusterType) == ClusterTypeEKS && flags.SkipWizard && flags.Region == "" { - return fmt.Errorf("--region is required for --type eks with --skip-wizard") + // 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.MinNodes < 0 || flags.MaxNodes < 0 { return fmt.Errorf("node bounds must not be negative: min=%d max=%d", flags.MinNodes, flags.MaxNodes) diff --git a/internal/cluster/prerequisites/checker.go b/internal/cluster/prerequisites/checker.go index 0e0f530b..a175968a 100644 --- a/internal/cluster/prerequisites/checker.go +++ b/internal/cluster/prerequisites/checker.go @@ -4,6 +4,7 @@ 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" @@ -96,21 +97,54 @@ 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()) } // CheckForClusterType runs the prerequisite gate for the given cluster type: -// Docker/k3d/helm for local k3d clusters, terraform + AWS CLI for EKS. GKE has -// no backend yet, so it passes through and fails at the provider factory -// instead of demanding tools it will never use. +// Docker/k3d/helm for local k3d clusters, terraform + the cloud CLI for the +// cloud types. Unknown types pass through and fail at the provider factory. func CheckForClusterType(clusterType models.ClusterType) error { switch clusterType { case models.ClusterTypeK3d, "": return CheckPrerequisites() case models.ClusterTypeEKS: return NewInstallerWithChecker(NewEKSPrerequisiteChecker()).CheckAndInstallNonInteractive(ui.IsNonInteractive()) + case models.ClusterTypeGKE: + return NewInstallerWithChecker(NewGKEPrerequisiteChecker()).CheckAndInstallNonInteractive(ui.IsNonInteractive()) default: return nil } 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/provider/factory.go b/internal/cluster/provider/factory.go index 45de1c40..900c5d9c 100644 --- a/internal/cluster/provider/factory.go +++ b/internal/cluster/provider/factory.go @@ -3,14 +3,14 @@ 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). GKE is a recognized type without a -// backend yet, so it returns ErrProviderNotFound; an unrecognized type is a +// 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 { @@ -21,7 +21,7 @@ func New(clusterType models.ClusterType, exec executor.CommandExecutor) (Provide // terraform engine stream terraform's own output during long applies. return eks.New(exec, pterm.PrintDebugMessages) case models.ClusterTypeGKE: - return nil, models.NewProviderNotFoundError(clusterType) + 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 index e06f6b6e..0f002a74 100644 --- a/internal/cluster/provider/factory_test.go +++ b/internal/cluster/provider/factory_test.go @@ -24,19 +24,13 @@ func TestNew(t *testing.T) { assert.NotNil(t, p) }) - t.Run("eks returns a provider", func(t *testing.T) { + t.Run("cloud types return providers", func(t *testing.T) { t.Setenv("OPENFRAME_CLUSTERS_DIR", t.TempDir()) - p, err := New(models.ClusterTypeEKS, exec) - assert.NoError(t, err) - assert.NotNil(t, p) - }) - - t.Run("gke has no backend yet and returns ErrProviderNotFound", func(t *testing.T) { - p, err := New(models.ClusterTypeGKE, exec) - assert.Nil(t, p) - var notFound models.ErrProviderNotFound - assert.True(t, errors.As(err, ¬Found), "expected ErrProviderNotFound for gke, got %v", err) - assert.Equal(t, models.ClusterTypeGKE, notFound.ClusterType) + 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) { diff --git a/internal/cluster/provider/provider.go b/internal/cluster/provider/provider.go index 7d011b7d..482391f1 100644 --- a/internal/cluster/provider/provider.go +++ b/internal/cluster/provider/provider.go @@ -12,6 +12,7 @@ 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" "k8s.io/client-go/rest" ) @@ -48,4 +49,5 @@ type Provider interface { var ( _ Provider = (*k3d.K3dManager)(nil) _ Provider = (*eks.Provider)(nil) + _ Provider = (*gke.Provider)(nil) ) 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..0d1bc71f --- /dev/null +++ b/internal/cluster/providers/gke/provider.go @@ -0,0 +1,241 @@ +// 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" + "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 +} + +// 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 + } + 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 + } + } + // 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..ef790014 --- /dev/null +++ b/internal/cluster/providers/gke/provider_test.go @@ -0,0 +1,232 @@ +package gke + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "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" + "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) Destroy(ctx context.Context, 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 true, 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") +} 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/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/terraform/workspace.go b/internal/cluster/providers/terraform/workspace.go index 6737a12b..b092ac3f 100644 --- a/internal/cluster/providers/terraform/workspace.go +++ b/internal/cluster/providers/terraform/workspace.go @@ -41,7 +41,8 @@ type Record struct { Type models.ClusterType `json:"type"` Status Status `json:"status"` Region string `json:"region"` - Profile string `json:"profile,omitempty"` + 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"` diff --git a/internal/cluster/service.go b/internal/cluster/service.go index 812dae5c..10fb3f57 100644 --- a/internal/cluster/service.go +++ b/internal/cluster/service.go @@ -221,15 +221,18 @@ func (s *ClusterService) DeleteCluster(ctx context.Context, name string, cluster return nil } -// cloudProvider returns the EKS backend, or nil when it cannot be built (its -// registry is plain files under ~/.openframe, so this practically never -// fails; a nil just degrades the CLI to local-only visibility). -func (s *ClusterService) cloudProvider() provider.Provider { - p, err := s.providerFor(models.ClusterTypeEKS) - if err != nil { - return nil +// 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 p + return providers } // ListClusters merges the local k3d clusters with the cloud clusters recorded @@ -240,7 +243,7 @@ func (s *ClusterService) ListClusters() ([]models.ClusterInfo, error) { if err != nil { return nil, err } - if cloud := s.cloudProvider(); cloud != nil { + for _, cloud := range s.cloudProviders() { cloudClusters, err := cloud.ListAllClusters(ctx) if err != nil { return nil, err @@ -253,7 +256,7 @@ func (s *ClusterService) ListClusters() ([]models.ClusterInfo, error) { // GetClusterStatus handles cluster status business logic func (s *ClusterService) GetClusterStatus(name string) (models.ClusterInfo, error) { ctx := context.Background() - if cloud := s.cloudProvider(); cloud != nil { + for _, cloud := range s.cloudProviders() { if info, err := cloud.GetClusterStatus(ctx, name); err == nil { return info, nil } @@ -264,7 +267,7 @@ func (s *ClusterService) GetClusterStatus(name string) (models.ClusterInfo, erro // GetRestConfig returns the rest.Config for an existing cluster func (s *ClusterService) GetRestConfig(name string) (*rest.Config, error) { ctx := context.Background() - if cloud := s.cloudProvider(); cloud != nil { + for _, cloud := range s.cloudProviders() { if _, err := cloud.DetectClusterType(ctx, name); err == nil { return cloud.GetRestConfig(ctx, name) } @@ -276,7 +279,7 @@ func (s *ClusterService) GetRestConfig(name string) (*rest.Config, error) { // read), then falls back to k3d discovery. func (s *ClusterService) DetectClusterType(name string) (models.ClusterType, error) { ctx := context.Background() - if cloud := s.cloudProvider(); cloud != nil { + for _, cloud := range s.cloudProviders() { if t, err := cloud.DetectClusterType(ctx, name); err == nil { return t, nil } diff --git a/internal/cluster/service_test.go b/internal/cluster/service_test.go index 23885ba1..49189a94 100644 --- a/internal/cluster/service_test.go +++ b/internal/cluster/service_test.go @@ -61,57 +61,43 @@ func TestClusterService_CreateCluster(t *testing.T) { _ = err } -func TestClusterService_CreateCluster_GKEFailsBeforeAnyCommand(t *testing.T) { - mock := executor.NewMockCommandExecutor() - service := NewClusterService(mock) - - _, err := service.CreateCluster(context.Background(), models.ClusterConfig{ - Name: "cloud-cluster", - Type: models.ClusterTypeGKE, - NodeCount: 1, - }) - - var notFound models.ErrProviderNotFound - if !errors.As(err, ¬Found) { - t.Fatalf("expected ErrProviderNotFound for gke, got %v", err) - } - if mock.GetCommandCount() != 0 { - t.Errorf("no commands should run for gke, got: %v", mock.GetExecutedCommands()) - } -} - -func TestClusterService_CreateCluster_EKSWithoutRegionFailsBeforeAnyCommand(t *testing.T) { +func TestClusterService_CreateCluster_CloudWithoutRegionFailsBeforeAnyCommand(t *testing.T) { t.Setenv("OPENFRAME_CLUSTERS_DIR", t.TempDir()) - mock := executor.NewMockCommandExecutor() - service := NewClusterService(mock) - - _, err := service.CreateCluster(context.Background(), models.ClusterConfig{ - Name: "cloud-cluster", - Type: models.ClusterTypeEKS, - NodeCount: 1, - }) - - var invalid models.ErrInvalidClusterConfig - if !errors.As(err, &invalid) { - t.Fatalf("expected ErrInvalidClusterConfig for eks without region, got %v", err) - } - if mock.GetCommandCount() != 0 { - t.Errorf("no commands should run before validation passes, got: %v", mock.GetExecutedCommands()) + 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_GKEFailsBeforeAnyCommand(t *testing.T) { - mock := executor.NewMockCommandExecutor() - service := NewClusterService(mock) - - err := service.DeleteCluster(context.Background(), "cloud-cluster", models.ClusterTypeGKE, false) - - var notFound models.ErrProviderNotFound - if !errors.As(err, ¬Found) { - t.Fatalf("expected ErrProviderNotFound, got %v", err) - } - if mock.GetCommandCount() != 0 { - t.Errorf("no commands should run for unsupported type, 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()) + } } } diff --git a/internal/cluster/ui/operations.go b/internal/cluster/ui/operations.go index 87e4a1bf..4e9c7c77 100644 --- a/internal/cluster/ui/operations.go +++ b/internal/cluster/ui/operations.go @@ -337,11 +337,14 @@ func (ui *OperationsUI) ShowConfigurationSummary(config models.ClusterConfig, dr 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("Cloud clusters create AWS resources that incur costs (EKS control plane, EC2 nodes, NAT gateway)") + pterm.Warning.Println("Cloud clusters create resources that incur costs (managed control plane, VM nodes, NAT)") } pterm.DefaultBasicText.Println() diff --git a/internal/cluster/ui/wizard.go b/internal/cluster/ui/wizard.go index eaff1273..3d025d03 100644 --- a/internal/cluster/ui/wizard.go +++ b/internal/cluster/ui/wizard.go @@ -14,8 +14,9 @@ type ClusterConfig struct { Type models.ClusterType NodeCount int K8sVersion string - // Cloud-only answers (EKS) + // Cloud-only answers (EKS/GKE) Region string + Project string MachineType string } @@ -28,9 +29,10 @@ func (c ClusterConfig) ToDomain() models.ClusterConfig { NodeCount: c.NodeCount, K8sVersion: c.K8sVersion, } - if c.Type == models.ClusterTypeEKS { + if c.Type == models.ClusterTypeEKS || c.Type == models.ClusterTypeGKE { domain.Cloud = &models.CloudConfig{ Region: c.Region, + Project: c.Project, MachineType: c.MachineType, } } @@ -84,16 +86,28 @@ func (w *ConfigWizard) Run() (ClusterConfig, error) { } w.config.Type = clusterType - // Step 3 (cloud only): region + instance type. The k3s version list below - // is meaningless for EKS, whose version comes from the module default. - if clusterType == models.ClusterTypeEKS { - region, err := steps.PromptRegion("us-east-1") + // 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("m6i.large") + machineType, err := steps.PromptMachineType(defaultMachine) if err != nil { return ClusterConfig{}, err } diff --git a/internal/cluster/ui/wizard_steps.go b/internal/cluster/ui/wizard_steps.go index 525ef9b5..dbb06fc8 100644 --- a/internal/cluster/ui/wizard_steps.go +++ b/internal/cluster/ui/wizard_steps.go @@ -48,6 +48,7 @@ func (ws *WizardSteps) PromptClusterType() (models.ClusterType, error) { 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: "{{ . }}:", @@ -61,10 +62,27 @@ func (ws *WizardSteps) PromptClusterType() (models.ClusterType, error) { if err != nil { return "", err } - if idx == 1 { + switch idx { + case 1: return models.ClusterTypeEKS, nil + case 2: + return models.ClusterTypeGKE, nil + default: + return models.ClusterTypeK3d, nil } - 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. @@ -150,6 +168,9 @@ func (ws *WizardSteps) ConfirmConfiguration(config models.ClusterConfig) (bool, {"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}) diff --git a/internal/platform/platform.go b/internal/platform/platform.go index 2b6a10ff..ce18bf78 100644 --- a/internal/platform/platform.go +++ b/internal/platform/platform.go @@ -90,6 +90,18 @@ var toolDocs = map[string]InstallDocs{ Windows: "terraform: 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", From da67bd37ce08676295bb6fb21ee8848dd690de6e Mon Sep 17 00:00:00 2001 From: Oleg Tkachuk Date: Thu, 16 Jul 2026 10:56:22 +0300 Subject: [PATCH 05/38] =?UTF-8?q?feat(cluster):=20cloud=20UX=20polish=20?= =?UTF-8?q?=E2=80=94=20plan=20preview,=20progress,=20guarded=20destroy=20(?= =?UTF-8?q?#227)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/cluster/create.go | 64 +++++++++-- cmd/cluster/create_behavior_test.go | 45 +++++++- cmd/cluster/delete.go | 31 ++++++ cmd/cluster/delete_test.go | 37 +++++++ docs/architecture/decisions.md | 51 +++++++-- internal/cluster/models/cluster.go | 4 + internal/cluster/models/flags.go | 19 ++-- internal/cluster/models/flags_test.go | 14 +++ internal/cluster/provider/provider.go | 11 ++ internal/cluster/providers/eks/provider.go | 71 ++++++++++++ .../cluster/providers/eks/provider_test.go | 58 ++++++++++ internal/cluster/providers/gke/provider.go | 67 ++++++++++++ .../cluster/providers/gke/provider_test.go | 57 ++++++++++ .../cluster/providers/terraform/backend.go | 47 ++++++++ .../providers/terraform/backend_test.go | 44 ++++++++ .../cluster/providers/terraform/engine.go | 65 +++++++++-- .../providers/terraform/engine_test.go | 103 ++++++++++++++++-- .../cluster/providers/terraform/progress.go | 69 ++++++++++++ .../cluster/providers/terraform/workspace.go | 25 +++-- internal/cluster/service.go | 12 +- internal/cluster/ui/operations.go | 2 +- internal/cluster/ui/prompts.go | 32 ++++++ internal/cluster/ui/wizard_steps.go | 4 + 23 files changed, 873 insertions(+), 59 deletions(-) create mode 100644 internal/cluster/providers/terraform/backend.go create mode 100644 internal/cluster/providers/terraform/backend_test.go create mode 100644 internal/cluster/providers/terraform/progress.go diff --git a/cmd/cluster/create.go b/cmd/cluster/create.go index dd5c846e..7b4741a5 100644 --- a/cmd/cluster/create.go +++ b/cmd/cluster/create.go @@ -1,13 +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" ) @@ -64,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() @@ -130,13 +173,14 @@ func runCreateCluster(cmd *cobra.Command, args []string) error { 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, + Region: cf.Region, + Profile: cf.Profile, + Project: cf.Project, + MachineType: cf.MachineType, + MinNodes: cf.MinNodes, + MaxNodes: cf.MaxNodes, + Spot: cf.Spot, + BackendConfig: cf.BackendConfig, } } } @@ -146,8 +190,12 @@ 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 } } diff --git a/cmd/cluster/create_behavior_test.go b/cmd/cluster/create_behavior_test.go index 7bd988ba..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,10 +74,19 @@ func TestRunCreateCluster_DryRunDefaultsNameWhenNoArgs(t *testing.T) { } } -func TestRunCreateCluster_CloudDryRunShowsSummaryAndExits(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 @@ -84,16 +95,42 @@ func TestRunCreateCluster_CloudDryRunShowsSummaryAndExits(t *testing.T) { gf.Create.Region = "us-east-1" gf.Create.Project = "my-project" - // Dry-run exits after the summary — before the prerequisite gate - // (which may install tools) and before any terraform runs, so this - // is hermetic. 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/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/internal/cluster/models/cluster.go b/internal/cluster/models/cluster.go index e459f153..11c48f34 100644 --- a/internal/cluster/models/cluster.go +++ b/internal/cluster/models/cluster.go @@ -32,6 +32,10 @@ type CloudConfig struct { 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 diff --git a/internal/cluster/models/flags.go b/internal/cluster/models/flags.go index 6622fc58..1f04e61e 100644 --- a/internal/cluster/models/flags.go +++ b/internal/cluster/models/flags.go @@ -21,13 +21,14 @@ type CreateFlags struct { SkipWizard bool // Cloud-only flags (EKS/GKE) - Region string - Profile string // AWS - Project string // GCP - MachineType string - MinNodes int - MaxNodes int - Spot bool + 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 @@ -77,6 +78,7 @@ func AddCreateFlags(cmd *cobra.Command, flags *CreateFlags) { 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 @@ -166,6 +168,9 @@ func ValidateCreateFlags(flags *CreateFlags) error { 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) } diff --git a/internal/cluster/models/flags_test.go b/internal/cluster/models/flags_test.go index 575f7c29..26ff3f07 100644 --- a/internal/cluster/models/flags_test.go +++ b/internal/cluster/models/flags_test.go @@ -332,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/provider/provider.go b/internal/cluster/provider/provider.go index 482391f1..3db1e9da 100644 --- a/internal/cluster/provider/provider.go +++ b/internal/cluster/provider/provider.go @@ -14,6 +14,7 @@ import ( "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" ) @@ -41,6 +42,14 @@ type Provider interface { GetKubeconfig(ctx context.Context, name string, clusterType models.ClusterType) (string, error) } +// 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. // // Backends are selected through New (factory.go). The old decorative factory @@ -50,4 +59,6 @@ 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/provider.go b/internal/cluster/providers/eks/provider.go index 96ef8867..938e2869 100644 --- a/internal/cluster/providers/eks/provider.go +++ b/internal/cluster/providers/eks/provider.go @@ -8,6 +8,7 @@ package eks import ( "context" "fmt" + "os" "strings" "time" @@ -61,6 +62,67 @@ func (p *Provider) preflightCredentials(ctx context.Context, profile string) 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. @@ -68,6 +130,10 @@ func (p *Provider) CreateCluster(ctx context.Context, config models.ClusterConfi 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 } @@ -91,6 +157,11 @@ func (p *Provider) CreateCluster(ctx context.Context, config models.ClusterConfi 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. diff --git a/internal/cluster/providers/eks/provider_test.go b/internal/cluster/providers/eks/provider_test.go index 388c4a5f..420d46e9 100644 --- a/internal/cluster/providers/eks/provider_test.go +++ b/internal/cluster/providers/eks/provider_test.go @@ -5,6 +5,7 @@ import ( "encoding/base64" "encoding/json" "errors" + "io" "os" "path/filepath" "testing" @@ -13,6 +14,7 @@ import ( 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" ) @@ -35,16 +37,31 @@ func (f *fakeRunner) Apply(ctx context.Context, opts ...tfexec.ApplyOption) erro 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{ @@ -233,3 +250,44 @@ func TestTemplateEmbedsModulePins(t *testing.T) { 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/gke/provider.go b/internal/cluster/providers/gke/provider.go index 0d1bc71f..6a573b11 100644 --- a/internal/cluster/providers/gke/provider.go +++ b/internal/cluster/providers/gke/provider.go @@ -9,6 +9,7 @@ package gke import ( "context" "fmt" + "os" "strings" "time" @@ -57,12 +58,73 @@ func (p *Provider) preflightCredentials(ctx context.Context, project string) 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 } @@ -86,6 +148,11 @@ func (p *Provider) CreateCluster(ctx context.Context, config models.ClusterConfi 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. diff --git a/internal/cluster/providers/gke/provider_test.go b/internal/cluster/providers/gke/provider_test.go index ef790014..92be8487 100644 --- a/internal/cluster/providers/gke/provider_test.go +++ b/internal/cluster/providers/gke/provider_test.go @@ -5,6 +5,7 @@ import ( "encoding/base64" "encoding/json" "errors" + "io" "os" "path/filepath" "testing" @@ -13,6 +14,7 @@ import ( 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" ) @@ -35,16 +37,31 @@ func (f *fakeRunner) Apply(ctx context.Context, opts ...tfexec.ApplyOption) erro 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{ @@ -230,3 +247,43 @@ func TestTemplateEmbedsModulePins(t *testing.T) { 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/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 index 4b65ddc7..bfc4617b 100644 --- a/internal/cluster/providers/terraform/engine.go +++ b/internal/cluster/providers/terraform/engine.go @@ -4,11 +4,14 @@ 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 @@ -16,8 +19,11 @@ import ( 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) } @@ -84,42 +90,79 @@ func (e *Engine) Init(ctx context.Context, dir string) error { return nil } -// Apply runs terraform apply in dir. It is idempotent: re-running after a -// partial failure resumes from the recorded state. +// 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.Apply(ctx); err != nil { + 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. +// 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.Destroy(ctx); err != nil { + if err := tf.DestroyJSON(ctx, newProgressWriter(e.verbose)); err != nil { return fmt.Errorf("terraform destroy failed: %w", err) } return nil } -// Plan runs terraform plan in dir and reports whether changes are pending. -func (e *Engine) Plan(ctx context.Context, dir string) (bool, error) { +// 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 false, err + return PlanSummary{}, err } - changes, err := tf.Plan(ctx) + planFile := filepath.Join(dir, "tfplan") + defer func() { _ = os.Remove(planFile) }() + + changes, err := tf.Plan(ctx, tfexec.Out(planFile)) if err != nil { - return false, fmt.Errorf("terraform plan failed: %w", err) + return PlanSummary{}, fmt.Errorf("terraform plan failed: %w", err) + } + if !changes { + return PlanSummary{}, nil } - return changes, 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. diff --git a/internal/cluster/providers/terraform/engine_test.go b/internal/cluster/providers/terraform/engine_test.go index 4111f5cf..6818a2f6 100644 --- a/internal/cluster/providers/terraform/engine_test.go +++ b/internal/cluster/providers/terraform/engine_test.go @@ -4,19 +4,24 @@ 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 + 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 { @@ -29,14 +34,32 @@ func (f *fakeRunner) Apply(ctx context.Context, opts ...tfexec.ApplyOption) erro 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 true, nil + 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) { @@ -57,9 +80,9 @@ func TestEngine_LifecycleCalls(t *testing.T) { require.NoError(t, e.Init(ctx, "dir")) require.NoError(t, e.Apply(ctx, "dir")) - changes, err := e.Plan(ctx, "dir") + changes, err := e.Plan(ctx, t.TempDir()) require.NoError(t, err) - assert.True(t, changes) + 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") @@ -71,6 +94,72 @@ func TestEngine_LifecycleCalls(t *testing.T) { 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") 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 index b092ac3f..19e1487c 100644 --- a/internal/cluster/providers/terraform/workspace.go +++ b/internal/cluster/providers/terraform/workspace.go @@ -84,22 +84,29 @@ func (w *Workspace) Exists() bool { return err == nil } -// 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 := os.MkdirAll(w.TerraformDir(), 0o750); err != nil { - return fmt.Errorf("creating workspace %s: %w", w.dir, err) +// 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(w.TerraformDir(), "main.tf"), mainTF, 0o600); err != nil { + if err := os.WriteFile(filepath.Join(dir, "main.tf"), mainTF, 0o600); err != nil { return err } - if err := os.WriteFile(filepath.Join(w.TerraformDir(), "terraform.tfvars.json"), varsJSON, 0o600); err != nil { + 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) diff --git a/internal/cluster/service.go b/internal/cluster/service.go index 10fb3f57..0ba893c4 100644 --- a/internal/cluster/service.go +++ b/internal/cluster/service.go @@ -153,13 +153,14 @@ func (s *ClusterService) CreateCluster(ctx context.Context, config models.Cluste 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) } @@ -195,9 +196,10 @@ func (s *ClusterService) DeleteCluster(ctx context.Context, name string, cluster return err } - // Show deletion progress + // 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 { diff --git a/internal/cluster/ui/operations.go b/internal/cluster/ui/operations.go index 4e9c7c77..ae213579 100644 --- a/internal/cluster/ui/operations.go +++ b/internal/cluster/ui/operations.go @@ -344,7 +344,7 @@ func (ui *OperationsUI) ShowConfigurationSummary(config models.ClusterConfig, dr if config.Cloud.MachineType != "" { pterm.DefaultBasicText.Printf("Instance: %s\n", config.Cloud.MachineType) } - pterm.Warning.Println("Cloud clusters create resources that incur costs (managed control plane, VM nodes, NAT)") + pterm.Warning.Println(CostHint(config.Type)) } pterm.DefaultBasicText.Println() diff --git a/internal/cluster/ui/prompts.go b/internal/cluster/ui/prompts.go index 5dabee64..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" ) @@ -49,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/wizard_steps.go b/internal/cluster/ui/wizard_steps.go index dbb06fc8..d78d88f6 100644 --- a/internal/cluster/ui/wizard_steps.go +++ b/internal/cluster/ui/wizard_steps.go @@ -177,6 +177,10 @@ func (ws *WizardSteps) ConfirmConfiguration(config models.ClusterConfig) (bool, } } + if config.Cloud != nil { + pterm.Warning.Println(CostHint(config.Type)) + } + // Use pterm for consistent styling if err := renderConfigurationTable(data); err != nil { // Fallback to simple display From 60c02baf9dddfcb227506d89f8bdca6f6d015860 Mon Sep 17 00:00:00 2001 From: Oleg Tkachuk Date: Thu, 16 Jul 2026 11:16:41 +0300 Subject: [PATCH 06/38] =?UTF-8?q?chore(cluster):=20pre-e2e=20hardening=20?= =?UTF-8?q?=E2=80=94=20template=20validation,=20tf=20version=20gate,=20doc?= =?UTF-8?q?s=20(#228)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Makefile | 11 ++- README.md | 3 +- docs/README.md | 3 +- docs/getting-started/cloud-clusters.md | 98 +++++++++++++++++++ .../prerequisites/terraform/terraform.go | 44 ++++++++- .../prerequisites/terraform/terraform_test.go | 31 ++++++ .../providers/eks/template_validate_test.go | 64 ++++++++++++ .../providers/gke/template_validate_test.go | 61 ++++++++++++ internal/cluster/service.go | 13 ++- internal/shared/config/transport_test.go | 28 ++++++ 10 files changed, 347 insertions(+), 9 deletions(-) create mode 100644 docs/getting-started/cloud-clusters.md create mode 100644 internal/cluster/prerequisites/terraform/terraform_test.go create mode 100644 internal/cluster/providers/eks/template_validate_test.go create mode 100644 internal/cluster/providers/gke/template_validate_test.go 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/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/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/internal/cluster/prerequisites/terraform/terraform.go b/internal/cluster/prerequisites/terraform/terraform.go index e8a89bd2..7bfe5ac3 100644 --- a/internal/cluster/prerequisites/terraform/terraform.go +++ b/internal/cluster/prerequisites/terraform/terraform.go @@ -2,9 +2,12 @@ package terraform import ( "context" + "encoding/json" "fmt" "os/exec" "runtime" + "strconv" + "strings" "time" "github.com/flamingo-stack/openframe-cli/internal/platform" @@ -28,9 +31,16 @@ func commandExists(cmd string) bool { return err == nil } -// IsInstalled reports whether a working terraform binary is reachable. The -// CLI-managed bin dir is prepended to PATH so a previously installed pinned -// binary is found even in a fresh shell. +// 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) @@ -40,7 +50,33 @@ func (t *TerraformInstaller) IsInstalled() bool { } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - return exec.CommandContext(ctx, "terraform", "version").Run() == nil + 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 { 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/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/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/service.go b/internal/cluster/service.go index 0ba893c4..2683538f 100644 --- a/internal/cluster/service.go +++ b/internal/cluster/service.go @@ -24,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. // @@ -963,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/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") From 8da596a4532baf0e3f6f316d0cbfb56cf379b270 Mon Sep 17 00:00:00 2001 From: Oleg Tkachuk Date: Thu, 16 Jul 2026 11:49:19 +0300 Subject: [PATCH 07/38] fix(ci): failures (#229) --- go.mod | 2 +- internal/cluster/prerequisites/checker.go | 25 ++++-- .../cluster/prerequisites/checker_test.go | 53 +++++++++-- .../prerequisites/docker/docker_test.go | 55 ++++-------- .../cluster/prerequisites/k3d/k3d_test.go | 46 ++++------ .../prerequisites/terraform/terraform.go | 8 +- internal/platform/platform.go | 2 +- internal/shared/download/pins.go | 40 ++++++--- internal/shared/download/pins_test.go | 87 ++++++++++++++++++- 9 files changed, 221 insertions(+), 97 deletions(-) diff --git a/go.mod b/go.mod index 84d4ddce..555f34c9 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ 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 @@ -83,7 +84,6 @@ require ( 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/hashicorp/terraform-json v0.27.2 // 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 diff --git a/internal/cluster/prerequisites/checker.go b/internal/cluster/prerequisites/checker.go index a175968a..935e56ef 100644 --- a/internal/cluster/prerequisites/checker.go +++ b/internal/cluster/prerequisites/checker.go @@ -134,18 +134,31 @@ func CheckPrerequisites() error { return NewInstaller().CheckAndInstallNonInteractive(ui.IsNonInteractive()) } -// CheckForClusterType runs the prerequisite gate for the given cluster type: +// 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 pass through and fail at the provider factory. -func CheckForClusterType(clusterType models.ClusterType) error { +// 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 CheckPrerequisites() + return NewPrerequisiteChecker() case models.ClusterTypeEKS: - return NewInstallerWithChecker(NewEKSPrerequisiteChecker()).CheckAndInstallNonInteractive(ui.IsNonInteractive()) + return NewEKSPrerequisiteChecker() case models.ClusterTypeGKE: - return NewInstallerWithChecker(NewGKEPrerequisiteChecker()).CheckAndInstallNonInteractive(ui.IsNonInteractive()) + 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 af87c1e9..9007706f 100644 --- a/internal/cluster/prerequisites/checker_test.go +++ b/internal/cluster/prerequisites/checker_test.go @@ -83,13 +83,54 @@ func containsAny(str string, substrings []string) bool { return false } -func TestCheckForClusterType_CloudTypesSkipLocalGate(t *testing.T) { - // The Docker/k3d/helm gate is for local clusters only; cloud types must - // pass through regardless of what is installed on this machine. - for _, clusterType := range []models.ClusterType{models.ClusterTypeGKE, models.ClusterTypeEKS} { - if err := CheckForClusterType(clusterType); err != nil { - t.Errorf("CheckForClusterType(%s) should not require local tools: %v", clusterType, err) +// 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)") } } 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/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 index 7bfe5ac3..9a2b528b 100644 --- a/internal/cluster/prerequisites/terraform/terraform.go +++ b/internal/cluster/prerequisites/terraform/terraform.go @@ -84,14 +84,14 @@ func (t *TerraformInstaller) GetInstallHelp() string { } // Install downloads the pinned Terraform release, verifies its SHA256, and -// installs it into ~/.openframe/bin (no sudo). +// 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": + case "darwin", "linux", "windows": return t.installVerified() 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 terraform installation not supported on %s", runtime.GOOS) } } diff --git a/internal/platform/platform.go b/internal/platform/platform.go index ce18bf78..b1850f1d 100644 --- a/internal/platform/platform.go +++ b/internal/platform/platform.go @@ -87,7 +87,7 @@ var toolDocs = map[string]InstallDocs{ "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: 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": { diff --git a/internal/shared/download/pins.go b/internal/shared/download/pins.go index 755c7c38..cced3462 100644 --- a/internal/shared/download/pins.go +++ b/internal/shared/download/pins.go @@ -96,16 +96,20 @@ 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 inside; SHA256 from the -// release's SHA256SUMS file. +// .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" + terraformSHA256LinuxAMD64 = "d25ce7b6902013ad905db3d2eab0be4cd905887fe88b81a6171b8d5503c31f3d" + terraformSHA256LinuxARM64 = "8891e9dcedc9e3b8950bc6af9d4d8af1f4cfade3062f53b9dc403a89f6ce8c9c" + terraformSHA256DarwinAMD64 = "e2e812e783771159bf758fd4e55d6dc9bb08f63e2af2c63d212721807a02c5dc" + terraformSHA256DarwinARM64 = "f210110c5698b94d803a7a63cdb0251b5455c150841478808e2bbb343f95ed68" + terraformSHA256WindowsAMD64 = "2ff41d2129afb1982733c132c61a8d6ef038f879f3aeede7fc28b8b8b24acf02" + terraformSHA256WindowsARM64 = "ffd9399a37ee8123263d84ec5c00d09d7704f9997cb345d7c9cac56c3fc1348e" ) var Terraform = PinnedTool{ @@ -113,10 +117,12 @@ var Terraform = PinnedTool{ 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}, + "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}, }, } @@ -142,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 { @@ -151,7 +157,7 @@ func (d Downloader) InstallPinnedTool(ctx context.Context, tool PinnedTool, binD return dest, nil } if tool.Zip { - if err := d.InstallVerifiedZipMember(ctx, asset, tool.Name, dest, 0o750); err != nil { + if err := d.InstallVerifiedZipMember(ctx, asset, exeName(tool.Name), dest, 0o750); err != nil { return "", err } return dest, nil @@ -162,6 +168,16 @@ func (d Downloader) InstallPinnedTool(ctx context.Context, tool PinnedTool, binD 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) { From c4f7b5eb513b43b5e7b5d07c8d41fe3ed1817363 Mon Sep 17 00:00:00 2001 From: Oleg Tkachuk Date: Thu, 16 Jul 2026 12:10:05 +0300 Subject: [PATCH 08/38] feat(prerequisites): auto-install terraform on Windows via pinned zip (#230) From bbb9bc41477609af6df8270257bb9d5db1411bdf Mon Sep 17 00:00:00 2001 From: Oleg Tkachuk Date: Tue, 21 Jul 2026 14:13:04 +0300 Subject: [PATCH 09/38] chore: go get -u ./... apply --- go.mod | 53 +++++++++++++-------------- go.sum | 110 +++++++++++++++++++++++++++++---------------------------- 2 files changed, 83 insertions(+), 80 deletions(-) diff --git a/go.mod b/go.mod index 555f34c9..be5e36d4 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ 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/hashicorp/terraform-json v0.28.0 github.com/manifoldco/promptui v0.9.0 github.com/pterm/pterm v0.12.83 github.com/sigstore/sigstore-go v1.2.2 @@ -31,6 +31,7 @@ require ( 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/apparentlymart/go-textseg/v17 v17.0.1 // 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 @@ -51,30 +52,30 @@ require ( github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-git/go-billy/v5 v5.9.0 // indirect github.com/go-jose/go-jose/v4 v4.1.4 // indirect - github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/logr v1.4.4 // indirect github.com/go-logr/stdr v1.2.2 // indirect - github.com/go-openapi/analysis v0.25.3 // indirect + github.com/go-openapi/analysis v0.25.5 // indirect github.com/go-openapi/errors v0.22.8 // indirect github.com/go-openapi/jsonpointer v1.0.0 // indirect github.com/go-openapi/jsonreference v1.0.0 // indirect - github.com/go-openapi/loads v0.24.0 // indirect - github.com/go-openapi/runtime v0.32.4 // indirect - github.com/go-openapi/runtime/server-middleware v0.32.4 // indirect - github.com/go-openapi/spec v0.22.6 // indirect - github.com/go-openapi/strfmt v0.26.4 // indirect - github.com/go-openapi/swag v0.27.0 // indirect - github.com/go-openapi/swag/cmdutils v0.27.0 // indirect - github.com/go-openapi/swag/conv v0.27.0 // indirect - github.com/go-openapi/swag/fileutils v0.27.0 // indirect - github.com/go-openapi/swag/jsonname v0.27.0 // indirect - github.com/go-openapi/swag/jsonutils v0.27.0 // indirect - github.com/go-openapi/swag/loading v0.27.0 // indirect - github.com/go-openapi/swag/mangling v0.27.0 // indirect - github.com/go-openapi/swag/netutils v0.27.0 // indirect - github.com/go-openapi/swag/stringutils v0.27.0 // indirect - github.com/go-openapi/swag/typeutils v0.27.0 // indirect - github.com/go-openapi/swag/yamlutils v0.27.0 // indirect - github.com/go-openapi/validate v0.26.0 // indirect + github.com/go-openapi/loads v0.25.0 // indirect + github.com/go-openapi/runtime v0.32.6 // indirect + github.com/go-openapi/runtime/server-middleware v0.32.6 // indirect + github.com/go-openapi/spec v0.22.9 // indirect + github.com/go-openapi/strfmt v0.27.0 // indirect + github.com/go-openapi/swag v0.27.3 // indirect + github.com/go-openapi/swag/cmdutils v0.27.3 // indirect + github.com/go-openapi/swag/conv v0.27.3 // indirect + github.com/go-openapi/swag/fileutils v0.27.3 // indirect + github.com/go-openapi/swag/jsonutils v0.27.3 // indirect + github.com/go-openapi/swag/loading v0.27.3 // indirect + github.com/go-openapi/swag/mangling v0.27.3 // indirect + github.com/go-openapi/swag/netutils v0.27.3 // indirect + github.com/go-openapi/swag/pools v0.27.3 // indirect + github.com/go-openapi/swag/stringutils v0.27.3 // indirect + github.com/go-openapi/swag/typeutils v0.27.3 // indirect + github.com/go-openapi/swag/yamlutils v0.27.3 // indirect + github.com/go-openapi/validate v0.26.1 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/google/certificate-transparency-go v1.3.3 // indirect @@ -124,7 +125,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 + github.com/zclconf/go-cty v1.19.0 // 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 @@ -138,16 +139,16 @@ require ( golang.org/x/sys v0.47.0 // indirect golang.org/x/text v0.40.0 // indirect golang.org/x/time v0.15.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260706201446-f0a921348800 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260706201446-f0a921348800 // indirect - google.golang.org/grpc v1.82.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260720211330-0afa2a65878a // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260720211330-0afa2a65878a // indirect + google.golang.org/grpc v1.82.1 // indirect google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect howett.net/plist v1.0.1 // indirect k8s.io/klog/v2 v2.140.0 // indirect - k8s.io/kube-openapi v0.0.0-20260706235625-cdb1db5517a0 // indirect + k8s.io/kube-openapi v0.0.0-20260721042612-c26577a1ec9a // indirect k8s.io/utils v0.0.0-20260707023825-cf1189d6abe3 // indirect sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect sigs.k8s.io/randfill v1.0.0 // indirect diff --git a/go.sum b/go.sum index 5875f906..9387276e 100644 --- a/go.sum +++ b/go.sum @@ -53,6 +53,8 @@ github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFI 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/apparentlymart/go-textseg/v17 v17.0.1 h1:bpMXRgQ5cEoRNuQke1a80/Nl6w3G5eoIbWo9f3gXkAs= +github.com/apparentlymart/go-textseg/v17 v17.0.1/go.mod h1:fa8X4jgGeevslICIY6LcdjkSecWnXmYd9Lk34z/VxZs= 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= @@ -161,60 +163,60 @@ github.com/go-git/go-git/v5 v5.19.1/go.mod h1:Pb1v0c7/g8aGQJwx9Us09W85yGoyvSwuhE github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= -github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= +github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= -github.com/go-openapi/analysis v0.25.3 h1:4zlcg85pd2xq3sEgjW887n1IpwCpCqTmqeT6dP9OxDw= -github.com/go-openapi/analysis v0.25.3/go.mod h1:6PEmUIra9/rn6SPstzbrMkhFAsMB2qm7g6E+4DRFyCU= +github.com/go-openapi/analysis v0.25.5 h1:xPYEvTb90o1y0epuiOPAoG4QqahjP3cdp5xNlHeKJRI= +github.com/go-openapi/analysis v0.25.5/go.mod h1:d3UGtQC5uq5Kqqqis2VH09Km/v3vwsWrYkbp4gdm+Rc= github.com/go-openapi/errors v0.22.8 h1:oP7sW7TWc3wFFjrzzj0nI83H2qMBkNjNfSd+XRejk/I= github.com/go-openapi/errors v0.22.8/go.mod h1:BuUoHcYrU6E7V9gfj1I5wLQqgtIHnup/alXZ8KdgQ0w= github.com/go-openapi/jsonpointer v1.0.0 h1:kR9tHqY0CtZaOPVFm622dPVNhrvYpwr4uCxgL3h1H8s= github.com/go-openapi/jsonpointer v1.0.0/go.mod h1:Z3rw7dWu1p9IgitXCFamSlA5lmDiklEB6vkaxcNZW5Y= github.com/go-openapi/jsonreference v1.0.0 h1:jlmTr6torcd1YgDQvSfNmRtKzYDO4FGBkrAdlAVWnpY= github.com/go-openapi/jsonreference v1.0.0/go.mod h1:jtwdyGbJk0Xhe5Y+rwtglQP6Sb1WZST4rT32LWB+sv0= -github.com/go-openapi/loads v0.24.0 h1:4LLorXRPTzIN9V6ngMUZbAscsBOUBk3Oa8cClu/bFrQ= -github.com/go-openapi/loads v0.24.0/go.mod h1:xQMgX+hw5xRAhGrcDXxeMw78IFqUpIzhleu3HqPhyF4= -github.com/go-openapi/runtime v0.32.4 h1:8ElGj/3goG0itt0nBPP6Cm57ehcYyuHoI3O20nxgvkw= -github.com/go-openapi/runtime v0.32.4/go.mod h1:Bz6keOZw1NX4T6f+m42OoT1MBPDt6Re13dbccHyGH/4= -github.com/go-openapi/runtime/server-middleware v0.32.4 h1:AU6eLMq9CXwh8f6kC1pivtkz+7lfo3TmakMBbUisKME= -github.com/go-openapi/runtime/server-middleware v0.32.4/go.mod h1:fYPep4GdTwg/XqZUjR40uIM/8C12Ba5M+MrGCiwpTHo= -github.com/go-openapi/spec v0.22.6 h1:Tyy1pLaNCM8GBCFLoGYLonjJi6zykqyLCjXLc19ZPic= -github.com/go-openapi/spec v0.22.6/go.mod h1:HZvTHat+iH0PALQRWhrqIHtU/PEqxqd89fu0MxGlMeM= -github.com/go-openapi/strfmt v0.26.4 h1:yI6IAEfcWow459BD5UzFY430KUwXZwBHrYusPFkhWlc= -github.com/go-openapi/strfmt v0.26.4/go.mod h1:hNJi6nb5ETD6i7A1yRo03M9S6ZoTPPoWff1iUexmfUc= -github.com/go-openapi/swag v0.27.0 h1:8ecSuZlh4NXc3GsmAOqECIYqDTApCWaMe3gO4gjJNEE= -github.com/go-openapi/swag v0.27.0/go.mod h1:Kkgz9Ht0+ul9/aVdFmc9xSyPzUwf/aFF5KiFPBXfSY0= -github.com/go-openapi/swag/cmdutils v0.27.0 h1:aIKiqhB29AaP+7xm8/CPg3uOpeHx2SUp6TvMpu/a31Y= -github.com/go-openapi/swag/cmdutils v0.27.0/go.mod h1:Sm1MVFMkF6guJJ+pQqHnQA3N0j9qALV3NxzDSv6bETM= -github.com/go-openapi/swag/conv v0.27.0 h1:EKOH4feXrvdo8DbSsXSAqRT8fz1epEnS5O2IfXUOzE8= -github.com/go-openapi/swag/conv v0.27.0/go.mod h1:pfiv0uKQTbaGApk8Zs/lZV3uSjmSpa2FO1y183YngN8= -github.com/go-openapi/swag/fileutils v0.27.0 h1:ib5jMUqGq5tY1EyO4inlrabsaeDAleFU+XD1FXQcgp8= -github.com/go-openapi/swag/fileutils v0.27.0/go.mod h1:VvJFZLTZS0AI854gEQz5tk7dBESdLjiNUMSZ/th2ry8= -github.com/go-openapi/swag/jsonname v0.27.0 h1:4QVB//CKOdE8IOiBg19JNY2wfDS48MhesIquYBy2rUE= -github.com/go-openapi/swag/jsonname v0.27.0/go.mod h1:I1YsyvvhBuZsFXSW6I7ODfdyq13p7hDil//1T9/pFFk= -github.com/go-openapi/swag/jsonutils v0.27.0 h1:VYtd9jEQYeU4j8q5vdn5KWotF4vKywhGdMBrALtAsfE= -github.com/go-openapi/swag/jsonutils v0.27.0/go.mod h1:U7pb8AGuwhok3RDicHeHwSG4L3PXSq6PAL98Aon632g= -github.com/go-openapi/swag/jsonutils/fixtures_test v0.27.0 h1:+d7C7Ur/SsGg/UZ9G0JEovnfRqtMNZCJQGKc2h/ojoE= -github.com/go-openapi/swag/jsonutils/fixtures_test v0.27.0/go.mod h1:mofwUWx70wvskwESqRJ//k/9kURmCgyJl5m5Ppoh5kY= -github.com/go-openapi/swag/loading v0.27.0 h1:s8DA9aPEdFH6OluHUYUn3DnIuoTdyWs9RwffXBUfyeI= -github.com/go-openapi/swag/loading v0.27.0/go.mod h1:VOz+Jg6UGGywcmRvYsI4fvtp+bd7NfioseGEPleYdA4= -github.com/go-openapi/swag/mangling v0.27.0 h1:rpPJuqQHa6z2pDiP3iIpXOyNXlSs9cQCxnJSAxzdfOc= -github.com/go-openapi/swag/mangling v0.27.0/go.mod h1:jtBE2+V+3pILxOR7Vgce+Cwp6A2PgZbvVqfNntbVs0w= -github.com/go-openapi/swag/netutils v0.27.0 h1:lEUG+hHvPvLggB3A8snFk0IRKNf9uC0YKc+7WYqvAF8= -github.com/go-openapi/swag/netutils v0.27.0/go.mod h1:J+WYyFMLtvtCGqa6jLv+YNUmIKI3ZRQRrvfNDMoQoEQ= -github.com/go-openapi/swag/stringutils v0.27.0 h1:Of7w/HljWsNZvuxsUAnw3n+hCOyI6HLJOxW2kQRAxio= -github.com/go-openapi/swag/stringutils v0.27.0/go.mod h1:lzRN95CxXmA03XcDWHLOb6nOMcxCqR5rGY0lOgsfRoM= -github.com/go-openapi/swag/typeutils v0.27.0 h1:aCf4MSGo8NLwZP8Q6t32DWLJSvl/WwNqgmEG+xJ6v2o= -github.com/go-openapi/swag/typeutils v0.27.0/go.mod h1:Srm0xFNRZ1Y+vCxJclo5qzx8aj+1pAKda/YfFPrG0dQ= -github.com/go-openapi/swag/yamlutils v0.27.0 h1:bQ6eAMil5X9tdcf7dMn4t15alzG6jddnrKPuKa/zxKM= -github.com/go-openapi/swag/yamlutils v0.27.0/go.mod h1:yRfIo7qqVkmJRQjX8exjA3AfcI8rH1KDNPsTparoCv4= +github.com/go-openapi/loads v0.25.0 h1:74Bc2snfaVlsHzwdQj/3gsA9XJz3daXTJVs+4ZaK7jI= +github.com/go-openapi/loads v0.25.0/go.mod h1:JFBw4SIB9+PTIFHDfcXuSSy5h6aWzjtUCrPYyx3qWU8= +github.com/go-openapi/runtime v0.32.6 h1:hrcTTF8P7ZZr2Majzq11I65QtL/s85o7Q+zJf+AvFN4= +github.com/go-openapi/runtime v0.32.6/go.mod h1:+rsupH3+TFKqmFysqkmgBOTxpVJV8eV+j9myvvea2Xw= +github.com/go-openapi/runtime/server-middleware v0.32.6 h1:IGTYzybyFrUeSqQEwwO1y/9KnOk4QsabFNtAQtIHxDE= +github.com/go-openapi/runtime/server-middleware v0.32.6/go.mod h1:OQHTBqMGquJShXhPYQ62yAqDMtC1rYpsEwldNWjYKhA= +github.com/go-openapi/spec v0.22.9 h1:/vKIFDcGKp0ktZWGbym/tJEWbk6/XOEmAVU0kqKMH+w= +github.com/go-openapi/spec v0.22.9/go.mod h1:b/mNUYIOQOyIiUzUzXEE8xzyZqf93KvM9hQGP91yfl0= +github.com/go-openapi/strfmt v0.27.0 h1:kbcTeaD9TXuXD0hhMXzuYa1sdTo6+dWGvwjW93E80IM= +github.com/go-openapi/strfmt v0.27.0/go.mod h1:s/qhDqfY72irigXUGJmtgid2Rm+3tnz3k8hZaRmvWYc= +github.com/go-openapi/swag v0.27.3 h1:i6oVKkGZeFgETHMiBHGtj9gIQ1aLtWDdJnT/SRZeets= +github.com/go-openapi/swag v0.27.3/go.mod h1:qEXs3GcyyQTDCFQ4ykqnLPDh8qT+zBcjbVWdxCAW0Us= +github.com/go-openapi/swag/cmdutils v0.27.3 h1:sjuL0TvW81i9R9GRMO/fy+c3mOW+7zxRYwy/7fobZt4= +github.com/go-openapi/swag/cmdutils v0.27.3/go.mod h1:Sm1MVFMkF6guJJ+pQqHnQA3N0j9qALV3NxzDSv6bETM= +github.com/go-openapi/swag/conv v0.27.3 h1:iqJFmGEjmX3AY0lSszABFqRVqOSt99XS0LzNIMJYuhU= +github.com/go-openapi/swag/conv v0.27.3/go.mod h1:nPRmN6jgNme99hpf+nM0auDZGALWIqlwhisKPK/bQhQ= +github.com/go-openapi/swag/fileutils v0.27.3 h1:3UVoZ2RLaIs1lt+2jcKzL8RM3Yk0rmsDE9FLA/HGxFE= +github.com/go-openapi/swag/fileutils v0.27.3/go.mod h1:VvJFZLTZS0AI854gEQz5tk7dBESdLjiNUMSZ/th2ry8= +github.com/go-openapi/swag/jsonutils v0.27.3 h1:1DEz+O82frtSMBcos/7XIn1GnpNTbsD4Bru4Dc/uhRc= +github.com/go-openapi/swag/jsonutils v0.27.3/go.mod h1:qiDCoQvzkMxrV3G8FLEdIU5L+EFYc0zcDOHWT3Yofvo= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.27.3 h1:h/eT9kmGCDdFLJF29lOhzLtF0FmP1AX2MhLJWVebsb8= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.27.3/go.mod h1:mofwUWx70wvskwESqRJ//k/9kURmCgyJl5m5Ppoh5kY= +github.com/go-openapi/swag/loading v0.27.3 h1:L9nQkEgzU7QgFQL+pLEMfGUKxeM4pWwGwbET9Z3weW0= +github.com/go-openapi/swag/loading v0.27.3/go.mod h1:rJ0NeaKsF4CVPnMGjPQl7JlSHzvD0bc2DKXLss1hiuE= +github.com/go-openapi/swag/mangling v0.27.3 h1:gRzzD1PAUoLTtGMgI3KpBmCSOlTuLTFWnviLxLcTnyg= +github.com/go-openapi/swag/mangling v0.27.3/go.mod h1:jtBE2+V+3pILxOR7Vgce+Cwp6A2PgZbvVqfNntbVs0w= +github.com/go-openapi/swag/netutils v0.27.3 h1:IoBvfCoprsE6E87kAIm9basnISqDDqB79mJ8MN+f5PU= +github.com/go-openapi/swag/netutils v0.27.3/go.mod h1:J+WYyFMLtvtCGqa6jLv+YNUmIKI3ZRQRrvfNDMoQoEQ= +github.com/go-openapi/swag/pools v0.27.3 h1:gXjImP3F6/56wRRcFgEPld084Y6u2gs21ikPBt8NKBk= +github.com/go-openapi/swag/pools v0.27.3/go.mod h1:kVQefhSK5RWuRe7BXsL8htgBPAMpN7HDGpGEknqugeE= +github.com/go-openapi/swag/stringutils v0.27.3 h1:Ru28hnbAvN5wycALQYy8IobHvASq+FUFMlp1QzLM0JI= +github.com/go-openapi/swag/stringutils v0.27.3/go.mod h1:lzRN95CxXmA03XcDWHLOb6nOMcxCqR5rGY0lOgsfRoM= +github.com/go-openapi/swag/typeutils v0.27.3 h1:l6SSrx5eR5/WVwrGNzN6bQ9WqL04mrxNBl9YgQ3rcJ4= +github.com/go-openapi/swag/typeutils v0.27.3/go.mod h1:Srm0xFNRZ1Y+vCxJclo5qzx8aj+1pAKda/YfFPrG0dQ= +github.com/go-openapi/swag/yamlutils v0.27.3 h1:cRFCAoYtslYn9L9T0xWryHy1t7c1MACC+DMj3CLvwvs= +github.com/go-openapi/swag/yamlutils v0.27.3/go.mod h1:6JYBGj8sw/NawMllyZY+cTA8Mzk2etS3ZBASdcyPsiU= github.com/go-openapi/testify/enable/yaml/v2 v2.6.0 h1:gGHwAJ0R/5jU8BEGDbfRNR3hL68dAVi84WuOApp29B0= github.com/go-openapi/testify/enable/yaml/v2 v2.6.0/go.mod h1:tY+St1SGq4NFl0QIqdTY4aEdbChAHxhyB77XQi9iJCo= github.com/go-openapi/testify/v2 v2.6.0 h1:5PKH2HE7YJ/LuRPQGvSxBRlFXNQhSetBLlGAgUEu3ug= github.com/go-openapi/testify/v2 v2.6.0/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= -github.com/go-openapi/validate v0.26.0 h1:dxWzQ3F+vb1SajqUxHjwb5T4mTpSHmdrtv5Bi7+ZNhw= -github.com/go-openapi/validate v0.26.0/go.mod h1:b4o00uq7fJeJA+wWhVFCJpKTctzeFwzZImGGmHsl2JA= +github.com/go-openapi/validate v0.26.1 h1:pZSbvtRO8G2R2FpWTYRn3w8LrsNwbtaVhP2dWiBa0Us= +github.com/go-openapi/validate v0.26.1/go.mod h1:B8UMgXiQiwwQWIbmuROlwJZDPGlikPuh7iHV1vPX9Oo= github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= @@ -280,8 +282,8 @@ github.com/hashicorp/hcl v1.0.1-vault-7 h1:ag5OxFVy3QYTFTJODRzTKVZ6xvdfLLCA1cy/Y 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/terraform-json v0.28.0 h1:dOkJT55rWfU6T1/VklHde51ym4LfNP+9xYR3ZizAJe4= +github.com/hashicorp/terraform-json v0.28.0/go.mod h1:PJIRf+Yzu5iLb52c/xYp1tUOL4jzMzfIAB5gvWWKIWE= 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= @@ -444,8 +446,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= +github.com/zclconf/go-cty v1.19.0 h1:IV8WdqYZc2c5rLX9bEoLNXKojBAp0MZPBHMIrCoa/s4= +github.com/zclconf/go-cty v1.19.0/go.mod h1:12W89jGn3JCOIQi7infWr9m80rOkb5RNYJqXMZcN4c8= 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= @@ -540,12 +542,12 @@ google.golang.org/api v0.283.0 h1:0lkp8u0MPwJVHqRL+nJlMAoZVVzbmiXmFHXMOTmSPik= google.golang.org/api v0.283.0/go.mod h1:6Wssta4c5n9qHq5CBhmlai5h/PUa1djdDAIhYEHyvcM= google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0= google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I= -google.golang.org/genproto/googleapis/api v0.0.0-20260706201446-f0a921348800 h1:admdQBe8jR3VWhBsUrAOaF2Qw6K/+p5pSm1GN8+6Fw4= -google.golang.org/genproto/googleapis/api v0.0.0-20260706201446-f0a921348800/go.mod h1:FPk7EXUKMtImne7AmknoYjT4QXqKIzzRbeQIXzLk6fQ= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260706201446-f0a921348800 h1:qEHAMpSaUhtD0p3NbEEI83HwNGFxEwaSJ1G9PLnCBZE= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260706201446-f0a921348800/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.82.0 h1:vguDnZUPjE26w09A63VoxZPnvPjB5Riyc0mkXPFmAIU= -google.golang.org/grpc v1.82.0/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= +google.golang.org/genproto/googleapis/api v0.0.0-20260720211330-0afa2a65878a h1:97PfJ4tCxY5C7NzzgGqQEMZmXbISdvSArNNEOoUGKBg= +google.golang.org/genproto/googleapis/api v0.0.0-20260720211330-0afa2a65878a/go.mod h1:1brfde68Npq6+WA75c1EHWPijZEG1kMus61ygPZfn4A= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260720211330-0afa2a65878a h1:qI/YMH1ep2qQtqcp00gMQyoU7mjvbhg88GJKCvfoLj0= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260720211330-0afa2a65878a/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE= +google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI= google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -575,8 +577,8 @@ k8s.io/client-go v0.36.2 h1:bfgxmFKc9CgqsgX4xKLAAdmTQlWee7Ob/HlDOrJ5TBI= k8s.io/client-go v0.36.2/go.mod h1:1vgO4OAlfPnoLcb+Rze2GF5rAr14w8qjrYMoyXJzQj0= k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc= k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0= -k8s.io/kube-openapi v0.0.0-20260706235625-cdb1db5517a0 h1:CVjOUCTXINUThEmDs25FNSna0+vnGSoTleN+wiJu6hE= -k8s.io/kube-openapi v0.0.0-20260706235625-cdb1db5517a0/go.mod h1:rcZ+P5cEvHQB+m154WBOatIGBgOEPjzmLkXjkHfg3ms= +k8s.io/kube-openapi v0.0.0-20260721042612-c26577a1ec9a h1:QkCyIr4f6yciQ8A+ymvRkmr8DMtkqfVlBorj2LlPK7M= +k8s.io/kube-openapi v0.0.0-20260721042612-c26577a1ec9a/go.mod h1:rcZ+P5cEvHQB+m154WBOatIGBgOEPjzmLkXjkHfg3ms= k8s.io/utils v0.0.0-20260707023825-cf1189d6abe3 h1:jVkFFVfXdXP74B/zbO3hM3hpSFD0xvhQ5U686DPurkE= k8s.io/utils v0.0.0-20260707023825-cf1189d6abe3/go.mod h1:M2s5JB1lIYP3jzZdorPLHXIPJzt9vv2muW5a6L9DtNM= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= From c018eff8ae9be67f9b4e09ff987dac541af1c6dc Mon Sep 17 00:00:00 2001 From: Oleg Tkachuk Date: Tue, 21 Jul 2026 16:11:51 +0300 Subject: [PATCH 10/38] feat(cluster): GKE ownership boundary, external discovery, EKS coming-soon gate (#238) --- cmd/cluster/create.go | 19 ++ cmd/cluster/create_behavior_test.go | 84 +++++--- cmd/cluster/list.go | 65 +++++- cmd/cluster/list_test.go | 52 +++++ internal/cluster/discovery/gke.go | 195 ++++++++++++++++++ internal/cluster/discovery/gke_test.go | 109 ++++++++++ internal/cluster/models/cluster.go | 17 ++ internal/cluster/models/flags.go | 18 +- internal/cluster/providers/eks/kubeconfig.go | 7 + internal/cluster/providers/eks/provider.go | 3 + internal/cluster/providers/gke/kubeconfig.go | 8 + internal/cluster/providers/gke/provider.go | 29 +++ .../cluster/providers/gke/provider_test.go | 64 ++++++ internal/cluster/service.go | 10 +- internal/cluster/ui/service.go | 49 ++++- internal/cluster/ui/wizard_steps.go | 31 +-- internal/cluster/utils/cmd_helpers.go | 12 ++ 17 files changed, 714 insertions(+), 58 deletions(-) create mode 100644 internal/cluster/discovery/gke.go create mode 100644 internal/cluster/discovery/gke_test.go diff --git a/cmd/cluster/create.go b/cmd/cluster/create.go index 7b4741a5..9a5c4e25 100644 --- a/cmd/cluster/create.go +++ b/cmd/cluster/create.go @@ -107,6 +107,16 @@ func cloudPlanPreview(ctx context.Context, config models.ClusterConfig) error { return nil } +// showEKSComingSoonBanner is the temporary stub for AWS EKS creation. +func showEKSComingSoonBanner() { + pterm.DefaultBox. + WithTitle(" 🚧 AWS EKS — coming soon "). + WithTitleTopCenter(). + Println("Creating AWS EKS clusters will be available shortly.\n" + + "GKE is fully supported today:\n" + + " openframe cluster create my-gke --type gke --project --region ") +} + func runCreateCluster(cmd *cobra.Command, args []string) error { service := utils.GetCommandService() globalFlags := utils.GetGlobalFlags() @@ -185,6 +195,15 @@ func runCreateCluster(cmd *cobra.Command, args []string) error { } } + // AWS EKS creation is temporarily gated behind a coming-soon banner while + // the GKE flow is being finished end-to-end. The EKS provider stays fully + // functional for existing clusters (status/delete/resume) — only NEW + // creates are gated. + if config.Type == models.ClusterTypeEKS { + showEKSComingSoonBanner() + return nil + } + // Show configuration summary for dry-run or skip-wizard modes if globalFlags.Create.DryRun || globalFlags.Create.SkipWizard || globalFlags.Global.Verbose { operationsUI := ui.NewOperationsUI() diff --git a/cmd/cluster/create_behavior_test.go b/cmd/cluster/create_behavior_test.go index 59fd629b..15f88481 100644 --- a/cmd/cluster/create_behavior_test.go +++ b/cmd/cluster/create_behavior_test.go @@ -74,37 +74,35 @@ func TestRunCreateCluster_DryRunDefaultsNameWhenNoArgs(t *testing.T) { } } +// While EKS creation is gated behind the coming-soon banner, the plan preview +// is a GKE-only path (see TestRunCreateCluster_EKSShowsComingSoonBanner). func TestRunCreateCluster_CloudDryRunRunsPlanPreview(t *testing.T) { - 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) - } - }) + setupCreate(t) + // Stub the preview: the real one shells out to terraform. + var previewed *models.ClusterConfig + orig := planPreviewFn + planPreviewFn = func(ctx context.Context, config models.ClusterConfig) error { + previewed = &config + return nil + } + t.Cleanup(func() { planPreviewFn = orig }) + + cmd := getCreateCmd() + gf := utils.GetGlobalFlags() + gf.Create.SkipWizard = true + gf.Create.DryRun = true + gf.Create.ClusterType = "gke" + gf.Create.Region = "us-central1" + gf.Create.Project = "my-project" + + if err := runCreateCluster(cmd, []string{"cloud-cluster"}); err != nil { + t.Fatalf("gke dry-run should return nil, got %v", err) + } + if previewed == nil { + t.Fatal("gke dry-run must invoke the terraform plan preview") + } + if previewed.Cloud == nil || previewed.Cloud.Region != "us-central1" { + t.Fatalf("preview received wrong config: %+v", previewed) } } @@ -160,3 +158,29 @@ func TestRunClusterStatus_ListFailureSurfacesError(t *testing.T) { t.Fatal("expected an error when cluster listing fails") } } + +// TestRunCreateCluster_EKSShowsComingSoonBanner: while EKS creation is gated, +// --type eks must show the banner and exit cleanly — no prerequisite gate, no +// provider calls, no validation errors about missing --region. +func TestRunCreateCluster_EKSShowsComingSoonBanner(t *testing.T) { + setupCreate(t) + called := false + orig := planPreviewFn + planPreviewFn = func(ctx context.Context, config models.ClusterConfig) error { + called = true + return nil + } + t.Cleanup(func() { planPreviewFn = orig }) + + cmd := getCreateCmd() + gf := utils.GetGlobalFlags() + gf.Create.SkipWizard = true + gf.Create.ClusterType = "eks" + + if err := runCreateCluster(cmd, []string{"cloud-cluster"}); err != nil { + t.Fatalf("eks banner path must return nil, got %v", err) + } + if called { + t.Fatal("eks must not reach the plan preview while gated") + } +} diff --git a/cmd/cluster/list.go b/cmd/cluster/list.go index 9e24303c..2a5b476f 100644 --- a/cmd/cluster/list.go +++ b/cmd/cluster/list.go @@ -1,11 +1,14 @@ package cluster import ( + "context" "encoding/json" "fmt" + "github.com/flamingo-stack/openframe-cli/internal/cluster/discovery" "github.com/flamingo-stack/openframe-cli/internal/cluster/models" "github.com/flamingo-stack/openframe-cli/internal/cluster/utils" + "github.com/pterm/pterm" "github.com/spf13/cobra" "sigs.k8s.io/yaml" ) @@ -22,8 +25,13 @@ func getListCmd() *cobra.Command { Displays cluster information including name, type, status, and node count from all registered providers in a formatted table. +With --all, additionally discovers GKE clusters that exist in the GCP +projects of your gcloud configurations but were created outside openframe. +Discovered clusters are read-only: openframe never modifies or deletes them. + Examples: openframe cluster list + openframe cluster list --all openframe cluster list --verbose openframe cluster list --quiet`, PreRunE: func(cmd *cobra.Command, args []string) error { @@ -52,6 +60,7 @@ Examples: func runListClusters(cmd *cobra.Command, args []string) error { service := utils.GetCommandService() + globalFlags := utils.GetGlobalFlags() // Get all clusters clusters, err := service.ListClusters() @@ -59,19 +68,71 @@ func runListClusters(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to list clusters: %w", err) } + var notices []string + if globalFlags.List.All { + external, discoveryNotices := discoverExternalClusters(cmd.Context(), clusters) + clusters = append(clusters, external...) + notices = discoveryNotices + } + switch out, _ := cmd.Flags().GetString("output"); out { case "json": return printClustersJSON(clusters) case "yaml": return printClustersYAML(clusters) case "", "text": - globalFlags := utils.GetGlobalFlags() - return service.DisplayClusterList(clusters, globalFlags.List.Quiet, globalFlags.Global.Verbose) + if err := service.DisplayClusterList(clusters, globalFlags.List.Quiet, globalFlags.Global.Verbose); err != nil { + return err + } + for _, notice := range notices { + pterm.Info.Println(notice) + } + return nil default: return fmt.Errorf("invalid --output %q (want \"text\", \"json\", or \"yaml\")", out) } } +// discoverExternalClusters runs GKE discovery, dropping entries that are +// already managed (same name+project as a registry cluster). Auth problems +// degrade to notices, never errors: a logged-out gcloud must not break list. +// EKS discovery is not implemented yet — a notice says so. +func discoverExternalClusters(ctx context.Context, managed []models.ClusterInfo) ([]models.ClusterInfo, []string) { + notices := []string{"AWS EKS discovery is coming soon — external EKS clusters are not shown yet"} + + d := discovery.NewGKEDiscoverer(utils.CommandExecutor()) + switch d.AuthStatus(ctx) { + case discovery.CLIMissing: + return nil, append(notices, "GKE: gcloud is not installed — install it to discover external clusters") + case discovery.NotAuthenticated: + return nil, append(notices, "GKE: not authenticated — run 'gcloud auth login' to discover external clusters") + } + + result, err := d.Discover(ctx) + if err != nil { + return nil, append(notices, fmt.Sprintf("GKE discovery failed: %v", err)) + } + for _, w := range result.Warnings { + notices = append(notices, "GKE discovery skipped "+w) + } + + isManaged := func(c models.ClusterInfo) bool { + for _, m := range managed { + if m.Name == c.Name && (m.Project == "" || m.Project == c.Project) { + return true + } + } + return false + } + var external []models.ClusterInfo + for _, c := range result.Clusters { + if !isManaged(c) { + external = append(external, c) + } + } + return external, notices +} + // clusterJSON is the machine-readable shape of a cluster. type clusterJSON struct { Name string `json:"name"` diff --git a/cmd/cluster/list_test.go b/cmd/cluster/list_test.go index d6ce75bc..56841741 100644 --- a/cmd/cluster/list_test.go +++ b/cmd/cluster/list_test.go @@ -1,9 +1,11 @@ package cluster import ( + "path/filepath" "testing" "github.com/flamingo-stack/openframe-cli/internal/cluster/utils" + "github.com/flamingo-stack/openframe-cli/internal/shared/executor" "github.com/flamingo-stack/openframe-cli/tests/testutil" ) @@ -21,3 +23,53 @@ func TestListCommand(t *testing.T) { testutil.TestClusterCommand(t, "list", getListCmd, setupFunc, teardownFunc) } + +// TestRunListClusters_AllDiscoversExternalGKE drives list --all end to end on +// mocks: k3d listing + gcloud discovery, with the EKS coming-soon notice. +func TestRunListClusters_AllDiscoversExternalGKE(t *testing.T) { + t.Setenv("OPENFRAME_CLUSTERS_DIR", t.TempDir()) + t.Setenv("KUBECONFIG", filepath.Join(t.TempDir(), "kubeconfig")) + utils.InitGlobalFlags() + mock := executor.NewMockCommandExecutor() + mock.SetResponse("k3d cluster list", &executor.CommandResult{ExitCode: 0, Stdout: "[]"}) + mock.SetResponse("gcloud auth list", &executor.CommandResult{ExitCode: 0, Stdout: "dev@example.com\n"}) + mock.SetResponse("gcloud config configurations list", &executor.CommandResult{ExitCode: 0, + Stdout: `[{"name":"dev-x","properties":{"core":{"project":"proj-x"}}}]`}) + mock.SetResponse("clusters list --project proj-x", &executor.CommandResult{ExitCode: 0, + Stdout: `[{"name":"ext-1","location":"us-central1","status":"RUNNING","currentNodeCount":2}]`}) + utils.SetTestExecutor(mock) + t.Cleanup(utils.ResetGlobalFlags) + + cmd := getListCmd() + utils.GetGlobalFlags().List.All = true + + if err := runListClusters(cmd, nil); err != nil { + t.Fatalf("list --all: %v", err) + } + // The discovery call chain must have run. + if !mock.WasCommandExecuted("clusters list --project proj-x") { + t.Fatal("expected GKE discovery to list clusters in configured projects") + } +} + +// TestRunListClusters_AllNotAuthenticatedDegradesGracefully: a logged-out +// gcloud must not fail the command. +func TestRunListClusters_AllNotAuthenticatedDegradesGracefully(t *testing.T) { + t.Setenv("OPENFRAME_CLUSTERS_DIR", t.TempDir()) + utils.InitGlobalFlags() + mock := executor.NewMockCommandExecutor() + mock.SetResponse("k3d cluster list", &executor.CommandResult{ExitCode: 0, Stdout: "[]"}) + mock.SetResponse("gcloud auth list", &executor.CommandResult{ExitCode: 0, Stdout: ""}) + utils.SetTestExecutor(mock) + t.Cleanup(utils.ResetGlobalFlags) + + cmd := getListCmd() + utils.GetGlobalFlags().List.All = true + + if err := runListClusters(cmd, nil); err != nil { + t.Fatalf("list --all without auth must degrade to a notice, got: %v", err) + } + if mock.WasCommandExecuted("clusters list --project") { + t.Fatal("discovery must not query projects when not authenticated") + } +} diff --git a/internal/cluster/discovery/gke.go b/internal/cluster/discovery/gke.go new file mode 100644 index 00000000..624bcc90 --- /dev/null +++ b/internal/cluster/discovery/gke.go @@ -0,0 +1,195 @@ +// Package discovery finds cloud Kubernetes clusters that exist OUTSIDE the +// openframe workspace registry — clusters created by Terraform repos, other +// tools, or other people. Discovered clusters are strictly read-only for the +// CLI: they appear in `cluster list --all` and `cluster status`, and every +// mutating command refuses them (no workspace = not ours). +// +// Discovery never stores credentials: it delegates to the provider CLIs +// (gcloud) through the shared CommandExecutor, so it is fully mockable and +// respects whatever the user has authenticated. +package discovery + +import ( + "context" + "encoding/json" + "fmt" + "sort" + "strings" + + "github.com/flamingo-stack/openframe-cli/internal/cluster/models" + "github.com/flamingo-stack/openframe-cli/internal/shared/executor" + "k8s.io/client-go/tools/clientcmd" +) + +// AuthStatus reports whether a provider's CLI is usable without treating +// "not logged in" as an error — list must degrade gracefully. +type AuthStatus int + +const ( + Authenticated AuthStatus = iota + NotAuthenticated + CLIMissing +) + +// GKEDiscoverer lists GKE clusters across the projects configured in the +// user's named gcloud configurations (e.g. dev-*, stage-*, prod-*). +type GKEDiscoverer struct { + exec executor.CommandExecutor +} + +func NewGKEDiscoverer(exec executor.CommandExecutor) *GKEDiscoverer { + return &GKEDiscoverer{exec: exec} +} + +// AuthStatus probes gcloud without failing: a missing binary and a logged-out +// state are states to report, not errors. +func (d *GKEDiscoverer) AuthStatus(ctx context.Context) AuthStatus { + result, err := d.exec.Execute(ctx, "gcloud", "auth", "list", "--filter=status:ACTIVE", "--format=value(account)") + if err != nil { + if strings.Contains(err.Error(), "executable file not found") { + return CLIMissing + } + return NotAuthenticated + } + if result == nil || strings.TrimSpace(result.Stdout) == "" { + return NotAuthenticated + } + return Authenticated +} + +// gcloudConfiguration is the subset of `gcloud config configurations list +// --format=json` the discoverer reads. +type gcloudConfiguration struct { + Name string `json:"name"` + Properties struct { + Core struct { + Project string `json:"project"` + } `json:"core"` + } `json:"properties"` +} + +// Projects returns the unique GCP projects named by the user's gcloud +// configurations, sorted. Configurations without a project (or with the +// literal "none") are skipped. +func (d *GKEDiscoverer) Projects(ctx context.Context) ([]string, error) { + result, err := d.exec.Execute(ctx, "gcloud", "config", "configurations", "list", "--format=json") + if err != nil { + return nil, fmt.Errorf("listing gcloud configurations: %w", err) + } + var configs []gcloudConfiguration + if err := json.Unmarshal([]byte(result.Stdout), &configs); err != nil { + return nil, fmt.Errorf("parsing gcloud configurations: %w", err) + } + seen := map[string]bool{} + var projects []string + for _, c := range configs { + p := strings.TrimSpace(c.Properties.Core.Project) + if p == "" || p == "none" || seen[p] { + continue + } + seen[p] = true + projects = append(projects, p) + } + sort.Strings(projects) + return projects, nil +} + +// gkeCluster is the subset of `gcloud container clusters list --format=json` +// the discoverer reads. +type gkeCluster struct { + Name string `json:"name"` + Location string `json:"location"` + Status string `json:"status"` + CurrentNodeCount int `json:"currentNodeCount"` + CurrentMasterVersion string `json:"currentMasterVersion"` +} + +// Result is one discovery pass: the clusters found plus per-project warnings +// (permission denied on some projects must not hide the rest). +type Result struct { + Clusters []models.ClusterInfo + Warnings []string +} + +// Discover lists clusters in every configured project, best-effort per +// project. Discovered entries carry Source=external and, when resolvable, the +// kubeconfig context that reaches them. +func (d *GKEDiscoverer) Discover(ctx context.Context) (Result, error) { + projects, err := d.Projects(ctx) + if err != nil { + return Result{}, err + } + contexts := kubeconfigContexts() + + var res Result + for _, project := range projects { + result, err := d.exec.Execute(ctx, "gcloud", "container", "clusters", "list", "--project", project, "--format=json") + if err != nil { + // Typical: PERMISSION_DENIED on projects outside the user's role, or + // the container API disabled. Report and keep going. + res.Warnings = append(res.Warnings, fmt.Sprintf("%s: %v", project, err)) + continue + } + var clusters []gkeCluster + if err := json.Unmarshal([]byte(result.Stdout), &clusters); err != nil { + res.Warnings = append(res.Warnings, fmt.Sprintf("%s: unparseable clusters list", project)) + continue + } + for _, c := range clusters { + res.Clusters = append(res.Clusters, models.ClusterInfo{ + Name: c.Name, + Type: models.ClusterTypeGKE, + Source: models.SourceExternal, + Status: titleCase(c.Status), + NodeCount: c.CurrentNodeCount, + K8sVersion: c.CurrentMasterVersion, + Project: project, + Region: c.Location, + Context: matchContext(contexts, project, c.Location, c.Name), + }) + } + } + return res, nil +} + +// kubeconfigContexts returns the context names of the user's kubeconfig +// (honoring $KUBECONFIG); best-effort — a missing kubeconfig is fine. +func kubeconfigContexts() []string { + cfg, err := clientcmd.NewDefaultPathOptions().GetStartingConfig() + if err != nil { + return nil + } + names := make([]string, 0, len(cfg.Contexts)) + for name := range cfg.Contexts { + names = append(names, name) + } + sort.Strings(names) + return names +} + +// matchContext maps a discovered cluster onto a kubeconfig context by the +// three conventional naming shapes; a renamed context cannot be matched and +// yields "". +func matchContext(contexts []string, project, location, name string) string { + candidates := []string{ + name, // plain (openframe-style or hand-renamed to the cluster name) + fmt.Sprintf("gke_%s_%s_%s", project, location, name), // gcloud get-credentials + fmt.Sprintf("connectgateway_%s_%s_%s", project, location, name), // fleet connect gateway + } + for _, want := range candidates { + for _, have := range contexts { + if have == want { + return have + } + } + } + return "" +} + +func titleCase(s string) string { + if s == "" { + return s + } + s = strings.ToLower(s) + return strings.ToUpper(s[:1]) + s[1:] +} diff --git a/internal/cluster/discovery/gke_test.go b/internal/cluster/discovery/gke_test.go new file mode 100644 index 00000000..f922814b --- /dev/null +++ b/internal/cluster/discovery/gke_test.go @@ -0,0 +1,109 @@ +package discovery + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/flamingo-stack/openframe-cli/internal/cluster/models" + "github.com/flamingo-stack/openframe-cli/internal/shared/executor" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const configurationsJSON = `[ + {"name": "default", "properties": {"core": {"project": "none"}}}, + {"name": "dev-tenant-runners", "properties": {"core": {"project": "tenant-runners-db9z"}}}, + {"name": "dev-shared", "properties": {"core": {"project": "shared-j62b"}}}, + {"name": "dev-shared-dup", "properties": {"core": {"project": "shared-j62b"}}}, + {"name": "prod-shared", "properties": {"core": {"project": "shared-4t5d"}}} +]` + +const runnersClustersJSON = `[ + {"name": "tenant-cluster-1", "location": "us-central1", "status": "RUNNING", + "currentNodeCount": 3, "currentMasterVersion": "1.33.2-gke.100"} +]` + +func kubeconfigWith(t *testing.T, contexts map[string]string) { + t.Helper() + content := "apiVersion: v1\nkind: Config\nclusters:\ncontexts:\n" + for name := range contexts { + content += "- name: " + name + "\n context:\n cluster: c\n user: u\n" + } + path := filepath.Join(t.TempDir(), "kubeconfig") + require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) + t.Setenv("KUBECONFIG", path) +} + +func TestAuthStatus(t *testing.T) { + t.Run("active account", func(t *testing.T) { + mock := executor.NewMockCommandExecutor() + mock.SetResponse("gcloud auth list", &executor.CommandResult{ExitCode: 0, Stdout: "dev@flamingo.example\n"}) + assert.Equal(t, Authenticated, NewGKEDiscoverer(mock).AuthStatus(context.Background())) + }) + + t.Run("no active account", func(t *testing.T) { + mock := executor.NewMockCommandExecutor() + mock.SetResponse("gcloud auth list", &executor.CommandResult{ExitCode: 0, Stdout: ""}) + assert.Equal(t, NotAuthenticated, NewGKEDiscoverer(mock).AuthStatus(context.Background())) + }) + + t.Run("gcloud errors map to not-authenticated", func(t *testing.T) { + mock := executor.NewMockCommandExecutor() + mock.SetShouldFail(true, "not logged in") + assert.Equal(t, NotAuthenticated, NewGKEDiscoverer(mock).AuthStatus(context.Background())) + }) +} + +func TestProjects_DedupesAndSkipsEmpty(t *testing.T) { + mock := executor.NewMockCommandExecutor() + mock.SetResponse("gcloud config configurations list", &executor.CommandResult{ExitCode: 0, Stdout: configurationsJSON}) + + projects, err := NewGKEDiscoverer(mock).Projects(context.Background()) + require.NoError(t, err) + assert.Equal(t, []string{"shared-4t5d", "shared-j62b", "tenant-runners-db9z"}, projects) +} + +func TestDiscover_ListsClustersAcrossProjects(t *testing.T) { + kubeconfigWith(t, map[string]string{ + "connectgateway_tenant-runners-db9z_us-central1_tenant-cluster-1": "", + }) + + mock := executor.NewMockCommandExecutor() + mock.SetResponse("gcloud config configurations list", &executor.CommandResult{ExitCode: 0, Stdout: configurationsJSON}) + mock.SetResponse("clusters list --project tenant-runners-db9z", &executor.CommandResult{ExitCode: 0, Stdout: runnersClustersJSON}) + mock.SetResponse("clusters list --project shared-j62b", &executor.CommandResult{ExitCode: 0, Stdout: "[]"}) + // prod project: PERMISSION_DENIED must not break discovery of the rest. + mock.SetResponse("clusters list --project shared-4t5d", &executor.CommandResult{ExitCode: 1, Stderr: "PERMISSION_DENIED"}) + + result, err := NewGKEDiscoverer(mock).Discover(context.Background()) + require.NoError(t, err) + + require.Len(t, result.Clusters, 1) + c := result.Clusters[0] + assert.Equal(t, "tenant-cluster-1", c.Name) + assert.Equal(t, models.ClusterTypeGKE, c.Type) + assert.Equal(t, models.SourceExternal, c.Source) + assert.Equal(t, "Running", c.Status) + assert.Equal(t, 3, c.NodeCount) + assert.Equal(t, "tenant-runners-db9z", c.Project) + assert.Equal(t, "us-central1", c.Region) + assert.Equal(t, "connectgateway_tenant-runners-db9z_us-central1_tenant-cluster-1", c.Context) + + require.Len(t, result.Warnings, 1) + assert.Contains(t, result.Warnings[0], "shared-4t5d") +} + +func TestMatchContext(t *testing.T) { + contexts := []string{ + "k3d-openframe-dev", + "gke_proj_us-central1_alpha", + "connectgateway_proj_us-central1_beta", + "gamma", + } + assert.Equal(t, "gke_proj_us-central1_alpha", matchContext(contexts, "proj", "us-central1", "alpha")) + assert.Equal(t, "connectgateway_proj_us-central1_beta", matchContext(contexts, "proj", "us-central1", "beta")) + assert.Equal(t, "gamma", matchContext(contexts, "proj", "us-central1", "gamma")) + assert.Equal(t, "", matchContext(contexts, "proj", "us-central1", "delta")) +} diff --git a/internal/cluster/models/cluster.go b/internal/cluster/models/cluster.go index 11c48f34..3c054118 100644 --- a/internal/cluster/models/cluster.go +++ b/internal/cluster/models/cluster.go @@ -38,10 +38,27 @@ type CloudConfig struct { BackendConfig string `json:"backend_config,omitempty"` } +// ClusterSource says who owns a cluster's lifecycle. +type ClusterSource string + +const ( + // SourceOpenframe: created by this CLI, has a workspace — full lifecycle. + SourceOpenframe ClusterSource = "openframe" + // SourceExternal: discovered in the cloud without a workspace — strictly + // read-only; every mutating command must refuse it. + SourceExternal ClusterSource = "external" +) + // ClusterInfo represents information about a cluster type ClusterInfo struct { Name string `json:"name"` Type ClusterType `json:"type"` + // Source is empty for local (k3d) clusters, where ownership is implicit. + Source ClusterSource `json:"source,omitempty"` + // Context is the kubeconfig context that reaches this cluster, when known. + Context string `json:"context,omitempty"` + Project string `json:"project,omitempty"` + Region string `json:"region,omitempty"` // Status is a human-readable server fraction ("1/1"). Machine consumers // should prefer ReadyServers/TotalServers (verification report: a string // fraction forces JSON consumers to parse it). diff --git a/internal/cluster/models/flags.go b/internal/cluster/models/flags.go index 1f04e61e..04987308 100644 --- a/internal/cluster/models/flags.go +++ b/internal/cluster/models/flags.go @@ -35,6 +35,8 @@ type CreateFlags struct { type ListFlags struct { GlobalFlags Quiet bool + // All additionally discovers external cloud clusters (read-only). + All bool } // StatusFlags contains flags specific to status command @@ -84,6 +86,7 @@ func AddCreateFlags(cmd *cobra.Command, flags *CreateFlags) { // AddListFlags adds list-specific flags to a command func AddListFlags(cmd *cobra.Command, flags *ListFlags) { cmd.Flags().BoolVarP(&flags.Quiet, "quiet", "q", false, "Only show cluster names") + cmd.Flags().BoolVarP(&flags.All, "all", "a", false, "Also discover external cloud clusters (read-only; needs provider CLI auth)") } // AddStatusFlags adds status-specific flags to a command @@ -160,13 +163,16 @@ func ValidateCreateFlags(flags *CreateFlags) error { } // The wizard prompts for these; in skip-wizard mode they must come from - // flags. + // flags. EKS is exempt while its creation is gated behind the coming-soon + // banner — the banner must win over a missing-flag error. 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 clusterType == ClusterTypeGKE && flags.SkipWizard { + if flags.Region == "" { + return fmt.Errorf("--region is required for --type gke with --skip-wizard") + } + if 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)") diff --git a/internal/cluster/providers/eks/kubeconfig.go b/internal/cluster/providers/eks/kubeconfig.go index 321bd81d..1052b9d8 100644 --- a/internal/cluster/providers/eks/kubeconfig.go +++ b/internal/cluster/providers/eks/kubeconfig.go @@ -77,12 +77,19 @@ func restConfigFor(rec tfengine.Record) (*rest.Config, error) { // 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. +// It refuses to overwrite a same-named context that points at a DIFFERENT +// server (see the GKE twin for rationale). func mergeIntoDefaultKubeconfig(rec tfengine.Record) error { pathOpts := clientcmd.NewDefaultPathOptions() existing, err := pathOpts.GetStartingConfig() if err != nil { return fmt.Errorf("loading kubeconfig: %w", err) } + if prior, ok := existing.Contexts[rec.Name]; ok { + if cluster, ok := existing.Clusters[prior.Cluster]; ok && cluster.Server != rec.Endpoint { + return fmt.Errorf("kubeconfig context '%s' already exists and points at %s — refusing to overwrite it; rename the existing context or pick another cluster name", rec.Name, cluster.Server) + } + } generated, err := kubeconfigFor(rec) if err != nil { return err diff --git a/internal/cluster/providers/eks/provider.go b/internal/cluster/providers/eks/provider.go index 938e2869..e0fd650e 100644 --- a/internal/cluster/providers/eks/provider.go +++ b/internal/cluster/providers/eks/provider.go @@ -304,6 +304,9 @@ func infoFor(rec tfengine.Record) models.ClusterInfo { return models.ClusterInfo{ Name: rec.Name, Type: models.ClusterTypeEKS, + Source: models.SourceOpenframe, + Context: rec.Name, + Region: rec.Region, Status: strings.ToTitle(string(rec.Status[0:1])) + string(rec.Status[1:]), NodeCount: rec.NodeCount, K8sVersion: rec.K8sVersion, diff --git a/internal/cluster/providers/gke/kubeconfig.go b/internal/cluster/providers/gke/kubeconfig.go index a17f9278..b849ccde 100644 --- a/internal/cluster/providers/gke/kubeconfig.go +++ b/internal/cluster/providers/gke/kubeconfig.go @@ -66,12 +66,20 @@ func restConfigFor(rec tfengine.Record) (*rest.Config, error) { // mergeIntoDefaultKubeconfig writes the cluster's context into the user's // kubeconfig (honoring $KUBECONFIG) and switches the current context to it. +// It refuses to overwrite a same-named context that points at a DIFFERENT +// server: that context belongs to something else (another cluster, another +// tool) and silently clobbering it would break the user's access 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) } + if prior, ok := existing.Contexts[rec.Name]; ok { + if cluster, ok := existing.Clusters[prior.Cluster]; ok && cluster.Server != rec.Endpoint { + return fmt.Errorf("kubeconfig context '%s' already exists and points at %s — refusing to overwrite it; rename the existing context or pick another cluster name", rec.Name, cluster.Server) + } + } generated, err := kubeconfigFor(rec) if err != nil { return err diff --git a/internal/cluster/providers/gke/provider.go b/internal/cluster/providers/gke/provider.go index 6a573b11..8b3c7757 100644 --- a/internal/cluster/providers/gke/provider.go +++ b/internal/cluster/providers/gke/provider.go @@ -58,6 +58,26 @@ func (p *Provider) preflightCredentials(ctx context.Context, project string) err return nil } +// preflightNameCollision refuses to create a cluster whose name already +// exists in the target project but has no openframe workspace: terraform +// would build the VPC first and then 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. +// +// Existence criterion: describe exits 0 AND prints exactly the cluster name +// (--format=value(name) yields just that). Anything else — non-zero exit, +// empty or unrelated output — is treated as "does not exist"; a genuinely +// broken API call fails later with a clearer terraform error anyway. +func (p *Provider) preflightNameCollision(ctx context.Context, config models.ClusterConfig) error { + result, err := p.executor.Execute(ctx, "gcloud", "container", "clusters", "describe", config.Name, + "--project", config.Cloud.Project, "--region", config.Cloud.Region, "--format=value(name)") + if err != nil || result == nil || strings.TrimSpace(result.Stdout) != config.Name { + return nil // not found (or indeterminate) — proceed + } + return fmt.Errorf("cluster '%s' already exists in project '%s' (region %s) but is not managed by openframe — refusing to touch it; pick another cluster name", + config.Name, config.Cloud.Project, config.Cloud.Region) +} + // backendTF renders the gcs backend block for a GKE workspace. func backendTF(cfg tfengine.BackendConfig) []byte { return []byte(fmt.Sprintf( @@ -130,7 +150,12 @@ func (p *Provider) CreateCluster(ctx context.Context, config models.ClusterConfi } ws := p.registry.Workspace(config.Name) + // The collision check only guards NEW clusters: an existing workspace means + // the cloud cluster (partial or complete) is ours and create resumes it. if !ws.Exists() { + if err := p.preflightNameCollision(ctx, config); err != nil { + return nil, err + } vars, err := tfvarsFor(config) if err != nil { return nil, err @@ -300,6 +325,10 @@ func infoFor(rec tfengine.Record) models.ClusterInfo { return models.ClusterInfo{ Name: rec.Name, Type: models.ClusterTypeGKE, + Source: models.SourceOpenframe, + Context: rec.Name, + Project: rec.Project, + Region: rec.Region, Status: strings.ToTitle(string(rec.Status[0:1])) + string(rec.Status[1:]), NodeCount: rec.NodeCount, K8sVersion: rec.K8sVersion, diff --git a/internal/cluster/providers/gke/provider_test.go b/internal/cluster/providers/gke/provider_test.go index 92be8487..37d7ba38 100644 --- a/internal/cluster/providers/gke/provider_test.go +++ b/internal/cluster/providers/gke/provider_test.go @@ -287,3 +287,67 @@ func TestCreateCluster_RejectsS3Backend(t *testing.T) { assert.Contains(t, err.Error(), "must be gcs://") assert.Empty(t, *calls) } + +// TestCreateCluster_RefusesExternalNameCollision locks the ownership boundary: +// a cluster that already exists in the project WITHOUT an openframe workspace +// is somebody else's — create must refuse before any terraform runs (terraform +// would build the VPC first and fail mid-apply, leaving billed debris). +func TestCreateCluster_RefusesExternalNameCollision(t *testing.T) { + p, calls, registry := newTestProvider(t, nil) + mock := executor.NewMockCommandExecutor() + mock.SetResponse("gcloud container clusters describe demo", &executor.CommandResult{ExitCode: 0, Stdout: "demo\n"}) + p.executor = mock + + _, err := p.CreateCluster(context.Background(), gkeConfig("demo")) + require.Error(t, err) + assert.Contains(t, err.Error(), "not managed by openframe") + assert.Empty(t, *calls, "terraform must not run on a name collision") + + _, err = registry.Get("demo") + var notFound models.ErrClusterNotFound + assert.ErrorAs(t, err, ¬Found, "no workspace must be scaffolded") +} + +// TestCreateCluster_ResumeSkipsCollisionCheck: an existing workspace means the +// cloud cluster is OURS (possibly partially created) — resume must proceed +// even though describe would find it. +func TestCreateCluster_ResumeSkipsCollisionCheck(t *testing.T) { + p, calls, _ := newTestProvider(t, nil) + _, err := p.CreateCluster(context.Background(), gkeConfig("demo")) + require.NoError(t, err) + + mock := executor.NewMockCommandExecutor() + mock.SetResponse("gcloud container clusters describe demo", &executor.CommandResult{ExitCode: 0, Stdout: "demo\n"}) + p.executor = mock + + *calls = nil + _, err = p.CreateCluster(context.Background(), gkeConfig("demo")) + require.NoError(t, err, "resume of an owned cluster must not be blocked by the collision check") + assert.Contains(t, *calls, "apply") +} + +// TestCreateCluster_RefusesKubeconfigContextClobber: a same-named kubeconfig +// context pointing at a DIFFERENT server belongs to something else and must +// not be overwritten. +func TestCreateCluster_RefusesKubeconfigContextClobber(t *testing.T) { + p, _, _ := newTestProvider(t, nil) + kubeconfig := `apiVersion: v1 +kind: Config +clusters: +- name: other + cluster: + server: https://somebody-elses.example:6443 +contexts: +- name: demo + context: + cluster: other + user: other +users: +- name: other +` + require.NoError(t, os.WriteFile(os.Getenv("KUBECONFIG"), []byte(kubeconfig), 0o600)) + + _, err := p.CreateCluster(context.Background(), gkeConfig("demo")) + require.Error(t, err) + assert.Contains(t, err.Error(), "refusing to overwrite") +} diff --git a/internal/cluster/service.go b/internal/cluster/service.go index 2683538f..85a9c82a 100644 --- a/internal/cluster/service.go +++ b/internal/cluster/service.go @@ -919,12 +919,20 @@ func (s *ClusterService) DisplayClusterList(clusters []models.ClusterInfo, quiet return nil } - // Convert to UI display format + // Convert to UI display format. Local clusters have no explicit source — + // label them "local" so the SOURCE column is never ambiguous. displayClusters := make([]uiCluster.ClusterDisplayInfo, len(clusters)) for i, cluster := range clusters { + source := string(cluster.Source) + if source == "" { + source = "local" + } displayClusters[i] = uiCluster.ClusterDisplayInfo{ Name: cluster.Name, Type: string(cluster.Type), + Source: source, + Context: cluster.Context, + Project: cluster.Project, Status: cluster.Status, NodeCount: cluster.NodeCount, CreatedAt: cluster.CreatedAt, diff --git a/internal/cluster/ui/service.go b/internal/cluster/ui/service.go index a7b9d0f8..73e5c6ab 100644 --- a/internal/cluster/ui/service.go +++ b/internal/cluster/ui/service.go @@ -13,6 +13,9 @@ import ( type ClusterDisplayInfo struct { Name string Type string + Source string // "local" | "openframe" | "external" + Context string // kubeconfig context, when known + Project string // GCP project (cloud clusters) Status string NodeCount int CreatedAt time.Time @@ -42,20 +45,54 @@ func (s *DisplayService) ShowClusterList(clusters []ClusterDisplayInfo, out io.W return } - // Create table data - tableData := pterm.TableData{ - {"NAME", "TYPE", "STATUS", "NODES", "CREATED"}, + // The SOURCE/CONTEXT/PROJECT columns only appear when the list contains + // cloud entries — a purely local listing keeps its compact shape. + hasCloud := false + for _, c := range clusters { + if c.Source != "" && c.Source != "local" { + hasCloud = true + break + } + } + + header := []string{"NAME", "TYPE", "STATUS", "NODES", "CREATED"} + if hasCloud { + header = []string{"NAME", "TYPE", "SOURCE", "STATUS", "NODES", "CONTEXT", "PROJECT", "CREATED"} } + tableData := pterm.TableData{header} + orDash := func(s string) string { + if s == "" { + return "—" + } + return s + } for _, clusterInfo := range clusters { statusColor := sharedUI.GetStatusColor(clusterInfo.Status) - tableData = append(tableData, []string{ + created := "" + if !clusterInfo.CreatedAt.IsZero() { + created = clusterInfo.CreatedAt.Format("2006-01-02 15:04") + } + row := []string{ pterm.Bold.Sprint(clusterInfo.Name), clusterInfo.Type, statusColor(clusterInfo.Status), fmt.Sprintf("%d", clusterInfo.NodeCount), - clusterInfo.CreatedAt.Format("2006-01-02 15:04"), - }) + created, + } + if hasCloud { + row = []string{ + pterm.Bold.Sprint(clusterInfo.Name), + clusterInfo.Type, + orDash(clusterInfo.Source), + statusColor(clusterInfo.Status), + fmt.Sprintf("%d", clusterInfo.NodeCount), + orDash(clusterInfo.Context), + orDash(clusterInfo.Project), + created, + } + } + tableData = append(tableData, row) } // Use pterm table for better formatting - but write to the provided writer diff --git a/internal/cluster/ui/wizard_steps.go b/internal/cluster/ui/wizard_steps.go index d78d88f6..7537110f 100644 --- a/internal/cluster/ui/wizard_steps.go +++ b/internal/cluster/ui/wizard_steps.go @@ -41,14 +41,16 @@ 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. AWS EKS is listed but +// gated: choosing it shows the coming-soon banner and re-prompts, so the +// wizard never produces an EKS config while creation is stubbed. func (ws *WizardSteps) PromptClusterType() (models.ClusterType, error) { prompt := promptui.Select{ Label: "Cluster Type", 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)", + "eks (AWS Elastic Kubernetes Service — coming soon)", }, Templates: &promptui.SelectTemplates{ Label: "{{ . }}:", @@ -58,17 +60,20 @@ func (ws *WizardSteps) PromptClusterType() (models.ClusterType, error) { }, } - idx, _, err := prompt.Run() - if err != nil { - return "", err - } - switch idx { - case 1: - return models.ClusterTypeEKS, nil - case 2: - return models.ClusterTypeGKE, nil - default: - return models.ClusterTypeK3d, nil + for { + idx, _, err := prompt.Run() + if err != nil { + return "", err + } + switch idx { + case 1: + return models.ClusterTypeGKE, nil + case 2: + pterm.Info.Println("AWS EKS support is coming soon — pick k3d or gke for now") + continue + default: + return models.ClusterTypeK3d, nil + } } } diff --git a/internal/cluster/utils/cmd_helpers.go b/internal/cluster/utils/cmd_helpers.go index f89fe56a..c9ae8168 100644 --- a/internal/cluster/utils/cmd_helpers.go +++ b/internal/cluster/utils/cmd_helpers.go @@ -43,6 +43,18 @@ func GetCommandService() *cluster.ClusterService { return cluster.NewClusterService(exec) } +// CommandExecutor returns the executor commands should shell through: the +// injected test executor when present (hermetic cmd-layer tests), otherwise a +// real one honoring the global --dry-run/--verbose flags. +func CommandExecutor() executor.CommandExecutor { + if globalFlags != nil && globalFlags.Executor != nil { + return globalFlags.Executor + } + dryRun := globalFlags != nil && globalFlags.Global != nil && globalFlags.Global.DryRun + verbose := globalFlags != nil && globalFlags.Global != nil && globalFlags.Global.Verbose + return executor.NewRealCommandExecutor(dryRun, verbose) +} + // WrapCommandWithCommonSetup wraps a command function with common CLI setup and error handling func WrapCommandWithCommonSetup(runFunc func(cmd *cobra.Command, args []string) error) func(cmd *cobra.Command, args []string) error { return func(cmd *cobra.Command, args []string) error { From fddb6a67235a0a4f04d083e1bf2897a2e256792a Mon Sep 17 00:00:00 2001 From: Oleg Tkachuk Date: Tue, 21 Jul 2026 16:25:01 +0300 Subject: [PATCH 11/38] =?UTF-8?q?feat(cluster):=20openframe=20cluster=20us?= =?UTF-8?q?e=20=E2=80=94=20switch=20kubectl=20context=20to=20any=20visible?= =?UTF-8?q?=20cluster=20(#239)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - resolves local k3d (k3d-/exact context), openframe-managed cloud (context = cluster name), and external GKE clusters found via discovery - external clusters without a kubeconfig entry get credentials fetched via `gcloud container clusters get-credentials` (private-cluster hint on failure); for GKE the gcloud configuration matching the cluster's project is activated so gcloud and kubectl line up - k8s.SwitchContext flips only current-context — never rewrites contexts, clusters, or users; the cluster itself is never touched - no-arg form offers interactive selection; logged-out gcloud yields an actionable auth hint; the prerequisite gate is skipped (no tools needed) --- cmd/cluster/cluster.go | 11 +- cmd/cluster/contract_test.go | 2 +- cmd/cluster/use.go | 164 +++++++++++++++++++++++++ cmd/cluster/use_test.go | 137 +++++++++++++++++++++ docs/getting-started/cloud-clusters.md | 6 + internal/cluster/discovery/gke.go | 32 ++++- internal/cluster/discovery/gke_test.go | 14 +++ internal/k8s/contexts.go | 34 +++++ internal/k8s/contexts_test.go | 48 ++++++++ 9 files changed, 439 insertions(+), 9 deletions(-) create mode 100644 cmd/cluster/use.go create mode 100644 cmd/cluster/use_test.go diff --git a/cmd/cluster/cluster.go b/cmd/cluster/cluster.go index 152d73fb..337cb8d3 100644 --- a/cmd/cluster/cluster.go +++ b/cmd/cluster/cluster.go @@ -21,9 +21,10 @@ func GetClusterCmd() *cobra.Command { This command group provides cluster lifecycle management functionality: • create - Create a new cluster with interactive configuration - • delete - Remove a cluster and clean up resources + • delete - Remove a cluster and clean up resources • list - Show all managed clusters • status - Display detailed cluster information + • use - Switch the kubectl context to a cluster • cleanup - Remove unused images and resources Supports K3d clusters for local development and AWS EKS for cloud deployments. @@ -47,9 +48,10 @@ Examples: 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" { + // (a cloud cluster must not demand Docker/k3d); use only flips local + // kubeconfig/gcloud state and needs no tools at all. The other + // subcommands are k3d-scoped, so the k3d gate stays here. + if cmd.Name() == "create" || cmd.Name() == "use" { return nil } return prerequisites.CheckPrerequisites() @@ -67,6 +69,7 @@ Examples: getDeleteCmd(), getListCmd(), getStatusCmd(), + getUseCmd(), getCleanupCmd(), ) diff --git a/cmd/cluster/contract_test.go b/cmd/cluster/contract_test.go index faae2748..323d55e8 100644 --- a/cmd/cluster/contract_test.go +++ b/cmd/cluster/contract_test.go @@ -18,7 +18,7 @@ func TestClusterContract_RootShape(t *testing.T) { assert.Equal(t, "cluster", cluster.Name()) assert.ElementsMatch(t, []string{"k"}, cluster.Aliases, "k alias is part of the contract") - testutil.AssertSubcommands(t, cluster, "create", "list", "delete", "status", "cleanup") + testutil.AssertSubcommands(t, cluster, "create", "list", "delete", "status", "use", "cleanup") } func TestClusterContract_Flags(t *testing.T) { diff --git a/cmd/cluster/use.go b/cmd/cluster/use.go new file mode 100644 index 00000000..e95bdc40 --- /dev/null +++ b/cmd/cluster/use.go @@ -0,0 +1,164 @@ +package cluster + +import ( + "context" + "fmt" + "strings" + + "github.com/flamingo-stack/openframe-cli/internal/cluster/discovery" + "github.com/flamingo-stack/openframe-cli/internal/cluster/models" + "github.com/flamingo-stack/openframe-cli/internal/cluster/ui" + "github.com/flamingo-stack/openframe-cli/internal/cluster/utils" + "github.com/flamingo-stack/openframe-cli/internal/k8s" + "github.com/flamingo-stack/openframe-cli/internal/shared/executor" + "github.com/pterm/pterm" + "github.com/spf13/cobra" +) + +func getUseCmd() *cobra.Command { + // Ensure global flags are initialized + utils.InitGlobalFlags() + + useCmd := &cobra.Command{ + Use: "use [NAME]", + Short: "Switch the kubectl context to a cluster", + Long: `Switch the current kubectl context (and, for GKE, the active gcloud +configuration) to the named cluster. + +Works for every cluster the CLI can see: local k3d clusters, clusters created +by openframe, and external GKE clusters discovered in your gcloud projects. +For an external cluster without a kubeconfig entry, credentials are fetched +via 'gcloud container clusters get-credentials' first. + +Only local configuration changes: the cluster itself is never touched. + +Examples: + openframe cluster use openframe-dev # local k3d + openframe cluster use my-gke # openframe-managed GKE + openframe cluster use tenant-cluster-1 # external GKE (discovered) + openframe cluster use # interactive selection`, + Args: cobra.MaximumNArgs(1), + PreRunE: func(cmd *cobra.Command, args []string) error { + utils.SyncGlobalFlags() + return utils.ValidateGlobalFlags() + }, + RunE: utils.WrapCommandWithCommonSetup(runUseCluster), + } + + return useCmd +} + +func runUseCluster(cmd *cobra.Command, args []string) error { + service := utils.GetCommandService() + exec := utils.CommandExecutor() + ctx := cmd.Context() + + // Resolve the cluster name: explicit arg, or interactive selection from + // the clusters the CLI already knows (local + managed — fast, no cloud + // calls; external clusters are addressed by explicit name). + name := "" + if len(args) > 0 { + name = strings.TrimSpace(args[0]) + } else { + clusters, err := service.ListClusters() + if err != nil { + return fmt.Errorf("failed to list clusters: %w", err) + } + name, err = ui.SelectClusterByName(clusters, "Select a cluster to use") + if err != nil { + return err + } + if name == "" { + return nil + } + } + + kubeconfig := k8s.DefaultKubeconfigPath() + + // 1) Clusters the CLI knows: managed cloud (registry) or local k3d. + if clusterType, err := service.DetectClusterType(name); err == nil { + contextName := name + if clusterType == models.ClusterTypeK3d { + contextName = k8s.ResolveContextForCluster(kubeconfig, name) + } + if clusterType == models.ClusterTypeGKE { + if info, err := service.GetClusterStatus(name); err == nil { + alignGcloudConfiguration(ctx, exec, info.Project) + } + } + return switchTo(kubeconfig, contextName, name) + } + + // 2) External GKE clusters, addressed by name via discovery. + return useExternalGKE(ctx, exec, kubeconfig, name) +} + +// useExternalGKE finds an external cluster by name and points kubectl at it, +// fetching credentials when the kubeconfig has no entry yet. +func useExternalGKE(ctx context.Context, exec executor.CommandExecutor, kubeconfig, name string) error { + d := discovery.NewGKEDiscoverer(exec) + switch d.AuthStatus(ctx) { + case discovery.CLIMissing: + return fmt.Errorf("cluster '%s' is not known locally, and gcloud is not installed to look for it in GCP", name) + case discovery.NotAuthenticated: + return fmt.Errorf("cluster '%s' is not known locally; run 'gcloud auth login' to look for it in your GCP projects", name) + } + + result, err := d.Discover(ctx) + if err != nil { + return err + } + var found *models.ClusterInfo + for i := range result.Clusters { + if result.Clusters[i].Name == name { + found = &result.Clusters[i] + break + } + } + if found == nil { + return fmt.Errorf("cluster '%s' not found locally or in the %s", name, "GCP projects of your gcloud configurations") + } + + alignGcloudConfiguration(ctx, exec, found.Project) + + contextName := found.Context + if contextName == "" { + // No kubeconfig entry yet — fetch credentials (adds the gke_* context). + pterm.Info.Printf("Fetching credentials for '%s' (project %s, %s)...\n", name, found.Project, found.Region) + if _, err := exec.Execute(ctx, "gcloud", "container", "clusters", "get-credentials", name, + "--project", found.Project, "--region", found.Region); err != nil { + return fmt.Errorf("could not fetch credentials for '%s' (for private clusters try 'gcloud container fleet memberships get-credentials %s'): %w", name, name, err) + } + contextName = fmt.Sprintf("gke_%s_%s_%s", found.Project, found.Region, name) + } + return switchTo(kubeconfig, contextName, name) +} + +// switchTo flips current-context and reports the result. +func switchTo(kubeconfig, contextName, clusterName string) error { + if !k8s.HasContext(kubeconfig, contextName) { + return fmt.Errorf("cluster '%s' has no kubeconfig context '%s' — fetch credentials for it first", clusterName, contextName) + } + if err := k8s.SwitchContext(kubeconfig, contextName); err != nil { + return err + } + pterm.Success.Printf("Switched kubectl context to '%s' (cluster '%s')\n", contextName, clusterName) + return nil +} + +// alignGcloudConfiguration activates the gcloud configuration whose project +// matches the cluster, so gcloud commands line up with kubectl. Best-effort: +// no matching configuration is fine, and failures never block the switch. +func alignGcloudConfiguration(ctx context.Context, exec executor.CommandExecutor, project string) { + if project == "" { + return + } + d := discovery.NewGKEDiscoverer(exec) + configName, err := d.ConfigurationForProject(ctx, project) + if err != nil || configName == "" { + return + } + if _, err := exec.Execute(ctx, "gcloud", "config", "configurations", "activate", configName); err == nil { + pterm.Info.Printf("Activated gcloud configuration '%s' (project %s)\n", configName, project) + } +} diff --git a/cmd/cluster/use_test.go b/cmd/cluster/use_test.go new file mode 100644 index 00000000..64da3830 --- /dev/null +++ b/cmd/cluster/use_test.go @@ -0,0 +1,137 @@ +package cluster + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/flamingo-stack/openframe-cli/internal/cluster/models" + "github.com/flamingo-stack/openframe-cli/internal/cluster/providers/terraform" + "github.com/flamingo-stack/openframe-cli/internal/cluster/utils" + "github.com/flamingo-stack/openframe-cli/internal/k8s" + "github.com/flamingo-stack/openframe-cli/internal/shared/executor" +) + +// writeUseKubeconfig writes a kubeconfig with the given context names and +// points $KUBECONFIG at it. +func writeUseKubeconfig(t *testing.T, current string, contexts ...string) string { + t.Helper() + content := "apiVersion: v1\nkind: Config\ncurrent-context: " + current + "\nclusters:\n- name: c\n cluster:\n server: https://x.example\ncontexts:\n" + for _, name := range contexts { + content += "- name: " + name + "\n context:\n cluster: c\n user: u\n" + } + content += "users:\n- name: u\n" + path := filepath.Join(t.TempDir(), "kubeconfig") + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("KUBECONFIG", path) + return path +} + +func setupUse(t *testing.T) *executor.MockCommandExecutor { + t.Helper() + t.Setenv("OPENFRAME_CLUSTERS_DIR", t.TempDir()) + utils.InitGlobalFlags() + mock := executor.NewMockCommandExecutor() + utils.SetTestExecutor(mock) + t.Cleanup(utils.ResetGlobalFlags) + return mock +} + +func TestRunUseCluster_K3d(t *testing.T) { + mock := setupUse(t) + mock.SetResponse("k3d cluster get dev", &executor.CommandResult{ExitCode: 0, Stdout: "dev"}) + path := writeUseKubeconfig(t, "other", "other", "k3d-dev") + + if err := runUseCluster(getUseCmd(), []string{"dev"}); err != nil { + t.Fatalf("use k3d: %v", err) + } + _, current, _ := k8s.LoadContexts(path) + if current != "k3d-dev" { + t.Fatalf("current-context = %q, want k3d-dev", current) + } +} + +func TestRunUseCluster_ManagedGKEAlignsGcloudConfiguration(t *testing.T) { + mock := setupUse(t) + mock.SetResponse("gcloud config configurations list", &executor.CommandResult{ExitCode: 0, + Stdout: `[{"name":"dev-x","properties":{"core":{"project":"proj-x"}}}]`}) + record := terraform.Record{ + Name: "my-gke", Type: models.ClusterTypeGKE, Status: terraform.StatusReady, + Region: "us-central1", Project: "proj-x", NodeCount: 3, + } + reg := terraform.NewRegistry(os.Getenv("OPENFRAME_CLUSTERS_DIR")) + if err := reg.Workspace("my-gke").Scaffold(record, nil, nil); err != nil { + t.Fatal(err) + } + path := writeUseKubeconfig(t, "other", "other", "my-gke") + + if err := runUseCluster(getUseCmd(), []string{"my-gke"}); err != nil { + t.Fatalf("use managed gke: %v", err) + } + _, current, _ := k8s.LoadContexts(path) + if current != "my-gke" { + t.Fatalf("current-context = %q, want my-gke", current) + } + if !mock.WasCommandExecuted("gcloud config configurations activate dev-x") { + t.Fatal("expected the matching gcloud configuration to be activated") + } +} + +func TestRunUseCluster_ExternalGKEWithExistingContext(t *testing.T) { + mock := setupUse(t) + path := writeUseKubeconfig(t, "other", "other", "connectgateway_proj-x_us-central1_ext-1") + mock.SetResponse("gcloud auth list", &executor.CommandResult{ExitCode: 0, Stdout: "me@example.com\n"}) + mock.SetResponse("gcloud config configurations list", &executor.CommandResult{ExitCode: 0, + Stdout: `[{"name":"dev-x","properties":{"core":{"project":"proj-x"}}}]`}) + mock.SetResponse("clusters list --project proj-x", &executor.CommandResult{ExitCode: 0, + Stdout: `[{"name":"ext-1","location":"us-central1","status":"RUNNING","currentNodeCount":2}]`}) + // k3d detection must miss so the flow falls through to discovery. + mock.SetResponse("k3d cluster get ext-1", &executor.CommandResult{ExitCode: 1, Stderr: "not found"}) + + if err := runUseCluster(getUseCmd(), []string{"ext-1"}); err != nil { + t.Fatalf("use external gke: %v", err) + } + _, current, _ := k8s.LoadContexts(path) + if current != "connectgateway_proj-x_us-central1_ext-1" { + t.Fatalf("current-context = %q", current) + } + if mock.WasCommandExecuted("get-credentials") { + t.Fatal("credentials must not be re-fetched when a context already exists") + } +} + +func TestRunUseCluster_ExternalGKEFetchesCredentials(t *testing.T) { + mock := setupUse(t) + writeUseKubeconfig(t, "other", "other") + mock.SetResponse("gcloud auth list", &executor.CommandResult{ExitCode: 0, Stdout: "me@example.com\n"}) + mock.SetResponse("gcloud config configurations list", &executor.CommandResult{ExitCode: 0, + Stdout: `[{"name":"dev-x","properties":{"core":{"project":"proj-x"}}}]`}) + mock.SetResponse("clusters list --project proj-x", &executor.CommandResult{ExitCode: 0, + Stdout: `[{"name":"ext-1","location":"us-central1","status":"RUNNING","currentNodeCount":2}]`}) + mock.SetResponse("k3d cluster get ext-1", &executor.CommandResult{ExitCode: 1, Stderr: "not found"}) + + err := runUseCluster(getUseCmd(), []string{"ext-1"}) + // The mock cannot actually write the gke_* context, so the switch fails — + // but the credentials fetch must have been attempted first. + if !mock.WasCommandExecuted("gcloud container clusters get-credentials ext-1") { + t.Fatal("expected a get-credentials attempt for a context-less external cluster") + } + if err == nil || !strings.Contains(err.Error(), "no kubeconfig context") { + t.Fatalf("expected a missing-context error after mock fetch, got: %v", err) + } +} + +func TestRunUseCluster_NotAuthenticatedIsActionable(t *testing.T) { + mock := setupUse(t) + writeUseKubeconfig(t, "other", "other") + mock.SetResponse("gcloud auth list", &executor.CommandResult{ExitCode: 0, Stdout: ""}) + mock.SetResponse("k3d cluster get ghost", &executor.CommandResult{ExitCode: 1, Stderr: "not found"}) + + err := runUseCluster(getUseCmd(), []string{"ghost"}) + if err == nil || !strings.Contains(err.Error(), "gcloud auth login") { + t.Fatalf("expected an auth hint, got: %v", err) + } +} diff --git a/docs/getting-started/cloud-clusters.md b/docs/getting-started/cloud-clusters.md index e5a9108a..bbd95de1 100644 --- a/docs/getting-started/cloud-clusters.md +++ b/docs/getting-started/cloud-clusters.md @@ -78,11 +78,17 @@ create, only after a successful delete. ```bash openframe cluster list # local + cloud clusters +openframe cluster list --all # + external clusters discovered in your GCP projects +openframe cluster use my-gke # switch kubectl context (and gcloud configuration) 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 use` works for external (discovered) GKE clusters too: it fetches +credentials via gcloud when the kubeconfig has no entry yet, and activates +the gcloud configuration matching the cluster's project. + `cluster delete --force` skips the typed confirmation (for CI). `cluster cleanup` does not apply to cloud clusters — use `delete`. diff --git a/internal/cluster/discovery/gke.go b/internal/cluster/discovery/gke.go index 624bcc90..a617212a 100644 --- a/internal/cluster/discovery/gke.go +++ b/internal/cluster/discovery/gke.go @@ -68,10 +68,8 @@ type gcloudConfiguration struct { } `json:"properties"` } -// Projects returns the unique GCP projects named by the user's gcloud -// configurations, sorted. Configurations without a project (or with the -// literal "none") are skipped. -func (d *GKEDiscoverer) Projects(ctx context.Context) ([]string, error) { +// configurations lists and parses the user's named gcloud configurations. +func (d *GKEDiscoverer) configurations(ctx context.Context) ([]gcloudConfiguration, error) { result, err := d.exec.Execute(ctx, "gcloud", "config", "configurations", "list", "--format=json") if err != nil { return nil, fmt.Errorf("listing gcloud configurations: %w", err) @@ -80,6 +78,17 @@ func (d *GKEDiscoverer) Projects(ctx context.Context) ([]string, error) { if err := json.Unmarshal([]byte(result.Stdout), &configs); err != nil { return nil, fmt.Errorf("parsing gcloud configurations: %w", err) } + return configs, nil +} + +// Projects returns the unique GCP projects named by the user's gcloud +// configurations, sorted. Configurations without a project (or with the +// literal "none") are skipped. +func (d *GKEDiscoverer) Projects(ctx context.Context) ([]string, error) { + configs, err := d.configurations(ctx) + if err != nil { + return nil, err + } seen := map[string]bool{} var projects []string for _, c := range configs { @@ -94,6 +103,21 @@ func (d *GKEDiscoverer) Projects(ctx context.Context) ([]string, error) { return projects, nil } +// ConfigurationForProject returns the name of the first gcloud configuration +// pointing at the given project, or "" when none does. +func (d *GKEDiscoverer) ConfigurationForProject(ctx context.Context, project string) (string, error) { + configs, err := d.configurations(ctx) + if err != nil { + return "", err + } + for _, c := range configs { + if strings.TrimSpace(c.Properties.Core.Project) == project { + return c.Name, nil + } + } + return "", nil +} + // gkeCluster is the subset of `gcloud container clusters list --format=json` // the discoverer reads. type gkeCluster struct { diff --git a/internal/cluster/discovery/gke_test.go b/internal/cluster/discovery/gke_test.go index f922814b..c1ac90df 100644 --- a/internal/cluster/discovery/gke_test.go +++ b/internal/cluster/discovery/gke_test.go @@ -107,3 +107,17 @@ func TestMatchContext(t *testing.T) { assert.Equal(t, "gamma", matchContext(contexts, "proj", "us-central1", "gamma")) assert.Equal(t, "", matchContext(contexts, "proj", "us-central1", "delta")) } + +func TestConfigurationForProject(t *testing.T) { + mock := executor.NewMockCommandExecutor() + mock.SetResponse("gcloud config configurations list", &executor.CommandResult{ExitCode: 0, Stdout: configurationsJSON}) + d := NewGKEDiscoverer(mock) + + name, err := d.ConfigurationForProject(context.Background(), "tenant-runners-db9z") + require.NoError(t, err) + assert.Equal(t, "dev-tenant-runners", name) + + name, err = d.ConfigurationForProject(context.Background(), "unknown-project") + require.NoError(t, err) + assert.Equal(t, "", name) +} diff --git a/internal/k8s/contexts.go b/internal/k8s/contexts.go index 419e6db0..0589c274 100644 --- a/internal/k8s/contexts.go +++ b/internal/k8s/contexts.go @@ -7,6 +7,7 @@ package k8s import ( + "fmt" "os" "path/filepath" "sort" @@ -57,6 +58,39 @@ func ResolveContextForCluster(kubeconfigPath, clusterName string) string { return k3d } +// HasContext reports whether the kubeconfig at path contains a context with +// the given name. An unreadable kubeconfig counts as "no". +func HasContext(path, name string) bool { + contexts, _, err := LoadContexts(path) + if err != nil { + return false + } + for _, c := range contexts { + if c.Name == name { + return true + } + } + return false +} + +// SwitchContext sets current-context in the kubeconfig at path. It only +// changes the pointer — never contexts, clusters, or users — so it cannot +// damage entries owned by other tools. +func SwitchContext(path, name string) error { + cfg, err := clientcmd.LoadFromFile(path) + if err != nil { + return fmt.Errorf("loading kubeconfig: %w", err) + } + if _, ok := cfg.Contexts[name]; !ok { + return fmt.Errorf("kubeconfig has no context '%s'", name) + } + cfg.CurrentContext = name + if err := clientcmd.WriteToFile(*cfg, path); err != nil { + return fmt.Errorf("writing kubeconfig: %w", err) + } + return nil +} + // LoadContexts reads the kubeconfig at path and returns its contexts (sorted by // name) together with the current-context name. This is what the interactive // context-selection menu is built on. diff --git a/internal/k8s/contexts_test.go b/internal/k8s/contexts_test.go index cf7bf60e..1f550d73 100644 --- a/internal/k8s/contexts_test.go +++ b/internal/k8s/contexts_test.go @@ -96,3 +96,51 @@ func TestDefaultKubeconfigPath_EnvWins(t *testing.T) { t.Setenv("KUBECONFIG", "/custom/kubeconfig") assert.Equal(t, "/custom/kubeconfig", DefaultKubeconfigPath()) } + +func TestSwitchContext(t *testing.T) { + path := filepath.Join(t.TempDir(), "kubeconfig") + kubeconfig := `apiVersion: v1 +kind: Config +current-context: alpha +clusters: +- name: c1 + cluster: + server: https://a.example +contexts: +- name: alpha + context: + cluster: c1 + user: u +- name: beta + context: + cluster: c1 + user: u +users: +- name: u +` + if err := os.WriteFile(path, []byte(kubeconfig), 0o600); err != nil { + t.Fatal(err) + } + + if !HasContext(path, "beta") || HasContext(path, "ghost") { + t.Fatal("HasContext misreports") + } + + if err := SwitchContext(path, "beta"); err != nil { + t.Fatalf("SwitchContext: %v", err) + } + _, current, err := LoadContexts(path) + if err != nil || current != "beta" { + t.Fatalf("current-context = %q (err %v), want beta", current, err) + } + + // Switching only flips the pointer — both contexts must survive. + contexts, _, _ := LoadContexts(path) + if len(contexts) != 2 { + t.Fatalf("contexts damaged: %v", contexts) + } + + if err := SwitchContext(path, "ghost"); err == nil { + t.Fatal("switching to a missing context must fail") + } +} From 67e1d1efd38f93ca5532fff6080a72608fd9b0b4 Mon Sep 17 00:00:00 2001 From: Oleg Tkachuk Date: Tue, 21 Jul 2026 16:49:07 +0300 Subject: [PATCH 12/38] feat(cluster): single gcloud login flow across all GKE commands (#240) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - discovery.AuthFlow: check auth state → confirm → run `gcloud auth login` attached to the terminal → re-verify; `create --type gke` additionally ensures Application Default Credentials (terraform's google provider uses ADC, not CLI credentials) - integrated into create (after the tool gate and in the dry-run preview), list --all (offers login instead of a hint), and use (before discovery) - non-interactive sessions never prompt (CI rule) and get the exact command to run; declined or failed logins surface actionable errors - all branches covered hermetically via interactive/confirm/runLogin seams --- cmd/cluster/create.go | 16 +++ cmd/cluster/list.go | 9 +- cmd/cluster/use.go | 6 +- docs/getting-started/cloud-clusters.md | 9 +- internal/cluster/discovery/auth.go | 113 +++++++++++++++++++ internal/cluster/discovery/auth_test.go | 137 ++++++++++++++++++++++++ 6 files changed, 286 insertions(+), 4 deletions(-) create mode 100644 internal/cluster/discovery/auth.go create mode 100644 internal/cluster/discovery/auth_test.go diff --git a/cmd/cluster/create.go b/cmd/cluster/create.go index 9a5c4e25..8066914f 100644 --- a/cmd/cluster/create.go +++ b/cmd/cluster/create.go @@ -5,6 +5,7 @@ import ( "fmt" "strings" + "github.com/flamingo-stack/openframe-cli/internal/cluster/discovery" "github.com/flamingo-stack/openframe-cli/internal/cluster/models" "github.com/flamingo-stack/openframe-cli/internal/cluster/prerequisites" "github.com/flamingo-stack/openframe-cli/internal/cluster/provider" @@ -94,6 +95,12 @@ func cloudPlanPreview(ctx context.Context, config models.ClusterConfig) error { return nil } + if config.Type == models.ClusterTypeGKE { + if err := discovery.NewAuthFlow(exec).Ensure(ctx, true); err != nil { + return err + } + } + pterm.Info.Printf("Computing terraform plan for %s cluster '%s'...\n", config.Type, config.Name) summary, err := planner.PlanCluster(ctx, config) if err != nil { @@ -227,6 +234,15 @@ func runCreateCluster(cmd *cobra.Command, args []string) error { return err } + // Single auth flow: for GKE, offer `gcloud auth login` (+ ADC for + // terraform) right here instead of failing later in the provider + // preflight with a "run this command" error. + if config.Type == models.ClusterTypeGKE { + if err := discovery.NewAuthFlow(utils.CommandExecutor()).Ensure(cmd.Context(), true); err != nil { + return err + } + } + // 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/list.go b/cmd/cluster/list.go index 2a5b476f..e2fa46f4 100644 --- a/cmd/cluster/list.go +++ b/cmd/cluster/list.go @@ -100,12 +100,17 @@ func runListClusters(cmd *cobra.Command, args []string) error { func discoverExternalClusters(ctx context.Context, managed []models.ClusterInfo) ([]models.ClusterInfo, []string) { notices := []string{"AWS EKS discovery is coming soon — external EKS clusters are not shown yet"} - d := discovery.NewGKEDiscoverer(utils.CommandExecutor()) + exec := utils.CommandExecutor() + d := discovery.NewGKEDiscoverer(exec) switch d.AuthStatus(ctx) { case discovery.CLIMissing: return nil, append(notices, "GKE: gcloud is not installed — install it to discover external clusters") case discovery.NotAuthenticated: - return nil, append(notices, "GKE: not authenticated — run 'gcloud auth login' to discover external clusters") + // One unambiguous flow: offer the login right here (interactive only — + // non-interactive sessions get the same message as before). + if err := discovery.NewAuthFlow(exec).Ensure(ctx, false); err != nil { + return nil, append(notices, "GKE: "+err.Error()) + } } result, err := d.Discover(ctx) diff --git a/cmd/cluster/use.go b/cmd/cluster/use.go index e95bdc40..332da138 100644 --- a/cmd/cluster/use.go +++ b/cmd/cluster/use.go @@ -101,7 +101,11 @@ func useExternalGKE(ctx context.Context, exec executor.CommandExecutor, kubeconf case discovery.CLIMissing: return fmt.Errorf("cluster '%s' is not known locally, and gcloud is not installed to look for it in GCP", name) case discovery.NotAuthenticated: - return fmt.Errorf("cluster '%s' is not known locally; run 'gcloud auth login' to look for it in your GCP projects", name) + // One unambiguous flow: offer the login right here (interactive only). + pterm.Info.Printf("Cluster '%s' is not known locally — looking for it in your GCP projects requires a Google Cloud login\n", name) + if err := discovery.NewAuthFlow(exec).Ensure(ctx, false); err != nil { + return err + } } result, err := d.Discover(ctx) diff --git a/docs/getting-started/cloud-clusters.md b/docs/getting-started/cloud-clusters.md index bbd95de1..48934501 100644 --- a/docs/getting-started/cloud-clusters.md +++ b/docs/getting-started/cloud-clusters.md @@ -19,7 +19,14 @@ Checked and installed automatically on `cluster create`: | 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 +**You do not need to log in beforehand.** When a command needs Google Cloud +access (`create --type gke`, `list --all`, `use`), the CLI checks your gcloud +auth state and, in an interactive session, offers to run `gcloud auth login` +(and `gcloud auth application-default login` for Terraform) right there — one +flow, no manual steps. Non-interactive sessions (CI) never prompt and fail +with the exact command to run instead. + +Credentials are additionally 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. diff --git a/internal/cluster/discovery/auth.go b/internal/cluster/discovery/auth.go new file mode 100644 index 00000000..f4e9c0cb --- /dev/null +++ b/internal/cluster/discovery/auth.go @@ -0,0 +1,113 @@ +package discovery + +import ( + "context" + "fmt" + "os" + osexec "os/exec" + "strings" + + "github.com/flamingo-stack/openframe-cli/internal/shared/executor" + sharedUI "github.com/flamingo-stack/openframe-cli/internal/shared/ui" + "github.com/pterm/pterm" +) + +// AuthFlow is the single gcloud login flow every GKE-touching command goes +// through: it checks the auth state and, in an interactive session, offers to +// run `gcloud auth login` (and `gcloud auth application-default login` when +// terraform needs ADC) right there — so the user never has to leave the CLI. +// Non-interactive sessions never prompt (CI rule) and get an actionable error +// instead. +type AuthFlow struct { + exec executor.CommandExecutor + // interactive reports whether prompting is allowed; seam for tests. + interactive func() bool + // confirm asks the user; seam for tests. + confirm func(message string) (bool, error) + // runLogin runs a gcloud login command attached to the terminal (browser + // flows print URLs and read stdin, so it must bypass the capturing + // executor); seam for tests. + runLogin func(ctx context.Context, args ...string) error +} + +// NewAuthFlow builds the production flow on the given executor (used for the +// non-interactive status probes, so they stay mockable). +func NewAuthFlow(exec executor.CommandExecutor) *AuthFlow { + return &AuthFlow{ + exec: exec, + interactive: func() bool { return !sharedUI.IsNonInteractive() }, + confirm: func(message string) (bool, error) { + return sharedUI.ConfirmActionInteractive(message, true) + }, + runLogin: func(ctx context.Context, args ...string) error { + cmd := osexec.CommandContext(ctx, "gcloud", args...) // #nosec G204 -- fixed argv assembled by this package, no user input + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() + }, + } +} + +// NewAuthFlowWithSeams is the test constructor. +func NewAuthFlowWithSeams(exec executor.CommandExecutor, interactive func() bool, + confirm func(string) (bool, error), runLogin func(context.Context, ...string) error) *AuthFlow { + return &AuthFlow{exec: exec, interactive: interactive, confirm: confirm, runLogin: runLogin} +} + +// Ensure makes sure gcloud is authenticated; with requireADC it also ensures +// Application Default Credentials, which terraform's google provider uses — +// `cluster create --type gke` needs both, discovery/use only the first. +func (f *AuthFlow) Ensure(ctx context.Context, requireADC bool) error { + d := &GKEDiscoverer{exec: f.exec} + switch d.AuthStatus(ctx) { + case CLIMissing: + return fmt.Errorf("gcloud is not installed — install the Google Cloud SDK first (https://cloud.google.com/sdk/docs/install)") + case NotAuthenticated: + if err := f.login(ctx, "Google Cloud login required. Log in now (opens a browser)?", + []string{"auth", "login"}, + func() bool { return d.AuthStatus(ctx) == Authenticated }, + "gcloud is not authenticated — run 'gcloud auth login'"); err != nil { + return err + } + } + + if !requireADC { + return nil + } + if f.hasADC(ctx) { + return nil + } + return f.login(ctx, "Terraform needs Application Default Credentials. Set them up now (opens a browser)?", + []string{"auth", "application-default", "login"}, + func() bool { return f.hasADC(ctx) }, + "Application Default Credentials are missing — run 'gcloud auth application-default login'") +} + +// hasADC probes Application Default Credentials without prompting. +func (f *AuthFlow) hasADC(ctx context.Context) bool { + result, err := f.exec.Execute(ctx, "gcloud", "auth", "application-default", "print-access-token") + return err == nil && result != nil && strings.TrimSpace(result.Stdout) != "" +} + +// login runs one interactive login step: prompt → run → re-verify. +func (f *AuthFlow) login(ctx context.Context, prompt string, args []string, verified func() bool, manualHint string) error { + if !f.interactive() { + return fmt.Errorf("%s", manualHint) + } + confirmed, err := f.confirm(prompt) + if err != nil { + return err + } + if !confirmed { + return fmt.Errorf("%s", manualHint) + } + if err := f.runLogin(ctx, args...); err != nil { + return fmt.Errorf("gcloud %s failed: %w", strings.Join(args, " "), err) + } + if !verified() { + return fmt.Errorf("login did not complete — %s", manualHint) + } + pterm.Success.Println("Google Cloud authentication complete") + return nil +} diff --git a/internal/cluster/discovery/auth_test.go b/internal/cluster/discovery/auth_test.go new file mode 100644 index 00000000..397b211f --- /dev/null +++ b/internal/cluster/discovery/auth_test.go @@ -0,0 +1,137 @@ +package discovery + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/flamingo-stack/openframe-cli/internal/shared/executor" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// authFlowHarness wires an AuthFlow with recording seams. +type authFlowHarness struct { + flow *AuthFlow + mock *executor.MockCommandExecutor + logins []string + confirms []string + confirmAns bool +} + +func newAuthHarness(t *testing.T, interactive bool) *authFlowHarness { + t.Helper() + h := &authFlowHarness{mock: executor.NewMockCommandExecutor(), confirmAns: true} + h.flow = NewAuthFlowWithSeams(h.mock, + func() bool { return interactive }, + func(msg string) (bool, error) { + h.confirms = append(h.confirms, msg) + return h.confirmAns, nil + }, + func(ctx context.Context, args ...string) error { + joined := strings.Join(args, " ") + h.logins = append(h.logins, joined) + // A successful login makes ITS OWN subsequent status probe pass — + // `auth login` must not grant ADC and vice versa. + if strings.Contains(joined, "application-default") { + h.mock.SetResponse("application-default print-access-token", &executor.CommandResult{ExitCode: 0, Stdout: "ya29.token\n"}) + } else { + h.mock.SetResponse("gcloud auth list", &executor.CommandResult{ExitCode: 0, Stdout: "me@example.com\n"}) + } + return nil + }) + return h +} + +func (h *authFlowHarness) loggedOut() { + h.mock.SetResponse("gcloud auth list", &executor.CommandResult{ExitCode: 0, Stdout: ""}) + h.mock.SetResponse("application-default print-access-token", &executor.CommandResult{ExitCode: 1, Stderr: "no credentials"}) +} + +func (h *authFlowHarness) loggedIn(withADC bool) { + h.mock.SetResponse("gcloud auth list", &executor.CommandResult{ExitCode: 0, Stdout: "me@example.com\n"}) + if withADC { + h.mock.SetResponse("application-default print-access-token", &executor.CommandResult{ExitCode: 0, Stdout: "ya29.token\n"}) + } else { + h.mock.SetResponse("application-default print-access-token", &executor.CommandResult{ExitCode: 1, Stderr: "no credentials"}) + } +} + +func TestAuthFlow_AlreadyAuthenticatedIsSilent(t *testing.T) { + h := newAuthHarness(t, true) + h.loggedIn(true) + + require.NoError(t, h.flow.Ensure(context.Background(), true)) + assert.Empty(t, h.logins, "no login must run when already authenticated") + assert.Empty(t, h.confirms, "no prompt must be shown when already authenticated") +} + +func TestAuthFlow_InteractiveLoginRuns(t *testing.T) { + h := newAuthHarness(t, true) + h.loggedOut() + + require.NoError(t, h.flow.Ensure(context.Background(), false)) + assert.Equal(t, []string{"auth login"}, h.logins) +} + +func TestAuthFlow_ADCRequiredRunsBothLogins(t *testing.T) { + h := newAuthHarness(t, true) + h.loggedOut() + + require.NoError(t, h.flow.Ensure(context.Background(), true)) + assert.Equal(t, []string{"auth login", "auth application-default login"}, h.logins) +} + +func TestAuthFlow_ADCOnlyWhenCLICredsPresent(t *testing.T) { + h := newAuthHarness(t, true) + h.loggedIn(false) + + require.NoError(t, h.flow.Ensure(context.Background(), true)) + assert.Equal(t, []string{"auth application-default login"}, h.logins) +} + +func TestAuthFlow_NonInteractiveNeverPrompts(t *testing.T) { + h := newAuthHarness(t, false) + h.loggedOut() + + err := h.flow.Ensure(context.Background(), true) + require.Error(t, err) + assert.Contains(t, err.Error(), "gcloud auth login") + assert.Empty(t, h.logins, "non-interactive sessions must never launch a login") + assert.Empty(t, h.confirms) +} + +func TestAuthFlow_DeclinedGivesManualHint(t *testing.T) { + h := newAuthHarness(t, true) + h.loggedOut() + h.confirmAns = false + + err := h.flow.Ensure(context.Background(), false) + require.Error(t, err) + assert.Contains(t, err.Error(), "gcloud auth login") + assert.Empty(t, h.logins) +} + +func TestAuthFlow_FailedLoginSurfaces(t *testing.T) { + h := newAuthHarness(t, true) + h.loggedOut() + h.flow.runLogin = func(ctx context.Context, args ...string) error { + return errors.New("browser exploded") + } + + err := h.flow.Ensure(context.Background(), false) + require.Error(t, err) + assert.Contains(t, err.Error(), "browser exploded") +} + +func TestAuthFlow_UnverifiedLoginFails(t *testing.T) { + h := newAuthHarness(t, true) + h.loggedOut() + // Login "succeeds" but the status probe still reports logged-out. + h.flow.runLogin = func(ctx context.Context, args ...string) error { return nil } + + err := h.flow.Ensure(context.Background(), false) + require.Error(t, err) + assert.Contains(t, err.Error(), "did not complete") +} From 4d233050e5c0d5330dc06b7decccef5c98c78dc3 Mon Sep 17 00:00:00 2001 From: Oleg Tkachuk Date: Tue, 21 Jul 2026 21:52:20 +0300 Subject: [PATCH 13/38] docs: step-by-step GKE workflow guide (#241) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - new getting-started/gke-workflow.md: zero-to-cluster in order — optional dry-run plan, create (wizard/flags) with the automatic tools→login→ provision chain spelled out, verify, app install, day-2 (list --all, use incl. external clusters), resume-after-failure, remote state, delete, troubleshooting table for the new error messages - docs index distinguishes the reference (cloud-clusters.md) from the walkthrough; cloud-clusters.md notes the EKS coming-soon gate --- docs/README.md | 3 +- docs/getting-started/cloud-clusters.md | 5 + docs/getting-started/gke-workflow.md | 139 +++++++++++++++++++++++++ 3 files changed, 146 insertions(+), 1 deletion(-) create mode 100644 docs/getting-started/gke-workflow.md diff --git a/docs/README.md b/docs/README.md index f9ba3031..2d6d6f36 100644 --- a/docs/README.md +++ b/docs/README.md @@ -10,7 +10,8 @@ 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 +- [Cloud Clusters](./getting-started/cloud-clusters.md) — Provision EKS/GKE clusters with Terraform (reference) +- [GKE Workflow](./getting-started/gke-workflow.md) — Step-by-step: from zero to a running GKE cluster ## Commands diff --git a/docs/getting-started/cloud-clusters.md b/docs/getting-started/cloud-clusters.md index 48934501..86604484 100644 --- a/docs/getting-started/cloud-clusters.md +++ b/docs/getting-started/cloud-clusters.md @@ -1,5 +1,10 @@ # Cloud Clusters (EKS / GKE) +> Looking for a step-by-step walkthrough? See the +> [GKE Workflow](./gke-workflow.md) — this page is the reference. +> AWS EKS creation is currently gated behind a coming-soon banner; the +> sections below describe both providers for when it is enabled. + 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 diff --git a/docs/getting-started/gke-workflow.md b/docs/getting-started/gke-workflow.md new file mode 100644 index 00000000..77894e8f --- /dev/null +++ b/docs/getting-started/gke-workflow.md @@ -0,0 +1,139 @@ +# GKE Workflow — from zero to a running cluster + +The complete, in-order flow for working with Google Kubernetes Engine through +the OpenFrame CLI. You need: the `openframe` binary, a Google account with +access to a GCP project, and a browser for the login. Everything else — +Terraform, gcloud, the auth plugin, credentials — the CLI sets up itself. + +> **Costs.** A GKE cluster bills real money (~$73/month cluster management +> fee + VM nodes + networking). The CLI warns before creating and requires +> re-typing the cluster name before deleting. + +## 0. (Optional) Preview what would be created + +A dry-run computes a real `terraform plan` without creating anything and +without registering the cluster: + +```bash +openframe cluster create my-gke --type gke \ + --project my-project --region us-central1 --skip-wizard --dry-run +# Plan: 27 to add, 0 to change, 0 to destroy +``` + +If Terraform is not installed yet, the preview is skipped with a note — it +installs automatically on a real create. + +## 1. Create the cluster + +One command; interactive wizard or flags. + +**Wizard** (prompts: name → type `gke` → project → region → instance type → +node count → confirmation with a cost warning): + +```bash +openframe cluster create +``` + +**Flags** (defaults: `e2-standard-4`, 3 nodes, latest GKE version): + +```bash +openframe cluster create my-gke --type gke \ + --project my-project --region us-central1 --skip-wizard +``` + +Useful extras: `--machine-type e2-standard-8`, `--min-nodes 1 --max-nodes 6`, +`--spot`, `--version 1.33`, `--nodes 4`. + +What happens, in order — no manual steps in between: + +1. **Tools**: terraform (pinned, checksum-verified, into `~/.openframe/bin`), + gcloud, and `gke-gcloud-auth-plugin` are checked and installed if missing. +2. **Login**: if gcloud is not authenticated, the CLI offers to run + `gcloud auth login` right there (browser opens); then, because Terraform + uses Application Default Credentials, it offers + `gcloud auth application-default login` too. CI/non-interactive sessions + never get prompts — they fail with the exact command to run. +3. **Preflight**: project access is verified, and the CLI refuses to proceed + if a cluster with this name already exists in the project but was not + created by openframe (it will never touch clusters it does not own). +4. **Provision** (~10–15 min): a dedicated VPC with pod/service ranges and a + regional GKE cluster with an autoscaling node pool, streamed as + per-resource progress lines. Add `--verbose` for raw Terraform output. +5. **Kubeconfig**: a context named exactly `my-gke` is merged into your + kubeconfig (existing contexts are never overwritten) and made current. + +## 2. Verify + +```bash +kubectl get nodes # exec-auth via gke-gcloud-auth-plugin +openframe cluster status my-gke +openframe cluster list +``` + +## 3. Install OpenFrame onto it + +```bash +openframe app install # targets the current kubectl context +openframe app status +openframe app access # ArgoCD URL + credentials +``` + +## 4. Day-2 operations + +```bash +# See everything: local k3d + openframe-managed + EXTERNAL clusters +# discovered in the GCP projects of your gcloud configurations +openframe cluster list --all +# NAME TYPE SOURCE STATUS NODES CONTEXT PROJECT CREATED +# my-gke gke openframe Ready 3 my-gke my-project 2026-07-21 14:02 +# tenant-cluster-1 gke external Running 3 connectgateway_..._tenant-... tenant-runners-db9z — + +# Switch kubectl (and the matching gcloud configuration) to any of them: +openframe cluster use my-gke +openframe cluster use tenant-cluster-1 # external: credentials are fetched + # via gcloud if not present yet +``` + +External clusters are strictly **read-only** for openframe: they show up in +`list --all`/`status`/`use`, but `delete` and `cleanup` refuse them. + +## 5. If a create fails or is interrupted + +The workspace and Terraform state under `~/.openframe/clusters/my-gke/` are +kept — they are the only pointer to the billed resources. Two options: + +```bash +openframe cluster create my-gke --type gke \ + --project my-project --region us-central1 --skip-wizard # resumes +openframe cluster delete my-gke # tears down +``` + +Want the state to survive your machine? Create with +`--backend-config gcs://my-bucket/clusters/my-gke` (remote state in GCS). + +## 6. Delete + +```bash +openframe cluster delete my-gke +# → asks you to re-type "my-gke", then terraform destroy removes the +# cluster, node pool, and VPC; the workspace and kubeconfig context are +# cleaned up afterwards +``` + +`--force` skips the typed confirmation (CI). `cluster cleanup` does not apply +to cloud clusters — use `delete`. + +## Troubleshooting + +| Symptom | What to do | +| --- | --- | +| "gcloud is not authenticated" (CI) | run `gcloud auth login` and `gcloud auth application-default login` in an interactive session | +| "project ... is not accessible" | check the project ID and your IAM role (`gcloud projects describe `) | +| "already exists ... not managed by openframe" | the name is taken by a cluster openframe does not own — pick another name | +| "kubeconfig context ... refusing to overwrite" | a same-named context points elsewhere — rename it or pick another cluster name | +| create failed mid-way | re-run the same create to resume, or delete to tear down (state is never lost) | +| want Terraform's own logs | add `--verbose` | + +See [Cloud Clusters](./cloud-clusters.md) for the reference (flags, state +model, EKS status) and `docs/architecture/decisions.md` (D5, D7, D8) for the +design rationale. From 67bb70aee773c2f9edbd3c6c6d440007450e58d7 Mon Sep 17 00:00:00 2001 From: Oleg Tkachuk Date: Wed, 22 Jul 2026 13:34:33 +0300 Subject: [PATCH 14/38] fix(cluster): make interrupted cloud creates resumable; tighten safety guards (#243) Audit findings on idempotency/safety, all fixed: - resume was unreachable through the CLI: any registry record (even status=failed) short-circuited into the "already exists" reuse path and errored on GetRestConfig. Non-Ready cloud records now fall through to the provider, whose terraform apply resumes from state (shouldResumeCloudCreate) - the reuse box printed a hardcoded green "Running" and a k3d- network line for every cluster type; it now shows the real status and k3d rows only for k3d - the GKE name-collision preflight was region-scoped and missed zonal clusters; it now uses a project-wide clusters list --filter - delete removed kubeconfig entries by name alone; it now only removes them when the context still points at the record's endpoint (mirror of the create-side no-clobber guard) --- internal/cluster/providers/eks/kubeconfig.go | 18 ++-- internal/cluster/providers/eks/provider.go | 7 +- internal/cluster/providers/gke/kubeconfig.go | 20 ++-- internal/cluster/providers/gke/provider.go | 33 ++++-- .../cluster/providers/gke/provider_test.go | 54 +++++++++- internal/cluster/service.go | 102 +++++++++++------- internal/cluster/service_test.go | 25 +++++ 7 files changed, 195 insertions(+), 64 deletions(-) diff --git a/internal/cluster/providers/eks/kubeconfig.go b/internal/cluster/providers/eks/kubeconfig.go index 1052b9d8..fa68ce19 100644 --- a/internal/cluster/providers/eks/kubeconfig.go +++ b/internal/cluster/providers/eks/kubeconfig.go @@ -104,18 +104,24 @@ func mergeIntoDefaultKubeconfig(rec tfengine.Record) error { return nil } -// removeFromDefaultKubeconfig drops the cluster's context after a destroy. +// removeFromDefaultKubeconfig drops the cluster's context after a destroy — +// but ONLY when the entry still points at OUR endpoint (see the GKE twin). // Best-effort: a missing entry is not an error. -func removeFromDefaultKubeconfig(name string) error { +func removeFromDefaultKubeconfig(rec tfengine.Record) 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 { + if prior, ok := existing.Contexts[rec.Name]; ok { + if cluster, ok := existing.Clusters[prior.Cluster]; ok && cluster.Server != rec.Endpoint { + return nil // same name, different server — not ours anymore + } + } + delete(existing.Clusters, rec.Name) + delete(existing.AuthInfos, rec.Name) + delete(existing.Contexts, rec.Name) + if existing.CurrentContext == rec.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 index e0fd650e..12808773 100644 --- a/internal/cluster/providers/eks/provider.go +++ b/internal/cluster/providers/eks/provider.go @@ -213,11 +213,16 @@ func (p *Provider) DeleteCluster(ctx context.Context, name string, clusterType m if !ws.Exists() { return models.NewClusterNotFoundError(name) } + // Read the record BEFORE destroy: the endpoint in it is what proves the + // kubeconfig entry is ours to remove afterwards. + rec, recErr := ws.ReadRecord() 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) + if recErr == nil { + _ = removeFromDefaultKubeconfig(rec) + } return ws.Remove() } diff --git a/internal/cluster/providers/gke/kubeconfig.go b/internal/cluster/providers/gke/kubeconfig.go index b849ccde..24d605af 100644 --- a/internal/cluster/providers/gke/kubeconfig.go +++ b/internal/cluster/providers/gke/kubeconfig.go @@ -94,17 +94,25 @@ func mergeIntoDefaultKubeconfig(rec tfengine.Record) error { return nil } -// removeFromDefaultKubeconfig drops the cluster's context after a destroy. -func removeFromDefaultKubeconfig(name string) error { +// removeFromDefaultKubeconfig drops the cluster's context after a destroy — +// but ONLY when the entry still points at OUR endpoint. If the user repointed +// or recreated a same-named context toward another server since the create, +// it is no longer ours to delete (the create-side no-clobber guard's mirror). +func removeFromDefaultKubeconfig(rec tfengine.Record) 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 { + if prior, ok := existing.Contexts[rec.Name]; ok { + if cluster, ok := existing.Clusters[prior.Cluster]; ok && cluster.Server != rec.Endpoint { + return nil // same name, different server — not ours anymore + } + } + delete(existing.Clusters, rec.Name) + delete(existing.AuthInfos, rec.Name) + delete(existing.Contexts, rec.Name) + if existing.CurrentContext == rec.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 index 8b3c7757..2318ab84 100644 --- a/internal/cluster/providers/gke/provider.go +++ b/internal/cluster/providers/gke/provider.go @@ -64,18 +64,24 @@ func (p *Provider) preflightCredentials(ctx context.Context, project string) err // leaving partial billed infrastructure. External clusters are strictly not // ours to touch — the user must pick another name. // -// Existence criterion: describe exits 0 AND prints exactly the cluster name -// (--format=value(name) yields just that). Anything else — non-zero exit, -// empty or unrelated output — is treated as "does not exist"; a genuinely -// broken API call fails later with a clearer terraform error anyway. +// Existence criterion: the project-wide listing (deliberately NOT scoped to a +// location — a ZONAL cluster with the same name must also count) prints a +// line equal to the cluster name. Anything else — non-zero exit, empty or +// unrelated output — is treated as "does not exist"; a genuinely broken API +// call fails later with a clearer terraform error anyway. func (p *Provider) preflightNameCollision(ctx context.Context, config models.ClusterConfig) error { - result, err := p.executor.Execute(ctx, "gcloud", "container", "clusters", "describe", config.Name, - "--project", config.Cloud.Project, "--region", config.Cloud.Region, "--format=value(name)") - if err != nil || result == nil || strings.TrimSpace(result.Stdout) != config.Name { - return nil // not found (or indeterminate) — proceed + result, err := p.executor.Execute(ctx, "gcloud", "container", "clusters", "list", + "--project", config.Cloud.Project, "--filter=name="+config.Name, "--format=value(name)") + if err != nil || result == nil { + return nil // indeterminate — proceed + } + for _, line := range strings.Split(result.Stdout, "\n") { + if strings.TrimSpace(line) == config.Name { + return fmt.Errorf("cluster '%s' already exists in project '%s' but is not managed by openframe — refusing to touch it; pick another cluster name", + config.Name, config.Cloud.Project) + } } - return fmt.Errorf("cluster '%s' already exists in project '%s' (region %s) but is not managed by openframe — refusing to touch it; pick another cluster name", - config.Name, config.Cloud.Project, config.Cloud.Region) + return nil } // backendTF renders the gcs backend block for a GKE workspace. @@ -235,11 +241,16 @@ func (p *Provider) DeleteCluster(ctx context.Context, name string, clusterType m if !ws.Exists() { return models.NewClusterNotFoundError(name) } + // Read the record BEFORE destroy: the endpoint in it is what proves the + // kubeconfig entry is ours to remove afterwards. + rec, recErr := ws.ReadRecord() 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) + if recErr == nil { + _ = removeFromDefaultKubeconfig(rec) + } return ws.Remove() } diff --git a/internal/cluster/providers/gke/provider_test.go b/internal/cluster/providers/gke/provider_test.go index 37d7ba38..1d51b59a 100644 --- a/internal/cluster/providers/gke/provider_test.go +++ b/internal/cluster/providers/gke/provider_test.go @@ -295,7 +295,7 @@ func TestCreateCluster_RejectsS3Backend(t *testing.T) { func TestCreateCluster_RefusesExternalNameCollision(t *testing.T) { p, calls, registry := newTestProvider(t, nil) mock := executor.NewMockCommandExecutor() - mock.SetResponse("gcloud container clusters describe demo", &executor.CommandResult{ExitCode: 0, Stdout: "demo\n"}) + mock.SetResponse("clusters list --project my-project", &executor.CommandResult{ExitCode: 0, Stdout: "demo\n"}) p.executor = mock _, err := p.CreateCluster(context.Background(), gkeConfig("demo")) @@ -317,7 +317,7 @@ func TestCreateCluster_ResumeSkipsCollisionCheck(t *testing.T) { require.NoError(t, err) mock := executor.NewMockCommandExecutor() - mock.SetResponse("gcloud container clusters describe demo", &executor.CommandResult{ExitCode: 0, Stdout: "demo\n"}) + mock.SetResponse("clusters list --project my-project", &executor.CommandResult{ExitCode: 0, Stdout: "demo\n"}) p.executor = mock *calls = nil @@ -351,3 +351,53 @@ users: require.Error(t, err) assert.Contains(t, err.Error(), "refusing to overwrite") } + +// TestDeleteCluster_LeavesRepointedContextAlone (П4): if the user repointed a +// same-named kubeconfig context at another server after the create, delete +// must not remove it — the mirror of the create-side no-clobber guard. +func TestDeleteCluster_LeavesRepointedContextAlone(t *testing.T) { + p, _, _ := newTestProvider(t, nil) + _, err := p.CreateCluster(context.Background(), gkeConfig("demo")) + require.NoError(t, err) + + // Repoint the "demo" context at somebody else's server. + repointed := `apiVersion: v1 +kind: Config +current-context: demo +clusters: +- name: demo + cluster: + server: https://somebody-elses.example:6443 +contexts: +- name: demo + context: + cluster: demo + user: demo +users: +- name: demo +` + require.NoError(t, os.WriteFile(os.Getenv("KUBECONFIG"), []byte(repointed), 0o600)) + + require.NoError(t, p.DeleteCluster(context.Background(), "demo", models.ClusterTypeGKE, false)) + + kubeconfig, err := os.ReadFile(os.Getenv("KUBECONFIG")) + require.NoError(t, err) + assert.Contains(t, string(kubeconfig), "somebody-elses.example", "a repointed context must survive delete") + assert.Contains(t, string(kubeconfig), "current-context: demo") +} + +// TestPreflightNameCollision_FindsZonalClusters (П3): the collision check uses +// a project-wide listing, so a zonal cluster with the same name (which a +// region-scoped describe would miss) also counts. +func TestPreflightNameCollision_FindsZonalClusters(t *testing.T) { + p, calls, _ := newTestProvider(t, nil) + mock := executor.NewMockCommandExecutor() + // clusters list output for a zonal cluster: same name, any location. + mock.SetResponse("clusters list --project my-project", &executor.CommandResult{ExitCode: 0, Stdout: "demo\n"}) + p.executor = mock + + _, err := p.CreateCluster(context.Background(), gkeConfig("demo")) + require.Error(t, err) + assert.Contains(t, err.Error(), "not managed by openframe") + assert.Empty(t, *calls) +} diff --git a/internal/cluster/service.go b/internal/cluster/service.go index 85a9c82a..a5fff798 100644 --- a/internal/cluster/service.go +++ b/internal/cluster/service.go @@ -106,6 +106,55 @@ func (s *ClusterService) providerFor(clusterType models.ClusterType) (provider.P return provider.New(clusterType, s.executor) } +// shouldResumeCloudCreate decides whether an existing registry entry means +// "reuse the cluster" or "resume an interrupted create". Cloud records exist +// from the moment a workspace is scaffolded, so a non-Ready status marks a +// create that failed or was interrupted — terraform apply resumes it +// idempotently. Local k3d clusters only ever surface here when they actually +// run, so they always reuse. +func shouldResumeCloudCreate(clusterType models.ClusterType, status string) bool { + isCloud := clusterType == models.ClusterTypeEKS || clusterType == models.ClusterTypeGKE + return isCloud && status != "Ready" +} + +// showExistingClusterReuse prints the "already exists" summary with the +// cluster's REAL status and only k3d-specific rows for k3d clusters — this +// box used to hardcode a green "Running" and a "k3d-" network line for +// every type, which was wrong for cloud clusters. +func (s *ClusterService) showExistingClusterReuse(name string, info models.ClusterInfo) { + pterm.Warning.Printf("Cluster '%s' already exists!\n", pterm.Cyan(name)) + pterm.DefaultBasicText.Println() + + statusColor := ui.GetStatusColor(info.Status) + boxContent := fmt.Sprintf( + "NAME: %s\n"+ + "TYPE: %s\n"+ + "STATUS: %s\n"+ + "NODES: %d", + pterm.Bold.Sprint(info.Name), + strings.ToUpper(string(info.Type)), + statusColor(info.Status), + info.NodeCount, + ) + if info.Type == models.ClusterTypeK3d { + boxContent += fmt.Sprintf("\nNETWORK: k3d-%s", info.Name) + } + + pterm.DefaultBox. + WithTitle(" ⚠️ Cluster Already Exists ⚠️ "). + WithTitleTopCenter(). + Println(boxContent) + + // Show what user can do (suppress for automation) + if !s.suppressUI { + pterm.DefaultBasicText.Println() + pterm.Info.Printf("What would you like to do?\n") + pterm.DefaultBasicText.Printf(" • Check status: openframe cluster status %s\n", name) + pterm.DefaultBasicText.Printf(" • Delete first: openframe cluster delete %s\n", name) + pterm.DefaultBasicText.Printf(" • Use different name: openframe cluster create my-new-cluster\n") + } +} + // 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) { @@ -118,45 +167,22 @@ func (s *ClusterService) CreateCluster(ctx context.Context, config models.Cluste // Check if cluster already exists if existingInfo, err := mgr.GetClusterStatus(ctx, config.Name); err == nil { - // Cluster already exists - show friendly message - - // Show warning for existing cluster - pterm.Warning.Printf("Cluster '%s' already exists!\n", pterm.Cyan(config.Name)) - pterm.DefaultBasicText.Println() - - boxContent := fmt.Sprintf( - "NAME: %s\n"+ - "TYPE: %s\n"+ - "STATUS: %s\n"+ - "NODES: %d\n"+ - "NETWORK: k3d-%s", - pterm.Bold.Sprint(existingInfo.Name), - strings.ToUpper(string(existingInfo.Type)), - pterm.Green("Running"), - existingInfo.NodeCount, - existingInfo.Name, - ) - - pterm.DefaultBox. - WithTitle(" ⚠️ Cluster Already Running ⚠️ "). - WithTitleTopCenter(). - Println(boxContent) - - // Show what user can do (suppress for automation) - if !s.suppressUI { - pterm.DefaultBasicText.Println() - pterm.Info.Printf("What would you like to do?\n") - pterm.DefaultBasicText.Printf(" • Check status: openframe cluster status %s\n", config.Name) - pterm.DefaultBasicText.Printf(" • Delete first: openframe cluster delete %s\n", config.Name) - pterm.DefaultBasicText.Printf(" • Use different name: openframe cluster create my-new-cluster\n") - } - - // Return the rest.Config for the existing cluster - restConfig, err := mgr.GetRestConfig(ctx, config.Name) - if err != nil { - return nil, fmt.Errorf("cluster exists but failed to get REST config: %w", err) + if shouldResumeCloudCreate(config.Type, existingInfo.Status) { + // A cloud workspace whose create failed or was interrupted: fall + // through to the provider, whose terraform apply resumes from the + // recorded state. The registry record must NOT short-circuit here — + // that made the documented "re-run create to resume" unreachable. + pterm.Info.Printf("Cluster '%s' has an interrupted creation (status: %s) — resuming\n", + config.Name, existingInfo.Status) + } else { + // Cluster already exists and is usable — reuse it. + s.showExistingClusterReuse(config.Name, existingInfo) + 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 } - return restConfig, nil // Exit gracefully without error } // Cluster doesn't exist, proceed with creation. Cloud creates stream diff --git a/internal/cluster/service_test.go b/internal/cluster/service_test.go index 49189a94..abd91e57 100644 --- a/internal/cluster/service_test.go +++ b/internal/cluster/service_test.go @@ -240,3 +240,28 @@ func TestClusterService_WithRealExecutor(t *testing.T) { // Dry-run might still error if k3d is not available, which is acceptable in tests _ = err } + +// TestShouldResumeCloudCreate locks the resume decision (audit П1): a cloud +// registry record with a non-Ready status must RESUME through the provider, +// not short-circuit into the "already exists" reuse path — that made the +// documented "re-run create to resume" unreachable from the CLI. +func TestShouldResumeCloudCreate(t *testing.T) { + cases := []struct { + clusterType models.ClusterType + status string + want bool + }{ + {models.ClusterTypeGKE, "Failed", true}, + {models.ClusterTypeGKE, "Creating", true}, + {models.ClusterTypeGKE, "Ready", false}, + {models.ClusterTypeEKS, "Failed", true}, + {models.ClusterTypeEKS, "Ready", false}, + {models.ClusterTypeK3d, "1/1", false}, + {models.ClusterTypeK3d, "0/1", false}, + } + for _, tc := range cases { + if got := shouldResumeCloudCreate(tc.clusterType, tc.status); got != tc.want { + t.Errorf("shouldResumeCloudCreate(%s, %q) = %v, want %v", tc.clusterType, tc.status, got, tc.want) + } + } +} From 7245ccfb42aa343a11d09ff50d867fd3662930c6 Mon Sep 17 00:00:00 2001 From: Oleg Tkachuk Date: Wed, 22 Jul 2026 14:00:29 +0300 Subject: [PATCH 15/38] ci(test): exercise the full cloud-cluster command surface without creating one (#244) --- .github/workflows/test.yml | 111 ++++++++++++++++++ cmd/cluster/status_json_test.go | 3 + .../chart/prerequisites/installer_test.go | 1 + .../cluster/prerequisites/checker_test.go | 1 + .../cluster/prerequisites/installer_test.go | 1 + internal/cluster/service_test.go | 1 + internal/shared/config/system_test.go | 1 + 7 files changed, 119 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 816f3c42..e6dac23c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -345,6 +345,61 @@ jobs: echo "===================================================================" "$OF_BIN" prerequisites check || echo "some prerequisites missing (expected pre-bootstrap)" + # Cloud-cluster command SURFACE, with no cloud and no Docker: the EKS + # coming-soon banner, flag validation, the GKE dry-run's graceful + # degradation, and `cluster use` error paths. Everything here is either + # exit-0 by design or an expected, asserted failure — no resource is + # ever created. Pure, so it runs on every OS (on Windows via the WSL + # forward, exercising the Linux binary's paths). + - name: 'Cluster: cloud command surface (no cloud, no Docker)' + shell: bash + run: | + echo "===================================================================" + echo "=== TEST: cloud cluster surface — banner, validation, dry-run, use" + echo "===================================================================" + fail() { echo "::error::$1"; exit 1; } + + echo "--- EKS create is gated behind the coming-soon banner (exit 0)" + "$OF_BIN" cluster create eks-smoke --type eks --skip-wizard >/tmp/eks.out 2>&1 \ + || { cat /tmp/eks.out; fail "eks banner path must exit 0"; } + grep -qi "coming soon" /tmp/eks.out || { cat /tmp/eks.out; fail "eks banner text missing"; } + + echo "--- unknown --type is rejected" + if "$OF_BIN" cluster create x --type minikube --skip-wizard >/tmp/neg.out 2>&1; then + cat /tmp/neg.out; fail "unknown cluster type must be rejected" + fi + grep -q "unknown cluster type" /tmp/neg.out || { cat /tmp/neg.out; fail "missing unknown-type message"; } + + echo "--- gke --skip-wizard demands --region and --project" + if "$OF_BIN" cluster create x --type gke --skip-wizard >/tmp/neg.out 2>&1; then + cat /tmp/neg.out; fail "gke without region/project must be rejected" + fi + grep -qE '\-\-(region|project) is required' /tmp/neg.out || { cat /tmp/neg.out; fail "missing required-flag message"; } + + echo "--- gke dry-run degrades gracefully without terraform/credentials" + # Two legitimate outcomes on an unauthenticated runner: terraform absent + # -> soft skip (exit 0); terraform present -> the auth flow fails fast + # (non-interactive, exit non-zero). Both must be actionable, never a hang. + set +e + timeout 120 "$OF_BIN" cluster create gke-smoke --type gke \ + --project no-such-project --region us-central1 --skip-wizard --dry-run /tmp/gke.out 2>&1 + code=$? + set -e + [ "$code" -eq 124 ] && { cat /tmp/gke.out; fail "gke dry-run hung on a prompt"; } + grep -qiE "skipping the plan preview|not authenticated|not installed|application default|not accessible" /tmp/gke.out \ + || { cat /tmp/gke.out; fail "gke dry-run produced neither a skip nor an auth hint (exit $code)"; } + echo "OK (exit $code)" + + echo "--- cluster use of an unknown cluster is an actionable error" + set +e + timeout 60 "$OF_BIN" cluster use no-such-cluster /tmp/use.out 2>&1 + code=$? + set -e + [ "$code" -eq 124 ] && { cat /tmp/use.out; fail "cluster use hung on a prompt"; } + [ "$code" -eq 0 ] && { cat /tmp/use.out; fail "cluster use of an unknown cluster must fail"; } + grep -qiE "not (known|found) locally|gcloud" /tmp/use.out || { cat /tmp/use.out; fail "missing actionable use error"; } + echo "cloud surface OK" + # Everything below needs a Docker daemon + k3d. It runs on linux and on # windows (the Windows binary forwards into WSL, where Docker was started # above), exercising the real cluster lifecycle on both. darwin stays @@ -360,6 +415,62 @@ jobs: "$OF_BIN" cluster list -o json "$OF_BIN" cluster list -o yaml + # Managed-cloud OWNERSHIP GUARDS, exercised on the real binary against a + # fake GKE workspace (an isolated OPENFRAME_CLUSTERS_DIR with a + # hand-written cluster.json) — no cloud, no terraform, fully + # deterministic. Locks: registry-backed list/status, the typed-confirm + # refusal on non-interactive cloud delete, and use's missing-context + # error. Linux only: one leg suffices and the Windows binary would need + # the fake workspace inside WSL's home. + - name: 'Cluster: managed-cloud guards (fake workspace, no cloud)' + if: matrix.os == 'linux' + shell: bash + env: + OPENFRAME_CLUSTERS_DIR: ${{ runner.temp }}/of-fake-clusters + run: | + echo "===================================================================" + echo "=== TEST: cloud ownership guards on a fake GKE workspace" + echo "===================================================================" + fail() { echo "::error::$1"; exit 1; } + export KUBECONFIG="$RUNNER_TEMP/of-fake-kubeconfig" + + mkdir -p "$OPENFRAME_CLUSTERS_DIR/fake-gke" + cat > "$OPENFRAME_CLUSTERS_DIR/fake-gke/cluster.json" <<'EOF' + {"name":"fake-gke","type":"gke","status":"ready","region":"us-central1", + "project":"fake-project","node_count":3,"created_at":"2026-07-22T00:00:00Z", + "endpoint":"https://203.0.113.10","ca_cert":"ZmFrZS1jYQ=="} + EOF + + echo "--- the registry cluster appears in list with source=openframe" + "$OF_BIN" cluster list -o json | jq -e \ + '.[] | select(.name == "fake-gke")' >/dev/null \ + || fail "fake-gke missing from cluster list -o json" + + echo "--- status resolves through the registry (no cloud calls)" + "$OF_BIN" cluster status fake-gke -o json | jq -e \ + '.type == "gke"' >/dev/null || fail "status -o json lacks type gke" + + echo "--- use without a kubeconfig context fails with the exact hint" + set +e + "$OF_BIN" cluster use fake-gke /tmp/use.out 2>&1 + code=$? + set -e + [ "$code" -eq 0 ] && { cat /tmp/use.out; fail "use without a context must fail"; } + grep -q "no kubeconfig context" /tmp/use.out || { cat /tmp/use.out; fail "missing no-context hint"; } + + echo "--- non-interactive cloud delete WITHOUT --force refuses (typed-confirm gate)" + set +e + timeout 60 "$OF_BIN" cluster delete fake-gke /tmp/del.out 2>&1 + code=$? + set -e + [ "$code" -eq 124 ] && { cat /tmp/del.out; fail "cluster delete hung on a prompt"; } + [ "$code" -eq 0 ] && { cat /tmp/del.out; fail "non-interactive cloud delete without --force must refuse"; } + grep -q "refusing to destroy cloud cluster" /tmp/del.out || { cat /tmp/del.out; fail "missing refusal message"; } + + echo "--- the workspace (the only pointer to billed resources) survived" + [ -f "$OPENFRAME_CLUSTERS_DIR/fake-gke/cluster.json" ] || fail "refused delete must not remove the workspace" + echo "ownership guards OK" + # --- Medium: dry-run, still no real changes ---------------------------- - name: 'CLI: dry-run create & install' if: matrix.os != 'darwin' diff --git a/cmd/cluster/status_json_test.go b/cmd/cluster/status_json_test.go index 21f11ee9..bf3b5dac 100644 --- a/cmd/cluster/status_json_test.go +++ b/cmd/cluster/status_json_test.go @@ -13,7 +13,10 @@ import ( func TestStatusCommandHasOutputFlag(t *testing.T) { f := getStatusCmd().Flags().Lookup("output") if f == nil { + // The explicit return keeps SA5011 happy on linters that lose the + // noreturn fact for t.Fatal. t.Fatal("cluster status is missing the --output flag") + return } if f.Shorthand != "o" { t.Fatalf("--output shorthand = %q, want %q", f.Shorthand, "o") diff --git a/internal/chart/prerequisites/installer_test.go b/internal/chart/prerequisites/installer_test.go index f4d24354..8f7f321b 100644 --- a/internal/chart/prerequisites/installer_test.go +++ b/internal/chart/prerequisites/installer_test.go @@ -9,6 +9,7 @@ func TestNewInstaller(t *testing.T) { if installer == nil { t.Fatal("Expected installer to be created") + return } if installer.checker == nil { diff --git a/internal/cluster/prerequisites/checker_test.go b/internal/cluster/prerequisites/checker_test.go index 9007706f..70e73c8a 100644 --- a/internal/cluster/prerequisites/checker_test.go +++ b/internal/cluster/prerequisites/checker_test.go @@ -110,6 +110,7 @@ func TestCheckerForClusterType(t *testing.T) { checker := checkerForClusterType(tc.clusterType) if checker == nil { t.Fatalf("checkerForClusterType(%q) = nil, want a requirement set", tc.clusterType) + continue } got := names(checker) if len(got) != len(tc.want) { diff --git a/internal/cluster/prerequisites/installer_test.go b/internal/cluster/prerequisites/installer_test.go index 2297143d..4c51aea5 100644 --- a/internal/cluster/prerequisites/installer_test.go +++ b/internal/cluster/prerequisites/installer_test.go @@ -9,6 +9,7 @@ func TestNewInstaller(t *testing.T) { if installer == nil { t.Fatal("Expected installer to be created") + return } if installer.checker == nil { diff --git a/internal/cluster/service_test.go b/internal/cluster/service_test.go index abd91e57..15ec7c92 100644 --- a/internal/cluster/service_test.go +++ b/internal/cluster/service_test.go @@ -32,6 +32,7 @@ func TestNewClusterService(t *testing.T) { if service == nil { t.Fatal("NewClusterService should not return nil") + return } if service.executor != exec { diff --git a/internal/shared/config/system_test.go b/internal/shared/config/system_test.go index a760a4e6..22f38fcb 100644 --- a/internal/shared/config/system_test.go +++ b/internal/shared/config/system_test.go @@ -11,6 +11,7 @@ func TestNewSystemService(t *testing.T) { if service == nil { t.Fatal("NewSystemService should not return nil") + return } // Should have default log directory set From 52ecc8deed241f1fac61543651e72d427a5c8357 Mon Sep 17 00:00:00 2001 From: Oleg Tkachuk Date: Wed, 22 Jul 2026 14:06:43 +0300 Subject: [PATCH 16/38] ci(test): accept either refusal gate on non-interactive cloud delete The generic deletion confirmation refuses before the cloud-specific typed-confirm gate is reached in non-interactive sessions, so the step asserted the wrong message. Assert the actual guarantee instead: non-zero exit, an actionable refusal (either gate's message), and the workspace surviving. --- .github/workflows/test.yml | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e6dac23c..a0fe6a77 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -458,14 +458,20 @@ jobs: [ "$code" -eq 0 ] && { cat /tmp/use.out; fail "use without a context must fail"; } grep -q "no kubeconfig context" /tmp/use.out || { cat /tmp/use.out; fail "missing no-context hint"; } - echo "--- non-interactive cloud delete WITHOUT --force refuses (typed-confirm gate)" + echo "--- non-interactive cloud delete WITHOUT --force refuses" + # Two refusal gates guard this path, and either may fire first: the + # generic deletion confirmation ("confirmation required ... re-run + # with --force") refuses before the cloud-specific typed-confirm + # gate ("refusing to destroy cloud cluster") is reached. Both are + # correct refusals — assert the guarantee (non-zero exit, actionable + # message, workspace survives), not which gate fired. set +e timeout 60 "$OF_BIN" cluster delete fake-gke /tmp/del.out 2>&1 code=$? set -e [ "$code" -eq 124 ] && { cat /tmp/del.out; fail "cluster delete hung on a prompt"; } [ "$code" -eq 0 ] && { cat /tmp/del.out; fail "non-interactive cloud delete without --force must refuse"; } - grep -q "refusing to destroy cloud cluster" /tmp/del.out || { cat /tmp/del.out; fail "missing refusal message"; } + grep -qE "refusing to destroy cloud cluster|confirmation required.*--force" /tmp/del.out || { cat /tmp/del.out; fail "missing an actionable refusal message"; } echo "--- the workspace (the only pointer to billed resources) survived" [ -f "$OPENFRAME_CLUSTERS_DIR/fake-gke/cluster.json" ] || fail "refused delete must not remove the workspace" From 27a7283c6dfd004607fdc424626af484b727b243 Mon Sep 17 00:00:00 2001 From: Oleg Tkachuk Date: Wed, 22 Jul 2026 14:31:57 +0300 Subject: [PATCH 17/38] =?UTF-8?q?ci(test):=20cloud-surface=20step=20fixes?= =?UTF-8?q?=20=E2=80=94=20refusal-gate=20assertion=20+=20portable=20timeou?= =?UTF-8?q?t=20(#245)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/test.yml | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a0fe6a77..577855ba 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -358,6 +358,14 @@ jobs: echo "=== TEST: cloud cluster surface — banner, validation, dry-run, use" echo "===================================================================" fail() { echo "::error::$1"; exit 1; } + # Portable hang guard: macOS runners ship coreutils' gtimeout, not + # timeout. Fall back to no guard rather than failing with exit 127. + run_to() { + local t="$1"; shift + if command -v timeout >/dev/null 2>&1; then timeout "$t" "$@" + elif command -v gtimeout >/dev/null 2>&1; then gtimeout "$t" "$@" + else "$@"; fi + } echo "--- EKS create is gated behind the coming-soon banner (exit 0)" "$OF_BIN" cluster create eks-smoke --type eks --skip-wizard >/tmp/eks.out 2>&1 \ @@ -381,7 +389,7 @@ jobs: # -> soft skip (exit 0); terraform present -> the auth flow fails fast # (non-interactive, exit non-zero). Both must be actionable, never a hang. set +e - timeout 120 "$OF_BIN" cluster create gke-smoke --type gke \ + run_to 120 "$OF_BIN" cluster create gke-smoke --type gke \ --project no-such-project --region us-central1 --skip-wizard --dry-run /tmp/gke.out 2>&1 code=$? set -e @@ -392,7 +400,7 @@ jobs: echo "--- cluster use of an unknown cluster is an actionable error" set +e - timeout 60 "$OF_BIN" cluster use no-such-cluster /tmp/use.out 2>&1 + run_to 60 "$OF_BIN" cluster use no-such-cluster /tmp/use.out 2>&1 code=$? set -e [ "$code" -eq 124 ] && { cat /tmp/use.out; fail "cluster use hung on a prompt"; } From dc823c55ee75aa0698e07c9a9e80c907a8d15dec Mon Sep 17 00:00:00 2001 From: Oleg Tkachuk Date: Wed, 22 Jul 2026 15:09:04 +0300 Subject: [PATCH 18/38] feat(cluster): dry-run plan preview lists the planned resources (#246) The counts alone ("12 to add") don't tell the user WHAT would be created. PlanSummary now carries per-resource changes in terraform's diff notation (+ / ~ / - / -/+ with the resource address, in plan order) and the cloud dry-run prints them above the summary line --- cmd/cluster/create.go | 3 ++ docs/getting-started/gke-workflow.md | 5 ++++ .../cluster/providers/terraform/engine.go | 15 ++++++++++ .../providers/terraform/engine_test.go | 30 ++++++++++++------- 4 files changed, 43 insertions(+), 10 deletions(-) diff --git a/cmd/cluster/create.go b/cmd/cluster/create.go index 8066914f..8858a98b 100644 --- a/cmd/cluster/create.go +++ b/cmd/cluster/create.go @@ -110,6 +110,9 @@ func cloudPlanPreview(ctx context.Context, config models.ClusterConfig) error { pterm.Success.Println("Plan: no changes — the cluster already matches this configuration") return nil } + for _, change := range summary.Changes { + pterm.DefaultBasicText.Printf(" %-3s %s\n", change.Action, change.Address) + } pterm.Success.Printf("Plan: %d to add, %d to change, %d to destroy\n", summary.Add, summary.Change, summary.Destroy) return nil } diff --git a/docs/getting-started/gke-workflow.md b/docs/getting-started/gke-workflow.md index 77894e8f..955892bd 100644 --- a/docs/getting-started/gke-workflow.md +++ b/docs/getting-started/gke-workflow.md @@ -17,6 +17,11 @@ without registering the cluster: ```bash openframe cluster create my-gke --type gke \ --project my-project --region us-central1 --skip-wizard --dry-run +# + google_project_service.required["container.googleapis.com"] +# + module.network.module.vpc.google_compute_network.network +# + module.gke.google_container_cluster.primary +# + module.gke.google_container_node_pool.pools["default"] +# ... # Plan: 27 to add, 0 to change, 0 to destroy ``` diff --git a/internal/cluster/providers/terraform/engine.go b/internal/cluster/providers/terraform/engine.go index bfc4617b..348dee03 100644 --- a/internal/cluster/providers/terraform/engine.go +++ b/internal/cluster/providers/terraform/engine.go @@ -117,11 +117,22 @@ func (e *Engine) Destroy(ctx context.Context, dir string) error { return nil } +// PlanChange is one resource-level action of a terraform plan, in +// terraform's own diff notation: "+" create, "~" update, "-" destroy, +// "-/+" replace. +type PlanChange struct { + Action string + Address string +} + // PlanSummary is the resource-change footprint of a terraform plan. type PlanSummary struct { Add int Change int Destroy int + // Changes lists every planned resource action, in plan order — the counts + // alone don't tell the user WHAT would be created. + Changes []PlanChange } // HasChanges reports whether the plan would modify anything. @@ -153,13 +164,17 @@ func (e *Engine) Plan(ctx context.Context, dir string) (PlanSummary, error) { switch { case rc.Change.Actions.Create(): summary.Add++ + summary.Changes = append(summary.Changes, PlanChange{Action: "+", Address: rc.Address}) case rc.Change.Actions.Update(): summary.Change++ + summary.Changes = append(summary.Changes, PlanChange{Action: "~", Address: rc.Address}) case rc.Change.Actions.Delete(): summary.Destroy++ + summary.Changes = append(summary.Changes, PlanChange{Action: "-", Address: rc.Address}) case rc.Change.Actions.Replace(): summary.Add++ summary.Destroy++ + summary.Changes = append(summary.Changes, PlanChange{Action: "-/+", Address: rc.Address}) } } return summary, nil diff --git a/internal/cluster/providers/terraform/engine_test.go b/internal/cluster/providers/terraform/engine_test.go index 6818a2f6..167bfbc1 100644 --- a/internal/cluster/providers/terraform/engine_test.go +++ b/internal/cluster/providers/terraform/engine_test.go @@ -94,26 +94,36 @@ func TestEngine_LifecycleCalls(t *testing.T) { 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}} +// action builds a tfjson resource change with the given address and actions. +func action(address string, actions ...tfjson.Action) *tfjson.ResourceChange { + return &tfjson.ResourceChange{Address: address, Change: &tfjson.Change{Actions: actions}} } -func TestEngine_PlanSummaryCountsActions(t *testing.T) { +func TestEngine_PlanSummaryCountsAndListsActions(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 + action("module.network.google_compute_network.vpc", tfjson.ActionCreate), + action("module.gke.google_container_cluster.primary", tfjson.ActionCreate), + action("module.gke.google_container_node_pool.pools", tfjson.ActionUpdate), + action("google_project_service.required", tfjson.ActionDelete), + action("module.gke.random_string.suffix", 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.Equal(t, 3, summary.Add) + assert.Equal(t, 1, summary.Change) + assert.Equal(t, 2, summary.Destroy) assert.True(t, summary.HasChanges()) + // The per-resource listing preserves plan order and diff notation. + assert.Equal(t, []PlanChange{ + {Action: "+", Address: "module.network.google_compute_network.vpc"}, + {Action: "+", Address: "module.gke.google_container_cluster.primary"}, + {Action: "~", Address: "module.gke.google_container_node_pool.pools"}, + {Action: "-", Address: "google_project_service.required"}, + {Action: "-/+", Address: "module.gke.random_string.suffix"}, + }, summary.Changes) assert.Contains(t, f.calls, "show") } From c270dff9e9e4c802fd8f53a2183c1675c7df8e2c Mon Sep 17 00:00:00 2001 From: Oleg Tkachuk Date: Wed, 22 Jul 2026 15:11:21 +0300 Subject: [PATCH 19/38] feat(cluster): show the per-resource plan during a real create too The apply stream dropped planned_change events, so a real create printed only the plan's change_summary count while dry-run listed the resources. The filter now forwards planned_change ("...: Plan to create") lines, making plan detail visible in both flows. --- internal/cluster/providers/terraform/engine_test.go | 1 + internal/cluster/providers/terraform/progress.go | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/internal/cluster/providers/terraform/engine_test.go b/internal/cluster/providers/terraform/engine_test.go index 167bfbc1..8cf3e4ac 100644 --- a/internal/cluster/providers/terraform/engine_test.go +++ b/internal/cluster/providers/terraform/engine_test.go @@ -135,6 +135,7 @@ func TestProgressLine(t *testing.T) { want string ok bool }{ + {"planned change shown", `{"@message":"module.gke.google_container_cluster.primary: Plan to create","type":"planned_change"}`, false, "module.gke.google_container_cluster.primary: Plan to create", true}, {"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}, diff --git a/internal/cluster/providers/terraform/progress.go b/internal/cluster/providers/terraform/progress.go index 7d10d1b1..22dc19f8 100644 --- a/internal/cluster/providers/terraform/progress.go +++ b/internal/cluster/providers/terraform/progress.go @@ -31,7 +31,9 @@ func progressLine(line []byte, verbose bool) (string, bool) { return ev.Message, ev.Message != "" } switch ev.Type { - case "apply_start", "apply_complete", "apply_errored", "change_summary": + // planned_change lines ("...: Plan to create") show WHAT the apply is + // about to do — the plan detail, not just its change_summary count. + case "planned_change", "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 From d00e0b2518e92da4e4921f280a65143093d6ebf8 Mon Sep 17 00:00:00 2001 From: Oleg Tkachuk Date: Wed, 22 Jul 2026 15:28:14 +0300 Subject: [PATCH 20/38] feat(cluster): optional infracost estimate; no hardcoded prices anywhere (#247) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - dry-run preview: when infracost is installed and working, print the monthly estimate computed from the plan JSON (PlanSummary now carries terraform's machine-readable plan); any infracost failure falls back silently — figures are never invented - without infracost: an abstract cost warning plus the provider's pricing page and a tip to install infracost - CostHint and the docs drop the stale $73/$33 hardcoded figures --- cmd/cluster/create.go | 18 +++++ docs/getting-started/cloud-clusters.md | 9 ++- docs/getting-started/gke-workflow.md | 9 ++- internal/cluster/providers/terraform/cost.go | 70 +++++++++++++++++++ .../cluster/providers/terraform/cost_test.go | 63 +++++++++++++++++ .../cluster/providers/terraform/engine.go | 7 ++ .../providers/terraform/engine_test.go | 13 ++++ internal/cluster/ui/prompts.go | 10 +-- 8 files changed, 189 insertions(+), 10 deletions(-) create mode 100644 internal/cluster/providers/terraform/cost.go create mode 100644 internal/cluster/providers/terraform/cost_test.go diff --git a/cmd/cluster/create.go b/cmd/cluster/create.go index 8858a98b..e8fd3d3b 100644 --- a/cmd/cluster/create.go +++ b/cmd/cluster/create.go @@ -114,9 +114,27 @@ func cloudPlanPreview(ctx context.Context, config models.ClusterConfig) error { pterm.DefaultBasicText.Printf(" %-3s %s\n", change.Action, change.Address) } pterm.Success.Printf("Plan: %d to add, %d to change, %d to destroy\n", summary.Add, summary.Change, summary.Destroy) + showCostEstimate(ctx, exec, config, summary) return nil } +// showCostEstimate prints a monthly estimate when infracost is installed and +// working; otherwise it prints NO figures — only the abstract cost warning +// with the provider's pricing page. Best-effort either way: cost information +// never fails the preview. +func showCostEstimate(ctx context.Context, exec executor.CommandExecutor, config models.ClusterConfig, summary terraform.PlanSummary) { + if terraform.InfracostAvailable() { + if cost, err := terraform.EstimateMonthlyCost(ctx, exec, summary.PlanJSON); err == nil { + pterm.Info.Printf("Estimated monthly cost (infracost): %s\n", cost) + return + } else if utils.GetGlobalFlags().Global.Verbose { + pterm.Debug.Printf("infracost estimate unavailable: %v\n", err) + } + } + pterm.Info.Println(ui.CostHint(config.Type)) + pterm.Info.Println("Tip: install infracost (https://www.infracost.io/docs/) to see a monthly cost estimate here") +} + // showEKSComingSoonBanner is the temporary stub for AWS EKS creation. func showEKSComingSoonBanner() { pterm.DefaultBox. diff --git a/docs/getting-started/cloud-clusters.md b/docs/getting-started/cloud-clusters.md index 86604484..88a1bd13 100644 --- a/docs/getting-started/cloud-clusters.md +++ b/docs/getting-started/cloud-clusters.md @@ -11,9 +11,12 @@ 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. +> plane, VM nodes, and NAT/networking. The CLI shows a warning with the +> provider's pricing page before creating — and, when +> [infracost](https://www.infracost.io) is installed, a monthly estimate in +> the `--dry-run` preview — and requires you to re-type the cluster name +> before deleting. Pricing: [GKE](https://cloud.google.com/kubernetes-engine/pricing) +> · [EKS](https://aws.amazon.com/eks/pricing/). ## Prerequisites diff --git a/docs/getting-started/gke-workflow.md b/docs/getting-started/gke-workflow.md index 955892bd..33d5f24a 100644 --- a/docs/getting-started/gke-workflow.md +++ b/docs/getting-started/gke-workflow.md @@ -5,9 +5,12 @@ the OpenFrame CLI. You need: the `openframe` binary, a Google account with access to a GCP project, and a browser for the login. Everything else — Terraform, gcloud, the auth plugin, credentials — the CLI sets up itself. -> **Costs.** A GKE cluster bills real money (~$73/month cluster management -> fee + VM nodes + networking). The CLI warns before creating and requires -> re-typing the cluster name before deleting. +> **Costs.** A GKE cluster bills real money: a cluster management fee, VM +> nodes, and networking — see the +> [GKE pricing page](https://cloud.google.com/kubernetes-engine/pricing). +> With [infracost](https://www.infracost.io) installed, `--dry-run` shows a +> monthly estimate. The CLI warns before creating and requires re-typing the +> cluster name before deleting. ## 0. (Optional) Preview what would be created diff --git a/internal/cluster/providers/terraform/cost.go b/internal/cluster/providers/terraform/cost.go new file mode 100644 index 00000000..7e294501 --- /dev/null +++ b/internal/cluster/providers/terraform/cost.go @@ -0,0 +1,70 @@ +package terraform + +import ( + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + sharedexec "github.com/flamingo-stack/openframe-cli/internal/shared/executor" +) + +// Cost estimation is strictly optional: when the infracost CLI +// (https://www.infracost.io) is installed and configured, the dry-run plan +// preview shows a monthly estimate; otherwise the CLI prints no figures at +// all — only a pointer to the provider's pricing page. No pricing is ever +// hardcoded. + +// InfracostAvailable reports whether the infracost binary is on PATH. +func InfracostAvailable() bool { + _, err := exec.LookPath("infracost") + return err == nil +} + +// infracostBreakdown is the subset of `infracost breakdown --format json` +// this integration reads. +type infracostBreakdown struct { + TotalMonthlyCost string `json:"totalMonthlyCost"` + Currency string `json:"currency"` +} + +// EstimateMonthlyCost runs infracost against a terraform plan JSON and +// returns a human-readable monthly estimate (e.g. "142.53 USD"). Any failure +// (missing API key, network, unparseable output) is returned as an error — +// callers fall back to the abstract pricing hint, never to made-up numbers. +func EstimateMonthlyCost(ctx context.Context, execer sharedexec.CommandExecutor, planJSON []byte) (string, error) { + if len(planJSON) == 0 { + return "", fmt.Errorf("no plan JSON to estimate from") + } + tmp, err := os.MkdirTemp("", "openframe-infracost-*") + if err != nil { + return "", err + } + defer func() { _ = os.RemoveAll(tmp) }() + planPath := filepath.Join(tmp, "plan.json") + if err := os.WriteFile(planPath, planJSON, 0o600); err != nil { + return "", err + } + + result, err := execer.Execute(ctx, "infracost", "breakdown", + "--path", planPath, "--format", "json", "--no-color") + if err != nil { + return "", fmt.Errorf("infracost breakdown failed: %w", err) + } + var breakdown infracostBreakdown + if err := json.Unmarshal([]byte(result.Stdout), &breakdown); err != nil { + return "", fmt.Errorf("unparseable infracost output: %w", err) + } + cost := strings.TrimSpace(breakdown.TotalMonthlyCost) + if cost == "" { + return "", fmt.Errorf("infracost reported no total monthly cost") + } + currency := breakdown.Currency + if currency == "" { + currency = "USD" + } + return cost + " " + currency, nil +} diff --git a/internal/cluster/providers/terraform/cost_test.go b/internal/cluster/providers/terraform/cost_test.go new file mode 100644 index 00000000..80795b44 --- /dev/null +++ b/internal/cluster/providers/terraform/cost_test.go @@ -0,0 +1,63 @@ +package terraform + +import ( + "context" + "testing" + + "github.com/flamingo-stack/openframe-cli/internal/shared/executor" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestEstimateMonthlyCost(t *testing.T) { + planJSON := []byte(`{"format_version":"1.2"}`) + + t.Run("parses the infracost total", func(t *testing.T) { + mock := executor.NewMockCommandExecutor() + mock.SetResponse("infracost breakdown", &executor.CommandResult{ + ExitCode: 0, + Stdout: `{"totalMonthlyCost":"142.53","currency":"USD","projects":[]}`, + }) + + cost, err := EstimateMonthlyCost(context.Background(), mock, planJSON) + require.NoError(t, err) + assert.Equal(t, "142.53 USD", cost) + assert.True(t, mock.WasCommandExecuted("infracost breakdown"), "must invoke infracost breakdown") + }) + + t.Run("missing currency defaults to USD", func(t *testing.T) { + mock := executor.NewMockCommandExecutor() + mock.SetResponse("infracost breakdown", &executor.CommandResult{ + ExitCode: 0, Stdout: `{"totalMonthlyCost":"99.00"}`, + }) + cost, err := EstimateMonthlyCost(context.Background(), mock, planJSON) + require.NoError(t, err) + assert.Equal(t, "99.00 USD", cost) + }) + + t.Run("infracost failure is an error, never a made-up number", func(t *testing.T) { + mock := executor.NewMockCommandExecutor() + mock.SetShouldFail(true, "No INFRACOST_API_KEY environment variable is set") + _, err := EstimateMonthlyCost(context.Background(), mock, planJSON) + assert.Error(t, err) + }) + + t.Run("unparseable output is an error", func(t *testing.T) { + mock := executor.NewMockCommandExecutor() + mock.SetResponse("infracost breakdown", &executor.CommandResult{ExitCode: 0, Stdout: "not json"}) + _, err := EstimateMonthlyCost(context.Background(), mock, planJSON) + assert.Error(t, err) + }) + + t.Run("empty total is an error", func(t *testing.T) { + mock := executor.NewMockCommandExecutor() + mock.SetResponse("infracost breakdown", &executor.CommandResult{ExitCode: 0, Stdout: `{"currency":"USD"}`}) + _, err := EstimateMonthlyCost(context.Background(), mock, planJSON) + assert.Error(t, err) + }) + + t.Run("empty plan JSON is an error", func(t *testing.T) { + _, err := EstimateMonthlyCost(context.Background(), executor.NewMockCommandExecutor(), nil) + assert.Error(t, err) + }) +} diff --git a/internal/cluster/providers/terraform/engine.go b/internal/cluster/providers/terraform/engine.go index 348dee03..03e992c3 100644 --- a/internal/cluster/providers/terraform/engine.go +++ b/internal/cluster/providers/terraform/engine.go @@ -133,6 +133,9 @@ type PlanSummary struct { // Changes lists every planned resource action, in plan order — the counts // alone don't tell the user WHAT would be created. Changes []PlanChange + // PlanJSON is the machine-readable plan (terraform show -json), used by + // the optional infracost estimate. Empty when the plan has no changes. + PlanJSON []byte } // HasChanges reports whether the plan would modify anything. @@ -160,6 +163,10 @@ func (e *Engine) Plan(ctx context.Context, dir string) (PlanSummary, error) { return PlanSummary{}, fmt.Errorf("terraform show failed: %w", err) } var summary PlanSummary + // Best-effort: the JSON only feeds the optional cost estimate. + if data, err := json.Marshal(plan); err == nil { + summary.PlanJSON = data + } for _, rc := range plan.ResourceChanges { switch { case rc.Change.Actions.Create(): diff --git a/internal/cluster/providers/terraform/engine_test.go b/internal/cluster/providers/terraform/engine_test.go index 8cf3e4ac..7aba2535 100644 --- a/internal/cluster/providers/terraform/engine_test.go +++ b/internal/cluster/providers/terraform/engine_test.go @@ -187,3 +187,16 @@ 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") } + +func TestEngine_PlanCarriesPlanJSON(t *testing.T) { + f := &fakeRunner{ + planChanges: true, + plan: &tfjson.Plan{ResourceChanges: []*tfjson.ResourceChange{ + action("module.gke.google_container_cluster.primary", tfjson.ActionCreate), + }}, + } + summary, err := engineWith(f).Plan(context.Background(), t.TempDir()) + require.NoError(t, err) + // The machine-readable plan feeds the optional infracost estimate. + assert.Contains(t, string(summary.PlanJSON), "google_container_cluster.primary") +} diff --git a/internal/cluster/ui/prompts.go b/internal/cluster/ui/prompts.go index bf2d8f1e..9e9e8a04 100644 --- a/internal/cluster/ui/prompts.go +++ b/internal/cluster/ui/prompts.go @@ -54,14 +54,16 @@ func selectFromList(prompt string, items []string) (int, string, error) { return sharedUI.SelectFromList(prompt, items) } -// CostHint is the running-cost warning shown for cloud cluster types. The -// figures are deliberately rough baselines, not quotes. +// CostHint is the running-cost warning shown for cloud cluster types. It +// deliberately carries NO figures — real numbers come only from the optional +// infracost estimate in the dry-run preview; otherwise the user gets the +// provider's pricing page, never a stale hardcoded price. 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)" + return "This creates billed AWS resources (managed control plane, EC2 nodes, networking) — pricing: https://aws.amazon.com/eks/pricing/" case models.ClusterTypeGKE: - return "This creates billed GCP resources: GKE cluster management fee (~$73/mo), VM nodes, and networking" + return "This creates billed GCP resources (managed control plane, VM nodes, networking) — pricing: https://cloud.google.com/kubernetes-engine/pricing" default: return "Cloud clusters create resources that incur costs" } From 184fa5ac5e99af5dac3633fe47e124c6b86b7b48 Mon Sep 17 00:00:00 2001 From: Oleg Tkachuk Date: Wed, 22 Jul 2026 15:32:09 +0300 Subject: [PATCH 21/38] build: emit binaries into build/ instead of the repo root make build/build-all now write to build/ (already gitignored); the CI OF_BIN path, the WSL staging step, and the dev docs follow. --- .github/workflows/test.yml | 6 ++--- Makefile | 30 ++++++++++++--------- docs/development/setup/local-development.md | 9 ++++--- 3 files changed, 26 insertions(+), 19 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 577855ba..8bf88b4c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -106,7 +106,7 @@ jobs: env: # Path to the built binary for this matrix leg. On Windows it forwards the # whole CLI into WSL; on Linux it runs natively. Used by every test step. - OF_BIN: ./openframe-${{ matrix.os }}-${{ matrix.arch }}${{ matrix.os == 'windows' && '.exe' || '' }} + OF_BIN: ./build/openframe-${{ matrix.os }}-${{ matrix.arch }}${{ matrix.os == 'windows' && '.exe' || '' }} # Cluster used by the lifecycle steps; its k3d kube-context is k3d-. OF_CLUSTER: openframe-test OF_CONTEXT: k3d-openframe-test @@ -165,8 +165,8 @@ jobs: # published release to auto-download, so build the Linux binary and # stream it into the default WSL distro's ~/.openframe/bin via stdin — # no Windows→WSL path translation, which is where the launcher looks. - GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o openframe-linux-amd64 . - wsl -- bash -c 'mkdir -p "$HOME/.openframe/bin" && cat > "$HOME/.openframe/bin/openframe" && chmod +x "$HOME/.openframe/bin/openframe"' < openframe-linux-amd64 + GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o build/openframe-linux-amd64 . + wsl -- bash -c 'mkdir -p "$HOME/.openframe/bin" && cat > "$HOME/.openframe/bin/openframe" && chmod +x "$HOME/.openframe/bin/openframe"' < build/openframe-linux-amd64 fi # --- Simplest: no cluster, read-only (all platforms, incl. darwin) ----- diff --git a/Makefile b/Makefile index 1c9de5b5..4a5d2458 100644 --- a/Makefile +++ b/Makefile @@ -4,6 +4,7 @@ # Variables BINARY_NAME := openframe +BUILD_DIR := build # -trimpath drops absolute build paths from the binary (reproducibility), matching # the release build in .goreleaser.yml. GO_BUILD := CGO_ENABLED=0 go build -trimpath @@ -22,18 +23,20 @@ UNIT_PKGS := . ./cmd/... ./internal/... ./tests/testutil/... ./tests/scripts/... # Default target all: build -build: ## Build binary for the current platform - @echo "Building $(BINARY_NAME) for $(GOOS)/$(GOARCH)..." - @$(GO_BUILD) -o $(BINARY_NAME)-$(GOOS)-$(GOARCH)$(BINARY_SUFFIX) . - -build-all: ## Cross-compile every release platform (matches .goreleaser.yml) - @echo "Building $(BINARY_NAME) for all release platforms..." - @GOOS=linux GOARCH=amd64 $(GO_BUILD) -o $(BINARY_NAME)-linux-amd64 . - @GOOS=linux GOARCH=arm64 $(GO_BUILD) -o $(BINARY_NAME)-linux-arm64 . - @GOOS=darwin GOARCH=amd64 $(GO_BUILD) -o $(BINARY_NAME)-darwin-amd64 . - @GOOS=darwin GOARCH=arm64 $(GO_BUILD) -o $(BINARY_NAME)-darwin-arm64 . - @GOOS=windows GOARCH=amd64 $(GO_BUILD) -o $(BINARY_NAME)-windows-amd64.exe . - @GOOS=windows GOARCH=arm64 $(GO_BUILD) -o $(BINARY_NAME)-windows-arm64.exe . +build: ## Build binary into $(BUILD_DIR)/ for the current platform + @echo "Building $(BINARY_NAME) for $(GOOS)/$(GOARCH) into $(BUILD_DIR)/..." + @mkdir -p $(BUILD_DIR) + @$(GO_BUILD) -o $(BUILD_DIR)/$(BINARY_NAME)-$(GOOS)-$(GOARCH)$(BINARY_SUFFIX) . + +build-all: ## Cross-compile every release platform into $(BUILD_DIR)/ (matches .goreleaser.yml) + @echo "Building $(BINARY_NAME) for all release platforms into $(BUILD_DIR)/..." + @mkdir -p $(BUILD_DIR) + @GOOS=linux GOARCH=amd64 $(GO_BUILD) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 . + @GOOS=linux GOARCH=arm64 $(GO_BUILD) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 . + @GOOS=darwin GOARCH=amd64 $(GO_BUILD) -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-amd64 . + @GOOS=darwin GOARCH=arm64 $(GO_BUILD) -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 . + @GOOS=windows GOARCH=amd64 $(GO_BUILD) -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe . + @GOOS=windows GOARCH=arm64 $(GO_BUILD) -o $(BUILD_DIR)/$(BINARY_NAME)-windows-arm64.exe . test-unit: ## Run unit tests (vet on; incl. root main_test.go + tests/testutil) @echo "Running unit tests..." @@ -81,7 +84,8 @@ tidy: ## Check go.mod/go.sum are tidy (fails if `go mod tidy` would change them) # and deleted them. clean: ## Remove build artifacts @rm -f $(BINARY_NAME) \ - $(BINARY_NAME)-linux-* $(BINARY_NAME)-darwin-* $(BINARY_NAME)-windows-* + $(BINARY_NAME)-linux-* $(BINARY_NAME)-darwin-* $(BINARY_NAME)-windows-* \ + $(BUILD_DIR)/$(BINARY_NAME)-* @echo "Cleaned build artifacts" help: ## Show this help diff --git a/docs/development/setup/local-development.md b/docs/development/setup/local-development.md index d6fd48ad..4fb3b714 100644 --- a/docs/development/setup/local-development.md +++ b/docs/development/setup/local-development.md @@ -182,10 +182,13 @@ the ArgoCD install. Without an `argocd:` section the baseline is used unchanged. ## Cross-platform Builds +`make build` puts the current-platform binary into `build/`; `make build-all` +cross-compiles every release platform there. By hand: + ```bash -GOOS=linux GOARCH=amd64 go build -o openframe-linux-amd64 . -GOOS=darwin GOARCH=arm64 go build -o openframe-darwin-arm64 . -GOOS=windows GOARCH=amd64 go build -o openframe-windows-amd64.exe . +GOOS=linux GOARCH=amd64 go build -o build/openframe-linux-amd64 . +GOOS=darwin GOARCH=arm64 go build -o build/openframe-darwin-arm64 . +GOOS=windows GOARCH=amd64 go build -o build/openframe-windows-amd64.exe . ``` On Windows the CLI forwards into WSL2 and runs the Linux binary; that launch is handled by `internal/shared/wsllauncher`. From 09500c30c00b5430af3f5d2e1bb4e6f7861a9787 Mon Sep 17 00:00:00 2001 From: Oleg Tkachuk Date: Wed, 22 Jul 2026 16:34:34 +0300 Subject: [PATCH 22/38] feat(cluster): offer a verified infracost install in the dry-run preview (#248) - pinned infracost v0.10.45 (all supported platforms, SHA256 from the release's per-asset checksums) installed into ~/.openframe/bin; the archive's irregular member layout is handled by a dedicated installer - interactive dry-run without infracost now offers the install; the user's only manual step is a one-time free 'infracost auth login' (surfaced when an estimate fails without a key) - non-interactive sessions never prompt or download; hermetic seam-based tests cover offer/accept/decline/failure paths --- cmd/cluster/create.go | 56 ++++++++++-- cmd/cluster/create_behavior_test.go | 73 +++++++++++++++ docs/getting-started/cloud-clusters.md | 7 +- docs/getting-started/gke-workflow.md | 6 +- .../prerequisites/infracost/infracost.go | 89 +++++++++++++++++++ internal/platform/platform.go | 6 ++ internal/shared/download/pins.go | 30 +++++++ internal/shared/download/pins_test.go | 30 ++++++- 8 files changed, 282 insertions(+), 15 deletions(-) create mode 100644 internal/cluster/prerequisites/infracost/infracost.go diff --git a/cmd/cluster/create.go b/cmd/cluster/create.go index e8fd3d3b..fd4b19bd 100644 --- a/cmd/cluster/create.go +++ b/cmd/cluster/create.go @@ -8,11 +8,13 @@ import ( "github.com/flamingo-stack/openframe-cli/internal/cluster/discovery" "github.com/flamingo-stack/openframe-cli/internal/cluster/models" "github.com/flamingo-stack/openframe-cli/internal/cluster/prerequisites" + infracostinstall "github.com/flamingo-stack/openframe-cli/internal/cluster/prerequisites/infracost" "github.com/flamingo-stack/openframe-cli/internal/cluster/provider" "github.com/flamingo-stack/openframe-cli/internal/cluster/providers/terraform" "github.com/flamingo-stack/openframe-cli/internal/cluster/ui" "github.com/flamingo-stack/openframe-cli/internal/cluster/utils" "github.com/flamingo-stack/openframe-cli/internal/shared/executor" + sharedUI "github.com/flamingo-stack/openframe-cli/internal/shared/ui" "github.com/pterm/pterm" "github.com/spf13/cobra" ) @@ -118,18 +120,56 @@ func cloudPlanPreview(ctx context.Context, config models.ClusterConfig) error { return nil } -// showCostEstimate prints a monthly estimate when infracost is installed and -// working; otherwise it prints NO figures — only the abstract cost warning -// with the provider's pricing page. Best-effort either way: cost information -// never fails the preview. +// Seams for hermetic tests: availability probes the real PATH and the offer +// runs a real verified download — neither may happen in unit tests. +var ( + infracostAvailableFn = terraform.InfracostAvailable + infracostOfferFn = offerInfracostInstall +) + +// offerInfracostInstall proposes the verified pinned infracost install in an +// interactive session. It returns whether infracost is available afterwards. +// The API key stays the user's one manual step: `infracost auth login`. +func offerInfracostInstall() bool { + if sharedUI.IsNonInteractive() { + return false + } + confirmed, err := sharedUI.ConfirmActionInteractive( + "Install infracost (verified download) to see a monthly cost estimate?", true) + if err != nil || !confirmed { + return false + } + if err := infracostinstall.NewInstaller().Install(); err != nil { + pterm.Warning.Printf("infracost install failed: %v\n", err) + return false + } + return infracostAvailableFn() +} + +// showCostEstimate prints a monthly estimate when infracost is available and +// working — offering to install it first in interactive sessions. Otherwise +// it prints NO figures, only the abstract cost warning with the provider's +// pricing page. Best-effort either way: cost information never fails the +// preview. func showCostEstimate(ctx context.Context, exec executor.CommandExecutor, config models.ClusterConfig, summary terraform.PlanSummary) { - if terraform.InfracostAvailable() { - if cost, err := terraform.EstimateMonthlyCost(ctx, exec, summary.PlanJSON); err == nil { + available := infracostAvailableFn() + if !available { + available = infracostOfferFn() + } + if available { + cost, err := terraform.EstimateMonthlyCost(ctx, exec, summary.PlanJSON) + if err == nil { pterm.Info.Printf("Estimated monthly cost (infracost): %s\n", cost) return - } else if utils.GetGlobalFlags().Global.Verbose { - pterm.Debug.Printf("infracost estimate unavailable: %v\n", err) } + // infracost is installed but could not estimate — the usual cause is + // the missing (free) API key, the user's single manual step. + pterm.Info.Println("Cost estimate unavailable — if you haven't set up infracost yet, run 'infracost auth login' (free) and re-run") + if utils.GetGlobalFlags().Global.Verbose { + pterm.Debug.Printf("infracost estimate error: %v\n", err) + } + pterm.Info.Println(ui.CostHint(config.Type)) + return } pterm.Info.Println(ui.CostHint(config.Type)) pterm.Info.Println("Tip: install infracost (https://www.infracost.io/docs/) to see a monthly cost estimate here") diff --git a/cmd/cluster/create_behavior_test.go b/cmd/cluster/create_behavior_test.go index 15f88481..e6866bc5 100644 --- a/cmd/cluster/create_behavior_test.go +++ b/cmd/cluster/create_behavior_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/flamingo-stack/openframe-cli/internal/cluster/models" + "github.com/flamingo-stack/openframe-cli/internal/cluster/providers/terraform" "github.com/flamingo-stack/openframe-cli/internal/cluster/utils" "github.com/flamingo-stack/openframe-cli/internal/shared/executor" "github.com/flamingo-stack/openframe-cli/tests/testutil" @@ -184,3 +185,75 @@ func TestRunCreateCluster_EKSShowsComingSoonBanner(t *testing.T) { t.Fatal("eks must not reach the plan preview while gated") } } + +// TestShowCostEstimate drives the infracost offer/estimate flow on seams — +// no real PATH probe, download, or infracost invocation ever happens. +func TestShowCostEstimate(t *testing.T) { + summary := terraform.PlanSummary{Add: 1, PlanJSON: []byte(`{"format_version":"1.2"}`)} + config := models.ClusterConfig{Name: "x", Type: models.ClusterTypeGKE, NodeCount: 1, + Cloud: &models.CloudConfig{Region: "us-central1", Project: "p"}} + + override := func(t *testing.T, available bool, offer bool) (offered *bool) { + t.Helper() + offered = new(bool) + origAvail, origOffer := infracostAvailableFn, infracostOfferFn + infracostAvailableFn = func() bool { return available } + infracostOfferFn = func() bool { *offered = true; return offer } + t.Cleanup(func() { infracostAvailableFn, infracostOfferFn = origAvail, origOffer }) + return offered + } + + t.Run("unavailable and declined: no infracost invocation", func(t *testing.T) { + utils.InitGlobalFlags() + t.Cleanup(utils.ResetGlobalFlags) + offered := override(t, false, false) + mock := executor.NewMockCommandExecutor() + + showCostEstimate(context.Background(), mock, config, summary) + + if !*offered { + t.Fatal("the install offer must be made when infracost is unavailable") + } + if mock.WasCommandExecuted("infracost") { + t.Fatal("infracost must not run when unavailable and declined") + } + }) + + t.Run("offer accepted: estimate runs", func(t *testing.T) { + utils.InitGlobalFlags() + t.Cleanup(utils.ResetGlobalFlags) + override(t, false, true) + mock := executor.NewMockCommandExecutor() + mock.SetResponse("infracost breakdown", &executor.CommandResult{ + ExitCode: 0, Stdout: `{"totalMonthlyCost":"120.00","currency":"USD"}`}) + + showCostEstimate(context.Background(), mock, config, summary) + + if !mock.WasCommandExecuted("infracost breakdown") { + t.Fatal("estimate must run after an accepted install offer") + } + }) + + t.Run("available but estimate fails: no offer, graceful hint", func(t *testing.T) { + utils.InitGlobalFlags() + t.Cleanup(utils.ResetGlobalFlags) + offered := override(t, true, false) + mock := executor.NewMockCommandExecutor() + mock.SetShouldFail(true, "No INFRACOST_API_KEY environment variable is set") + + showCostEstimate(context.Background(), mock, config, summary) + + if *offered { + t.Fatal("no install offer when infracost is already available") + } + }) +} + +// TestOfferInfracostInstall_NonInteractiveNeverPrompts: CI sessions must not +// hit the confirm prompt (nor a download). +func TestOfferInfracostInstall_NonInteractiveNeverPrompts(t *testing.T) { + t.Setenv("CI", "true") + if offerInfracostInstall() { + t.Fatal("non-interactive sessions must not offer/install infracost") + } +} diff --git a/docs/getting-started/cloud-clusters.md b/docs/getting-started/cloud-clusters.md index 88a1bd13..818f9cdc 100644 --- a/docs/getting-started/cloud-clusters.md +++ b/docs/getting-started/cloud-clusters.md @@ -12,9 +12,10 @@ infrastructure code for you — no Terraform knowledge required. > **Cost warning.** Cloud clusters create billed resources: a managed control > plane, VM nodes, and NAT/networking. The CLI shows a warning with the -> provider's pricing page before creating — and, when -> [infracost](https://www.infracost.io) is installed, a monthly estimate in -> the `--dry-run` preview — and requires you to re-type the cluster name +> provider's pricing page before creating — and, in an interactive +> `--dry-run` it offers to install [infracost](https://www.infracost.io) +> (verified pinned download; one-time free `infracost auth login`) and shows +> a monthly estimate — and requires you to re-type the cluster name > before deleting. Pricing: [GKE](https://cloud.google.com/kubernetes-engine/pricing) > · [EKS](https://aws.amazon.com/eks/pricing/). diff --git a/docs/getting-started/gke-workflow.md b/docs/getting-started/gke-workflow.md index 33d5f24a..89976d1f 100644 --- a/docs/getting-started/gke-workflow.md +++ b/docs/getting-started/gke-workflow.md @@ -8,8 +8,10 @@ Terraform, gcloud, the auth plugin, credentials — the CLI sets up itself. > **Costs.** A GKE cluster bills real money: a cluster management fee, VM > nodes, and networking — see the > [GKE pricing page](https://cloud.google.com/kubernetes-engine/pricing). -> With [infracost](https://www.infracost.io) installed, `--dry-run` shows a -> monthly estimate. The CLI warns before creating and requires re-typing the +> In an interactive `--dry-run`, the CLI offers to install +> [infracost](https://www.infracost.io) (verified pinned download) and then +> shows a monthly estimate — your only manual step is a one-time free +> `infracost auth login`. The CLI warns before creating and requires re-typing the > cluster name before deleting. ## 0. (Optional) Preview what would be created diff --git a/internal/cluster/prerequisites/infracost/infracost.go b/internal/cluster/prerequisites/infracost/infracost.go new file mode 100644 index 00000000..fbc7bb7e --- /dev/null +++ b/internal/cluster/prerequisites/infracost/infracost.go @@ -0,0 +1,89 @@ +// Package infracost installs the OPTIONAL infracost CLI, which powers the +// monthly cost estimate in the cloud dry-run preview. It is never a required +// prerequisite: the CLI only OFFERS to install it, and estimates additionally +// need the user to run `infracost auth login` once (free API key for +// infracost's own pricing API — no cloud credentials are involved). +package infracost + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "time" + + "github.com/flamingo-stack/openframe-cli/internal/platform" + "github.com/flamingo-stack/openframe-cli/internal/shared/download" + "github.com/pterm/pterm" +) + +type Installer struct{} + +func NewInstaller() *Installer { return &Installer{} } + +func commandExists(cmd string) bool { + _, err := exec.LookPath(cmd) + return err == nil +} + +// IsInstalled reports whether a working infracost binary is reachable, +// preferring the CLI-managed bin dir. +func (i *Installer) IsInstalled() bool { + if binDir, err := download.UserBinDir(); err == nil { + download.PrependToPath(binDir) + } + if !commandExists("infracost") { + return false + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + return exec.CommandContext(ctx, "infracost", "--version").Run() == nil +} + +func (i *Installer) GetInstallHelp() string { + return platform.InstallHint("infracost") +} + +// Install downloads the pinned infracost release, verifies its SHA256, and +// installs it into ~/.openframe/bin. The archive member layout is irregular +// (see the pin comment), hence the direct InstallVerifiedTarGz call. +func (i *Installer) Install() error { + switch runtime.GOOS { + case "darwin", "linux", "windows": + default: + return fmt.Errorf("automatic infracost installation not supported on %s", runtime.GOOS) + } + + asset, ok := download.Infracost.Asset(runtime.GOOS, runtime.GOARCH) + if !ok { + return fmt.Errorf("no verified infracost %s asset for %s/%s", download.Infracost.Version, runtime.GOOS, runtime.GOARCH) + } + binDir, err := download.UserBinDir() + if err != nil { + return err + } + if err := os.MkdirAll(binDir, 0o750); err != nil { + return fmt.Errorf("creating %s: %w", binDir, err) + } + + member := fmt.Sprintf("infracost-%s-%s", runtime.GOOS, runtime.GOARCH) + dest := filepath.Join(binDir, "infracost") + if runtime.GOOS == "windows" { + member = "infracost.exe" + dest += ".exe" + } + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + fmt.Printf("Downloading verified infracost %s...\n", download.Infracost.Version) + if err := (download.Downloader{}).InstallVerifiedTarGz(ctx, asset, member, dest, 0o750); err != nil { + return fmt.Errorf("verified infracost install failed: %w", err) + } + download.PrependToPath(binDir) + pterm.Success.Printf("Installed verified infracost %s to %s\n", download.Infracost.Version, dest) + pterm.Info.Println("To enable estimates, get a free API key once: infracost auth login") + return nil +} diff --git a/internal/platform/platform.go b/internal/platform/platform.go index b1850f1d..da5d1585 100644 --- a/internal/platform/platform.go +++ b/internal/platform/platform.go @@ -102,6 +102,12 @@ var toolDocs = map[string]InstallDocs{ 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", }, + "infracost": { + Darwin: "infracost: The CLI can install a verified pinned binary automatically, or run 'brew install infracost'; enable estimates with 'infracost auth login'", + Linux: "infracost: The CLI can install a verified pinned binary automatically (see https://www.infracost.io/docs/); enable estimates with 'infracost auth login'", + Windows: "infracost: The CLI can install a verified pinned binary automatically (see https://www.infracost.io/docs/); enable estimates with 'infracost auth login'", + Default: "infracost: See https://www.infracost.io/docs/ — enable estimates with 'infracost auth login'", + }, "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", diff --git a/internal/shared/download/pins.go b/internal/shared/download/pins.go index cced3462..ad2fde35 100644 --- a/internal/shared/download/pins.go +++ b/internal/shared/download/pins.go @@ -126,6 +126,36 @@ var Terraform = PinnedTool{ }, } +// Infracost is the pinned infracost CLI, used by the OPTIONAL cost estimate +// in the cloud dry-run preview. Upstream: +// https://github.com/infracost/infracost/releases — .tar.gz per platform with +// per-asset .sha256 files. The archive member layout is irregular +// ("infracost--" on unix, "infracost.exe" on windows), so the +// infracost installer extracts it itself via InstallVerifiedTarGz instead of +// the InstallPinnedTool tarball convention. +const ( + infracostVersion = "v0.10.45" + infracostBaseURL = "https://github.com/infracost/infracost/releases/download/" + infracostVersion + "/infracost-" + + infracostSHA256LinuxAMD64 = "e2f527d8391a87ac00bfc55237ff875107861715e234bbbeb9b6015aba576c77" + infracostSHA256LinuxARM64 = "b9946cf42b9ed58184bd646484f63e0ecf430caca5978e0611302a1545c36262" + infracostSHA256DarwinAMD64 = "b2202262267b704b95e786acfeb09c1f86e384e896ea42edb4de3f91a338a637" + infracostSHA256DarwinARM64 = "98b134ca825d292a34a410cdbfa0cfa0d3c9ec2b576710de0d051be6d9002771" + infracostSHA256WindowsAMD64 = "7f627fac5cad9b7260e1123c1378d2fb4f308cdc64f78e99d6e69727d9549d06" +) + +var Infracost = PinnedTool{ + Name: "infracost", + Version: infracostVersion, + Assets: map[string]PinnedAsset{ + "linux/amd64": {URL: infracostBaseURL + "linux-amd64.tar.gz", SHA256: infracostSHA256LinuxAMD64}, + "linux/arm64": {URL: infracostBaseURL + "linux-arm64.tar.gz", SHA256: infracostSHA256LinuxARM64}, + "darwin/amd64": {URL: infracostBaseURL + "darwin-amd64.tar.gz", SHA256: infracostSHA256DarwinAMD64}, + "darwin/arm64": {URL: infracostBaseURL + "darwin-arm64.tar.gz", SHA256: infracostSHA256DarwinARM64}, + "windows/amd64": {URL: infracostBaseURL + "windows-amd64.tar.gz", SHA256: infracostSHA256WindowsAMD64}, + }, +} + // 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) { diff --git a/internal/shared/download/pins_test.go b/internal/shared/download/pins_test.go index d69f9f14..08d2a1f7 100644 --- a/internal/shared/download/pins_test.go +++ b/internal/shared/download/pins_test.go @@ -142,10 +142,10 @@ func TestPinnedAssets_RealDownload(t *testing.T) { if testing.Short() { t.Skip("network test skipped under -short") } - tools := []PinnedTool{Terraform} + tools := []PinnedTool{Terraform, Infracost} if runtime.GOOS != "windows" { // k3d/mkcert/helm have no windows pins: on Windows they run inside WSL. - // Terraform is pinned for all platforms. + // Terraform and infracost are pinned for all supported platforms. tools = append(tools, K3d, Mkcert, Helm) } for _, tool := range tools { @@ -308,3 +308,29 @@ func TestInstallPinnedTool_RealK3dExec(t *testing.T) { t.Fatalf("k3d version output %q does not contain %q", out, K3d.Version) } } + +// TestInfracost_Pins locks the infracost pin shape: a versioned .tar.gz + +// non-empty SHA256 for every supported platform (windows/arm64 has no +// upstream asset). +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) + } + for _, p := range []struct{ os, arch string }{ + {"linux", "amd64"}, {"linux", "arm64"}, + {"darwin", "amd64"}, {"darwin", "arm64"}, + {"windows", "amd64"}, + } { + asset, ok := Infracost.Asset(p.os, p.arch) + if !ok { + t.Errorf("no infracost 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, Infracost.Version) || !strings.HasSuffix(asset.URL, p.os+"-"+p.arch+".tar.gz") { + t.Errorf("%s/%s: URL %q must contain version and end with platform.tar.gz", p.os, p.arch, asset.URL) + } + } +} From 81615443bfa2d2f4e16d262e0e468139a0d511f8 Mon Sep 17 00:00:00 2001 From: Oleg Tkachuk Date: Wed, 22 Jul 2026 16:51:59 +0300 Subject: [PATCH 23/38] feat(cluster): run the one-time infracost auth login inside the CLI (#249) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A failed estimate (typically the missing free API key) now offers to run 'infracost auth login' right there, attached to the terminal, and retries the estimate once after a successful login — no separate console needed. Non-interactive sessions never prompt; hermetic seam-based tests cover the declined, accepted-and-retried, and CI paths. --- cmd/cluster/create.go | 41 ++++++++++++++++++++++--- cmd/cluster/create_behavior_test.go | 42 +++++++++++++++++++++++++- docs/getting-started/cloud-clusters.md | 2 +- docs/getting-started/gke-workflow.md | 4 +-- 4 files changed, 80 insertions(+), 9 deletions(-) diff --git a/cmd/cluster/create.go b/cmd/cluster/create.go index fd4b19bd..63a05841 100644 --- a/cmd/cluster/create.go +++ b/cmd/cluster/create.go @@ -3,6 +3,8 @@ package cluster import ( "context" "fmt" + "os" + osexec "os/exec" "strings" "github.com/flamingo-stack/openframe-cli/internal/cluster/discovery" @@ -120,13 +122,39 @@ func cloudPlanPreview(ctx context.Context, config models.ClusterConfig) error { return nil } -// Seams for hermetic tests: availability probes the real PATH and the offer -// runs a real verified download — neither may happen in unit tests. +// Seams for hermetic tests: availability probes the real PATH, the offer runs +// a real verified download, and the login launches a real browser flow — none +// of that may happen in unit tests. var ( infracostAvailableFn = terraform.InfracostAvailable infracostOfferFn = offerInfracostInstall + infracostLoginFn = offerInfracostLogin ) +// offerInfracostLogin runs the one-time `infracost auth login` (browser flow) +// right inside the CLI, so the user never needs a separate console. Attached +// straight to the terminal — the flow prints a URL and reads stdin, which the +// capturing executor would swallow. Returns whether a login was performed. +func offerInfracostLogin() bool { + if sharedUI.IsNonInteractive() { + return false + } + confirmed, err := sharedUI.ConfirmActionInteractive( + "infracost needs a one-time free API key. Run 'infracost auth login' now (opens a browser)?", true) + if err != nil || !confirmed { + return false + } + login := osexec.Command("infracost", "auth", "login") + login.Stdin = os.Stdin + login.Stdout = os.Stdout + login.Stderr = os.Stderr + if err := login.Run(); err != nil { + pterm.Warning.Printf("infracost auth login failed: %v\n", err) + return false + } + return true +} + // offerInfracostInstall proposes the verified pinned infracost install in an // interactive session. It returns whether infracost is available afterwards. // The API key stays the user's one manual step: `infracost auth login`. @@ -158,13 +186,16 @@ func showCostEstimate(ctx context.Context, exec executor.CommandExecutor, config } if available { cost, err := terraform.EstimateMonthlyCost(ctx, exec, summary.PlanJSON) + if err != nil && infracostLoginFn() { + // The usual failure is the missing (free) API key. The login just + // ran inside the CLI — retry once. + cost, err = terraform.EstimateMonthlyCost(ctx, exec, summary.PlanJSON) + } if err == nil { pterm.Info.Printf("Estimated monthly cost (infracost): %s\n", cost) return } - // infracost is installed but could not estimate — the usual cause is - // the missing (free) API key, the user's single manual step. - pterm.Info.Println("Cost estimate unavailable — if you haven't set up infracost yet, run 'infracost auth login' (free) and re-run") + pterm.Info.Println("Cost estimate unavailable — run 'infracost auth login' (free, one-time) and re-run") if utils.GetGlobalFlags().Global.Verbose { pterm.Debug.Printf("infracost estimate error: %v\n", err) } diff --git a/cmd/cluster/create_behavior_test.go b/cmd/cluster/create_behavior_test.go index e6866bc5..c4d38fb7 100644 --- a/cmd/cluster/create_behavior_test.go +++ b/cmd/cluster/create_behavior_test.go @@ -234,10 +234,14 @@ func TestShowCostEstimate(t *testing.T) { } }) - t.Run("available but estimate fails: no offer, graceful hint", func(t *testing.T) { + t.Run("available but estimate fails: login offered, declined -> hint", func(t *testing.T) { utils.InitGlobalFlags() t.Cleanup(utils.ResetGlobalFlags) offered := override(t, true, false) + loginOffered := false + origLogin := infracostLoginFn + infracostLoginFn = func() bool { loginOffered = true; return false } + t.Cleanup(func() { infracostLoginFn = origLogin }) mock := executor.NewMockCommandExecutor() mock.SetShouldFail(true, "No INFRACOST_API_KEY environment variable is set") @@ -246,6 +250,33 @@ func TestShowCostEstimate(t *testing.T) { if *offered { t.Fatal("no install offer when infracost is already available") } + if !loginOffered { + t.Fatal("a failed estimate must offer the in-CLI 'infracost auth login'") + } + }) + + t.Run("failed estimate + accepted login: estimate retried", func(t *testing.T) { + utils.InitGlobalFlags() + t.Cleanup(utils.ResetGlobalFlags) + override(t, true, false) + mock := executor.NewMockCommandExecutor() + // First breakdown fails (no key); the accepted login flips the mock to + // success, mimicking a real auth login taking effect. + mock.SetShouldFail(true, "No INFRACOST_API_KEY environment variable is set") + origLogin := infracostLoginFn + infracostLoginFn = func() bool { + mock.SetShouldFail(false, "") + mock.SetResponse("infracost breakdown", &executor.CommandResult{ + ExitCode: 0, Stdout: `{"totalMonthlyCost":"120.00","currency":"USD"}`}) + return true + } + t.Cleanup(func() { infracostLoginFn = origLogin }) + + showCostEstimate(context.Background(), mock, config, summary) + + if mock.GetCommandCount() < 2 { + t.Fatalf("estimate must be retried after a successful login, got %d invocations", mock.GetCommandCount()) + } }) } @@ -257,3 +288,12 @@ func TestOfferInfracostInstall_NonInteractiveNeverPrompts(t *testing.T) { t.Fatal("non-interactive sessions must not offer/install infracost") } } + +// TestOfferInfracostLogin_NonInteractiveNeverPrompts: CI sessions must not +// hit the confirm prompt (nor a browser). +func TestOfferInfracostLogin_NonInteractiveNeverPrompts(t *testing.T) { + t.Setenv("CI", "true") + if offerInfracostLogin() { + t.Fatal("non-interactive sessions must not run infracost auth login") + } +} diff --git a/docs/getting-started/cloud-clusters.md b/docs/getting-started/cloud-clusters.md index 818f9cdc..c2fa0647 100644 --- a/docs/getting-started/cloud-clusters.md +++ b/docs/getting-started/cloud-clusters.md @@ -14,7 +14,7 @@ infrastructure code for you — no Terraform knowledge required. > plane, VM nodes, and NAT/networking. The CLI shows a warning with the > provider's pricing page before creating — and, in an interactive > `--dry-run` it offers to install [infracost](https://www.infracost.io) -> (verified pinned download; one-time free `infracost auth login`) and shows +> (verified pinned download; the one-time free `infracost auth login` is also offered in-CLI) and shows > a monthly estimate — and requires you to re-type the cluster name > before deleting. Pricing: [GKE](https://cloud.google.com/kubernetes-engine/pricing) > · [EKS](https://aws.amazon.com/eks/pricing/). diff --git a/docs/getting-started/gke-workflow.md b/docs/getting-started/gke-workflow.md index 89976d1f..5db7ab99 100644 --- a/docs/getting-started/gke-workflow.md +++ b/docs/getting-started/gke-workflow.md @@ -10,8 +10,8 @@ Terraform, gcloud, the auth plugin, credentials — the CLI sets up itself. > [GKE pricing page](https://cloud.google.com/kubernetes-engine/pricing). > In an interactive `--dry-run`, the CLI offers to install > [infracost](https://www.infracost.io) (verified pinned download) and then -> shows a monthly estimate — your only manual step is a one-time free -> `infracost auth login`. The CLI warns before creating and requires re-typing the +> shows a monthly estimate — even the one-time free +> `infracost auth login` is offered right inside the CLI. The CLI warns before creating and requires re-typing the > cluster name before deleting. ## 0. (Optional) Preview what would be created From d3e37f090892005096a29e2fce480299b9736bec Mon Sep 17 00:00:00 2001 From: Oleg Tkachuk Date: Wed, 22 Jul 2026 18:24:05 +0300 Subject: [PATCH 24/38] feat(cluster): terraform-apply-style plan confirmation before a real create (#250) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - create now plans, shows every resource with the summary, and asks for approval before applying — and applies the SAVED plan, so what the user approved is exactly what runs (PlanForApply/ApplyPlan in the engine) - declining a brand-new create removes the fresh workspace (nothing was applied); declining a resume keeps the workspace and its state - non-interactive sessions auto-approve, preserving scripted/CI behavior; a no-change plan asks nothing - docs: create flow updated (plan-confirm step, private nodes + NAT) --- docs/getting-started/cloud-clusters.md | 7 ++- docs/getting-started/gke-workflow.md | 17 +++-- internal/cluster/providers/eks/provider.go | 50 ++++++++++++--- .../cluster/providers/eks/provider_test.go | 3 +- internal/cluster/providers/gke/provider.go | 50 ++++++++++++--- .../cluster/providers/gke/provider_test.go | 63 ++++++++++++++++++- .../cluster/providers/gke/templates/main.tf | 31 ++++++++- .../cluster/providers/terraform/confirm.go | 31 +++++++++ .../cluster/providers/terraform/engine.go | 36 +++++++++-- 9 files changed, 259 insertions(+), 29 deletions(-) create mode 100644 internal/cluster/providers/terraform/confirm.go diff --git a/docs/getting-started/cloud-clusters.md b/docs/getting-started/cloud-clusters.md index c2fa0647..6af51787 100644 --- a/docs/getting-started/cloud-clusters.md +++ b/docs/getting-started/cloud-clusters.md @@ -61,7 +61,12 @@ 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. +In interactive sessions the CLI first shows the full Terraform plan and asks +for approval (the `terraform apply` shape; what you approve is exactly what +runs — non-interactive sessions auto-approve). Provisioning then takes ~10–20 +minutes; the CLI streams per-resource progress. GKE nodes are private (no +external IPs, egress via Cloud NAT) with a public control-plane endpoint, so +the flow works in organizations enforcing `restrict_vm_external_ips`. 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` / diff --git a/docs/getting-started/gke-workflow.md b/docs/getting-started/gke-workflow.md index 5db7ab99..92b5eb94 100644 --- a/docs/getting-started/gke-workflow.md +++ b/docs/getting-started/gke-workflow.md @@ -66,10 +66,19 @@ What happens, in order — no manual steps in between: 3. **Preflight**: project access is verified, and the CLI refuses to proceed if a cluster with this name already exists in the project but was not created by openframe (it will never touch clusters it does not own). -4. **Provision** (~10–15 min): a dedicated VPC with pod/service ranges and a - regional GKE cluster with an autoscaling node pool, streamed as - per-resource progress lines. Add `--verbose` for raw Terraform output. -5. **Kubeconfig**: a context named exactly `my-gke` is merged into your +4. **Plan & confirm** (interactive sessions): the full Terraform plan is + shown — every resource to be created and the summary line — and you are + asked to approve it before anything is applied, exactly like + `terraform apply`. What you approve is what runs (the saved plan is + applied, not a re-plan). Declining a brand-new create leaves no trace. + Non-interactive sessions auto-approve, as before. +5. **Provision** (~10–15 min): required project APIs, a dedicated VPC with + pod/service ranges, a Cloud NAT for egress, and a regional GKE cluster + with **private nodes** (no external IPs — compatible with orgs enforcing + `restrict_vm_external_ips`) behind a public control-plane endpoint, + streamed as per-resource progress lines. Add `--verbose` for raw + Terraform output. +6. **Kubeconfig**: a context named exactly `my-gke` is merged into your kubeconfig (existing contexts are never overwritten) and made current. ## 2. Verify diff --git a/internal/cluster/providers/eks/provider.go b/internal/cluster/providers/eks/provider.go index 12808773..6d9fad99 100644 --- a/internal/cluster/providers/eks/provider.go +++ b/internal/cluster/providers/eks/provider.go @@ -24,6 +24,10 @@ type Provider struct { engine *tfengine.Engine registry *tfengine.Registry executor executor.CommandExecutor + // confirmApply, when set, is asked before applying the create plan (the + // interactive `terraform apply` shape). Nil means auto-approve — the + // non-interactive/programmatic behavior and the test default. + confirmApply func(tfengine.PlanSummary) bool } // New builds the production provider. The registry defaults to @@ -34,9 +38,10 @@ func New(exec executor.CommandExecutor, verbose bool) (*Provider, error) { return nil, err } return &Provider{ - engine: tfengine.NewEngine(verbose), - registry: registry, - executor: exec, + engine: tfengine.NewEngine(verbose), + registry: registry, + executor: exec, + confirmApply: tfengine.ConfirmApplyInteractive, }, nil } @@ -139,7 +144,8 @@ func (p *Provider) CreateCluster(ctx context.Context, config models.ClusterConfi } ws := p.registry.Workspace(config.Name) - if !ws.Exists() { + freshWorkspace := !ws.Exists() + if freshWorkspace { vars, err := tfvarsFor(config) if err != nil { return nil, err @@ -163,14 +169,44 @@ func (p *Provider) CreateCluster(ctx context.Context, config models.ClusterConfi } } } - // An existing workspace means a previous create failed or was interrupted; - // keep its tfvars (the state may reference them) and simply resume. + // An existing workspace means a previous create failed or was interrupted. + // Refresh the generated module from the CURRENT template before resuming: + // the retry must pick up template bugfixes (e.g. the private-nodes fix for + // org-policy environments), not replay the broken files. The state is + // untouched — terraform reconciles it against the refreshed config. + if ws.Exists() { + vars, err := tfvarsFor(config) + if err != nil { + return nil, err + } + if err := tfengine.WriteModule(ws.TerraformDir(), mainTF, vars); err != nil { + return nil, err + } + } 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 { + + // The `terraform apply` shape: plan, show, confirm, then apply the SAVED + // plan — what the user approved is exactly what runs. + summary, planFile, err := p.engine.PlanForApply(ctx, ws.TerraformDir()) + if planFile != "" { + defer func() { _ = os.Remove(planFile) }() + } + if err != nil { + _ = ws.SetStatus(tfengine.StatusFailed) + return nil, models.NewClusterOperationError("create", config.Name, err) + } + if p.confirmApply != nil && !p.confirmApply(summary) { + if freshWorkspace { + // Nothing was applied — a declined brand-new create leaves no trace. + _ = ws.Remove() + } + return nil, fmt.Errorf("cluster creation cancelled — no changes were applied") + } + if err := p.engine.ApplyPlan(ctx, ws.TerraformDir(), planFile); 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)) diff --git a/internal/cluster/providers/eks/provider_test.go b/internal/cluster/providers/eks/provider_test.go index 420d46e9..f4d7987a 100644 --- a/internal/cluster/providers/eks/provider_test.go +++ b/internal/cluster/providers/eks/provider_test.go @@ -101,7 +101,8 @@ func TestCreateCluster_HappyPath(t *testing.T) { restConfig, err := p.CreateCluster(context.Background(), eksConfig("demo")) require.NoError(t, err) - assert.Equal(t, []string{"init", "apply", "output"}, *calls) + assert.Equal(t, []string{"init", "plan", "show", "apply", "output"}, *calls, + "create follows the terraform-apply shape: plan+show before the (auto-approved) apply") assert.Equal(t, "https://demo.eks.example", restConfig.Host) assert.Equal(t, []byte("fake-ca-pem"), restConfig.CAData) require.NotNil(t, restConfig.ExecProvider) diff --git a/internal/cluster/providers/gke/provider.go b/internal/cluster/providers/gke/provider.go index 2318ab84..dbabfe37 100644 --- a/internal/cluster/providers/gke/provider.go +++ b/internal/cluster/providers/gke/provider.go @@ -25,6 +25,10 @@ type Provider struct { engine *tfengine.Engine registry *tfengine.Registry executor executor.CommandExecutor + // confirmApply, when set, is asked before applying the create plan (the + // interactive `terraform apply` shape). Nil means auto-approve — the + // non-interactive/programmatic behavior and the test default. + confirmApply func(tfengine.PlanSummary) bool } // New builds the production provider. The registry defaults to @@ -35,9 +39,10 @@ func New(exec executor.CommandExecutor, verbose bool) (*Provider, error) { return nil, err } return &Provider{ - engine: tfengine.NewEngine(verbose), - registry: registry, - executor: exec, + engine: tfengine.NewEngine(verbose), + registry: registry, + executor: exec, + confirmApply: tfengine.ConfirmApplyInteractive, }, nil } @@ -156,9 +161,10 @@ func (p *Provider) CreateCluster(ctx context.Context, config models.ClusterConfi } ws := p.registry.Workspace(config.Name) + freshWorkspace := !ws.Exists() // The collision check only guards NEW clusters: an existing workspace means // the cloud cluster (partial or complete) is ours and create resumes it. - if !ws.Exists() { + if freshWorkspace { if err := p.preflightNameCollision(ctx, config); err != nil { return nil, err } @@ -185,14 +191,44 @@ func (p *Provider) CreateCluster(ctx context.Context, config models.ClusterConfi } } } - // An existing workspace means a previous create failed or was interrupted; - // keep its tfvars (the state may reference them) and simply resume. + // An existing workspace means a previous create failed or was interrupted. + // Refresh the generated module from the CURRENT template before resuming: + // the retry must pick up template bugfixes (e.g. the private-nodes fix for + // org-policy environments), not replay the broken files. The state is + // untouched — terraform reconciles it against the refreshed config. + if ws.Exists() { + vars, err := tfvarsFor(config) + if err != nil { + return nil, err + } + if err := tfengine.WriteModule(ws.TerraformDir(), mainTF, vars); err != nil { + return nil, err + } + } 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 { + + // The `terraform apply` shape: plan, show, confirm, then apply the SAVED + // plan — what the user approved is exactly what runs. + summary, planFile, err := p.engine.PlanForApply(ctx, ws.TerraformDir()) + if planFile != "" { + defer func() { _ = os.Remove(planFile) }() + } + if err != nil { + _ = ws.SetStatus(tfengine.StatusFailed) + return nil, models.NewClusterOperationError("create", config.Name, err) + } + if p.confirmApply != nil && !p.confirmApply(summary) { + if freshWorkspace { + // Nothing was applied — a declined brand-new create leaves no trace. + _ = ws.Remove() + } + return nil, fmt.Errorf("cluster creation cancelled — no changes were applied") + } + if err := p.engine.ApplyPlan(ctx, ws.TerraformDir(), planFile); 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)) diff --git a/internal/cluster/providers/gke/provider_test.go b/internal/cluster/providers/gke/provider_test.go index 1d51b59a..2b835bf9 100644 --- a/internal/cluster/providers/gke/provider_test.go +++ b/internal/cluster/providers/gke/provider_test.go @@ -106,7 +106,8 @@ func TestCreateCluster_HappyPath(t *testing.T) { restConfig, err := p.CreateCluster(context.Background(), gkeConfig("demo")) require.NoError(t, err) - assert.Equal(t, []string{"init", "apply", "output"}, *calls) + assert.Equal(t, []string{"init", "plan", "show", "apply", "output"}, *calls, + "create follows the terraform-apply shape: plan+show before the (auto-approved) apply") 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) @@ -241,11 +242,36 @@ func TestTfvarsFor_VersionMapping(t *testing.T) { func TestTemplateEmbedsModulePins(t *testing.T) { tf := string(mainTF) - assert.Contains(t, tf, `source = "terraform-google-modules/kubernetes-engine/google"`) + assert.Contains(t, tf, `source = "terraform-google-modules/kubernetes-engine/google//modules/private-cluster"`) 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") + // Org-policy compatibility (restrict_vm_external_ips): private nodes with + // NAT egress and a public control-plane endpoint. + assert.Contains(t, tf, "enable_private_nodes = true") + assert.Contains(t, tf, "enable_private_endpoint = false") + assert.Contains(t, tf, `resource "google_compute_router_nat" "nat"`) +} + +// TestCreateCluster_ResumeRefreshesTemplate: a retry after a failed create +// must regenerate main.tf from the CURRENT embedded template so template +// bugfixes reach existing workspaces. +func TestCreateCluster_ResumeRefreshesTemplate(t *testing.T) { + p, _, registry := newTestProvider(t, nil) + _, err := p.CreateCluster(context.Background(), gkeConfig("demo")) + require.NoError(t, err) + + // Simulate a stale workspace from an older CLI version. + stalePath := filepath.Join(registry.Workspace("demo").TerraformDir(), "main.tf") + require.NoError(t, os.WriteFile(stalePath, []byte("# stale broken template"), 0o600)) + + _, err = p.CreateCluster(context.Background(), gkeConfig("demo")) + require.NoError(t, err) + + refreshed, err := os.ReadFile(stalePath) + require.NoError(t, err) + assert.Contains(t, string(refreshed), "enable_private_nodes", "resume must rewrite main.tf from the current template") } func TestPlanCluster_NewClusterDoesNotRegister(t *testing.T) { @@ -401,3 +427,36 @@ func TestPreflightNameCollision_FindsZonalClusters(t *testing.T) { assert.Contains(t, err.Error(), "not managed by openframe") assert.Empty(t, *calls) } + +// TestCreateCluster_DeclinedPlanAppliesNothing: the interactive plan gate — +// a declined plan must apply nothing, and a declined BRAND-NEW create must +// leave no workspace behind (nothing exists to resume or bill). +func TestCreateCluster_DeclinedPlanAppliesNothing(t *testing.T) { + p, calls, registry := newTestProvider(t, nil) + p.confirmApply = func(summary tfengine.PlanSummary) bool { return false } + + _, err := p.CreateCluster(context.Background(), gkeConfig("demo")) + require.Error(t, err) + assert.Contains(t, err.Error(), "cancelled") + assert.NotContains(t, *calls, "apply", "a declined plan must not apply") + + _, err = registry.Get("demo") + var notFound models.ErrClusterNotFound + assert.ErrorAs(t, err, ¬Found, "declined brand-new create must remove the fresh workspace") +} + +// TestCreateCluster_DeclinedResumeKeepsWorkspace: declining a RESUME keeps +// the workspace — its state still points at real (billed) resources. +func TestCreateCluster_DeclinedResumeKeepsWorkspace(t *testing.T) { + p, _, registry := newTestProvider(t, errors.New("first apply fails")) + _, err := p.CreateCluster(context.Background(), gkeConfig("demo")) + require.Error(t, err) // failed create leaves a resumable workspace + + p.confirmApply = func(summary tfengine.PlanSummary) bool { return false } + _, err = p.CreateCluster(context.Background(), gkeConfig("demo")) + require.Error(t, err) + assert.Contains(t, err.Error(), "cancelled") + + _, err = registry.Get("demo") + require.NoError(t, err, "declined resume must keep the workspace and its state") +} diff --git a/internal/cluster/providers/gke/templates/main.tf b/internal/cluster/providers/gke/templates/main.tf index 585cd4ba..7ddf4925 100644 --- a/internal/cluster/providers/gke/templates/main.tf +++ b/internal/cluster/providers/gke/templates/main.tf @@ -100,8 +100,30 @@ module "network" { depends_on = [google_project_service.required] } +# Private nodes have no external IPs (required by orgs enforcing the +# constraints/compute.vmExternalIpAccess policy — a public-node pool lands in +# ERROR state there), so egress (image pulls etc.) goes through Cloud NAT. +resource "google_compute_router" "nat" { + name = "${var.cluster_name}-router" + project = var.project + region = var.region + network = module.network.network_name +} + +resource "google_compute_router_nat" "nat" { + name = "${var.cluster_name}-nat" + project = var.project + region = var.region + router = google_compute_router.nat.name + + nat_ip_allocate_option = "AUTO_ONLY" + source_subnetwork_ip_ranges_to_nat = "ALL_SUBNETWORKS_ALL_IP_RANGES" +} + module "gke" { - source = "terraform-google-modules/kubernetes-engine/google" + # The private-cluster submodule: same interface as the root module plus the + # private-nodes controls the root module does not expose. + source = "terraform-google-modules/kubernetes-engine/google//modules/private-cluster" version = "~> 44.0" project_id = var.project @@ -116,6 +138,13 @@ module "gke" { kubernetes_version = var.kubernetes_version != "" ? var.kubernetes_version : "latest" + # Nodes are private (no external IPs — org-policy friendly; egress via the + # Cloud NAT above). The API endpoint stays public so the operator's machine + # reaches the cluster without a VPN or Connect Gateway. + enable_private_nodes = true + enable_private_endpoint = false + master_ipv4_cidr_block = "172.16.0.0/28" + # The CLI owns this cluster's lifecycle; deletion protection would break # 'openframe cluster delete'. deletion_protection = false diff --git a/internal/cluster/providers/terraform/confirm.go b/internal/cluster/providers/terraform/confirm.go new file mode 100644 index 00000000..bf83022e --- /dev/null +++ b/internal/cluster/providers/terraform/confirm.go @@ -0,0 +1,31 @@ +package terraform + +import ( + "fmt" + + sharedUI "github.com/flamingo-stack/openframe-cli/internal/shared/ui" + "github.com/pterm/pterm" +) + +// ConfirmApplyInteractive is the default pre-apply gate for cluster creation +// — the interactive `terraform apply` shape: show the full plan, then ask. +// Non-interactive sessions auto-approve (the previous behavior of every +// scripted/CI create), and a plan with no changes needs no question. +func ConfirmApplyInteractive(summary PlanSummary) bool { + if !summary.HasChanges() { + return true + } + if sharedUI.IsNonInteractive() { + return true + } + + pterm.DefaultBasicText.Println() + for _, change := range summary.Changes { + pterm.DefaultBasicText.Printf(" %-3s %s\n", change.Action, change.Address) + } + pterm.DefaultBasicText.Printf("\nPlan: %d to add, %d to change, %d to destroy\n\n", summary.Add, summary.Change, summary.Destroy) + + confirmed, err := sharedUI.ConfirmActionInteractive( + fmt.Sprintf("Apply this plan (%d resources to add)?", summary.Add), true) + return err == nil && confirmed +} diff --git a/internal/cluster/providers/terraform/engine.go b/internal/cluster/providers/terraform/engine.go index 03e992c3..1407c1bd 100644 --- a/internal/cluster/providers/terraform/engine.go +++ b/internal/cluster/providers/terraform/engine.go @@ -144,23 +144,34 @@ 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) { + summary, planFile, err := e.PlanForApply(ctx, dir) + if planFile != "" { + _ = os.Remove(planFile) + } + return summary, err +} + +// PlanForApply plans dir and KEEPS the plan file for a subsequent ApplyPlan — +// the interactive `terraform apply` shape: what was shown to the user is +// EXACTLY what gets applied, with no re-plan drift in between. The caller +// owns removing the returned plan file. +func (e *Engine) PlanForApply(ctx context.Context, dir string) (PlanSummary, string, error) { tf, err := e.newRunner(dir) if err != nil { - return PlanSummary{}, err + 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) + return PlanSummary{}, "", fmt.Errorf("terraform plan failed: %w", err) } if !changes { - return PlanSummary{}, nil + return PlanSummary{}, planFile, nil } plan, err := tf.ShowPlanFile(ctx, planFile) if err != nil { - return PlanSummary{}, fmt.Errorf("terraform show failed: %w", err) + return PlanSummary{}, planFile, fmt.Errorf("terraform show failed: %w", err) } var summary PlanSummary // Best-effort: the JSON only feeds the optional cost estimate. @@ -184,7 +195,20 @@ func (e *Engine) Plan(ctx context.Context, dir string) (PlanSummary, error) { summary.Changes = append(summary.Changes, PlanChange{Action: "-/+", Address: rc.Address}) } } - return summary, nil + return summary, planFile, nil +} + +// ApplyPlan applies a SAVED plan file produced by PlanForApply, streaming +// per-resource progress like Apply. +func (e *Engine) ApplyPlan(ctx context.Context, dir, planFile string) error { + tf, err := e.newRunner(dir) + if err != nil { + return err + } + if err := tf.ApplyJSON(ctx, newProgressWriter(e.verbose), tfexec.DirOrPlan(planFile)); err != nil { + return fmt.Errorf("terraform apply failed: %w", err) + } + return nil } // Outputs returns the root-module outputs of dir as raw JSON values. From d1827938c7fa5504d6a7bf42bb062df66aaa9d5c Mon Sep 17 00:00:00 2001 From: Oleg Tkachuk Date: Wed, 22 Jul 2026 22:01:37 +0300 Subject: [PATCH 25/38] feat(cluster): terraform-apply-style plan confirmation before a real create (#251) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - create now plans, shows every resource with the summary, and asks for approval before applying — and applies the SAVED plan, so what the user approved is exactly what runs (PlanForApply/ApplyPlan in the engine) - declining a brand-new create removes the fresh workspace (nothing was applied); declining a resume keeps the workspace and its state - non-interactive sessions auto-approve, preserving scripted/CI behavior; a no-change plan asks nothing - docs: create flow updated (plan-confirm step, private nodes + NAT From b837bcea1a957e48ce545876a0898a4d13b5a8d0 Mon Sep 17 00:00:00 2001 From: Oleg Tkachuk Date: Wed, 22 Jul 2026 22:56:42 +0300 Subject: [PATCH 26/38] fix: address external code-review findings (#253) - create: gated EKS create now exits non-zero with an actionable coming-soon error (scripts must not see success); help texts updated - list: isManaged match is project-aware so a k3d cluster no longer shadows a same-name external GKE cluster - use: get-credentials uses --location (works for zonal clusters too) - gke: module depends_on Cloud NAT so nodes can pull images on boot - eks: preflight name-collision guard (mirrors GKE); aws provider pinned '>= 6.52, < 7.0' - terraform: apply prompt shows full plan counts; infracost run capped at 60s; cluster.json written atomically (temp file + rename) - flags: non-cloud types reject all cloud-only flags - service: ListClusters degrades gracefully when a cloud provider errors instead of failing the whole listing - wizard: region prompt labeled per provider (GCP Region / AWS Region) - tests: EKS banner error + CI step expect non-zero exit; zonal use test; EKS collision + declined-plan tests --- .github/workflows/test.yml | 7 ++-- README.md | 3 +- cmd/cluster/cluster.go | 2 +- cmd/cluster/create.go | 11 +++--- cmd/cluster/create_behavior_test.go | 9 +++-- cmd/cluster/list.go | 4 ++- cmd/cluster/use.go | 4 ++- cmd/cluster/use_test.go | 29 ++++++++++++++-- cmd/root.go | 2 +- internal/cluster/models/flags.go | 10 ++++-- internal/cluster/provider/provider.go | 9 +++-- internal/cluster/providers/eks/provider.go | 22 ++++++++++++ .../cluster/providers/eks/provider_test.go | 34 +++++++++++++++++++ .../cluster/providers/eks/templates/main.tf | 2 +- .../cluster/providers/gke/templates/main.tf | 4 +++ .../cluster/providers/terraform/confirm.go | 3 +- internal/cluster/providers/terraform/cost.go | 6 ++++ .../cluster/providers/terraform/workspace.go | 28 +++++++++++++-- internal/cluster/service.go | 5 ++- internal/cluster/ui/wizard.go | 6 ++-- internal/cluster/ui/wizard_steps.go | 7 ++-- 21 files changed, 172 insertions(+), 35 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8bf88b4c..caef4a1d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -367,9 +367,10 @@ jobs: else "$@"; fi } - echo "--- EKS create is gated behind the coming-soon banner (exit 0)" - "$OF_BIN" cluster create eks-smoke --type eks --skip-wizard >/tmp/eks.out 2>&1 \ - || { cat /tmp/eks.out; fail "eks banner path must exit 0"; } + echo "--- EKS create is gated: banner + non-zero exit (scripts must not see success)" + if "$OF_BIN" cluster create eks-smoke --type eks --skip-wizard >/tmp/eks.out 2>&1; then + cat /tmp/eks.out; fail "gated eks create must exit non-zero" + fi grep -qi "coming soon" /tmp/eks.out || { cat /tmp/eks.out; fail "eks banner text missing"; } echo "--- unknown --type is rejected" diff --git a/README.md b/README.md index 9b290b5a..24fad29e 100644 --- a/README.md +++ b/README.md @@ -180,7 +180,8 @@ 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-gke --type gke --project my-project --region us-central1 --skip-wizard # cloud (billed!) +openframe cluster create my-eks --type eks --skip-wizard # EKS is gated: shows a coming-soon banner, creates nothing openframe cluster list # add -o json|yaml for scripts openframe cluster status dev openframe cluster delete dev --force diff --git a/cmd/cluster/cluster.go b/cmd/cluster/cluster.go index 337cb8d3..e86e3173 100644 --- a/cmd/cluster/cluster.go +++ b/cmd/cluster/cluster.go @@ -27,7 +27,7 @@ This command group provides cluster lifecycle management functionality: • use - Switch the kubectl context to a cluster • cleanup - Remove unused images and resources -Supports K3d clusters for local development and AWS EKS for cloud deployments. +Supports K3d clusters for local development and Google GKE for cloud deployments (AWS EKS coming soon). Examples: openframe cluster create diff --git a/cmd/cluster/create.go b/cmd/cluster/create.go index 63a05841..51560a02 100644 --- a/cmd/cluster/create.go +++ b/cmd/cluster/create.go @@ -34,12 +34,13 @@ 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 (AWS EKS +creation is temporarily disabled and coming soon). If a cluster with the same name already exists it is left untouched and reused — delete it first to start from scratch. Use the bootstrap command to install OpenFrame components after creation. -EKS clusters are provisioned with Terraform (installed automatically) and +Cloud clusters are provisioned with Terraform (installed automatically) and create AWS resources that incur costs; the workspace and state live under ~/.openframe/clusters/. A failed create can be re-run to resume, or torn down with 'openframe cluster delete'. @@ -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,9 @@ func runCreateCluster(cmd *cobra.Command, args []string) error { // creates are gated. if config.Type == models.ClusterTypeEKS { showEKSComingSoonBanner() - return nil + // Non-zero on purpose: a scripted `--type eks` must not look like a + // successful create when nothing was provisioned. + return fmt.Errorf("AWS EKS cluster creation is coming soon — use --type gke or k3d for now") } // Show configuration summary for dry-run or skip-wizard modes diff --git a/cmd/cluster/create_behavior_test.go b/cmd/cluster/create_behavior_test.go index c4d38fb7..f5e3a206 100644 --- a/cmd/cluster/create_behavior_test.go +++ b/cmd/cluster/create_behavior_test.go @@ -2,6 +2,7 @@ package cluster import ( "context" + "strings" "testing" "github.com/flamingo-stack/openframe-cli/internal/cluster/models" @@ -178,8 +179,12 @@ func TestRunCreateCluster_EKSShowsComingSoonBanner(t *testing.T) { gf.Create.SkipWizard = true gf.Create.ClusterType = "eks" - if err := runCreateCluster(cmd, []string{"cloud-cluster"}); err != nil { - t.Fatalf("eks banner path must return nil, got %v", err) + err := runCreateCluster(cmd, []string{"cloud-cluster"}) + if err == nil { + t.Fatal("gated eks create must exit non-zero — a script must not mistake the banner for success") + } + if !strings.Contains(err.Error(), "coming soon") { + t.Fatalf("expected an actionable coming-soon error, got: %v", err) } if called { t.Fatal("eks must not reach the plan preview while gated") diff --git a/cmd/cluster/list.go b/cmd/cluster/list.go index e2fa46f4..aa6c679f 100644 --- a/cmd/cluster/list.go +++ b/cmd/cluster/list.go @@ -123,7 +123,9 @@ 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) { + // Project-aware: a local k3d cluster (empty Project) must not + // suppress an external GKE cluster that merely shares its name. + if m.Name == c.Name && m.Project == c.Project { return true } } diff --git a/cmd/cluster/use.go b/cmd/cluster/use.go index 332da138..257dcca8 100644 --- a/cmd/cluster/use.go +++ b/cmd/cluster/use.go @@ -129,8 +129,10 @@ func useExternalGKE(ctx context.Context, exec executor.CommandExecutor, kubeconf if contextName == "" { // No kubeconfig entry yet — fetch credentials (adds the gke_* context). pterm.Info.Printf("Fetching credentials for '%s' (project %s, %s)...\n", name, found.Project, found.Region) + // --location (not --region): discovery's Region field carries the GKE + // location, which is a ZONE for zonal clusters. if _, err := exec.Execute(ctx, "gcloud", "container", "clusters", "get-credentials", name, - "--project", found.Project, "--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/cmd/cluster/use_test.go b/cmd/cluster/use_test.go index 64da3830..b78a7883 100644 --- a/cmd/cluster/use_test.go +++ b/cmd/cluster/use_test.go @@ -115,9 +115,10 @@ func TestRunUseCluster_ExternalGKEFetchesCredentials(t *testing.T) { err := runUseCluster(getUseCmd(), []string{"ext-1"}) // The mock cannot actually write the gke_* context, so the switch fails — - // but the credentials fetch must have been attempted first. - if !mock.WasCommandExecuted("gcloud container clusters get-credentials ext-1") { - t.Fatal("expected a get-credentials attempt for a context-less external cluster") + // but the credentials fetch must have been attempted first, with + // --location (which accepts both regions and zones). + if !mock.WasCommandExecuted("gcloud container clusters get-credentials ext-1 --project proj-x --location us-central1") { + t.Fatalf("expected a --location get-credentials attempt, got: %v", mock.GetExecutedCommands()) } if err == nil || !strings.Contains(err.Error(), "no kubeconfig context") { t.Fatalf("expected a missing-context error after mock fetch, got: %v", err) @@ -135,3 +136,25 @@ func TestRunUseCluster_NotAuthenticatedIsActionable(t *testing.T) { t.Fatalf("expected an auth hint, got: %v", err) } } + +// TestRunUseCluster_ExternalZonalGKEFetchesByLocation: a ZONAL cluster's +// location is a zone — get-credentials must receive it via --location, where +// --region would fail. +func TestRunUseCluster_ExternalZonalGKEFetchesByLocation(t *testing.T) { + mock := setupUse(t) + writeUseKubeconfig(t, "other", "other") + mock.SetResponse("gcloud auth list", &executor.CommandResult{ExitCode: 0, Stdout: "me@example.com\n"}) + mock.SetResponse("gcloud config configurations list", &executor.CommandResult{ExitCode: 0, + Stdout: `[{"name":"dev-x","properties":{"core":{"project":"proj-x"}}}]`}) + mock.SetResponse("clusters list --project proj-x", &executor.CommandResult{ExitCode: 0, + Stdout: `[{"name":"zonal-1","location":"us-central1-a","status":"RUNNING","currentNodeCount":1}]`}) + mock.SetResponse("k3d cluster get zonal-1", &executor.CommandResult{ExitCode: 1, Stderr: "not found"}) + + err := runUseCluster(getUseCmd(), []string{"zonal-1"}) + if !mock.WasCommandExecuted("gcloud container clusters get-credentials zonal-1 --project proj-x --location us-central1-a") { + t.Fatalf("expected a zone-valued --location fetch, got: %v", mock.GetExecutedCommands()) + } + if err == nil || !strings.Contains(err.Error(), "no kubeconfig context") { + t.Fatalf("expected a missing-context error after mock fetch, got: %v", err) + } +} diff --git a/cmd/root.go b/cmd/root.go index 73201262..de4dc128 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 - local K3d clusters (cloud providers planned) + - Cluster Management - local K3d and cloud GKE clusters (AWS EKS coming soon) - Helm Integration - App-of-Apps pattern with ArgoCD - Prerequisite Checking - Validates tools before running diff --git a/internal/cluster/models/flags.go b/internal/cluster/models/flags.go index 04987308..5b52bcc4 100644 --- a/internal/cluster/models/flags.go +++ b/internal/cluster/models/flags.go @@ -174,8 +174,14 @@ func ValidateCreateFlags(flags *CreateFlags) error { 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 !isCloud { + switch { + case flags.BackendConfig != "": + return fmt.Errorf("--backend-config only applies to cloud cluster types (eks, gke)") + case flags.Region != "" || flags.Profile != "" || flags.Project != "" || + flags.MachineType != "" || flags.MinNodes != 0 || flags.MaxNodes != 0 || flags.Spot: + return fmt.Errorf("--region/--profile/--project/--machine-type/--min-nodes/--max-nodes/--spot only apply 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) diff --git a/internal/cluster/provider/provider.go b/internal/cluster/provider/provider.go index 3db1e9da..53921e35 100644 --- a/internal/cluster/provider/provider.go +++ b/internal/cluster/provider/provider.go @@ -1,10 +1,9 @@ // 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. Three backends are +// implemented — k3d (local), EKS, and GKE — all selected through the New +// factory, keyed on the cluster type. Backends implement the same Provider +// interface, so the rest of the CLI never needs to know which one runs. package provider import ( diff --git a/internal/cluster/providers/eks/provider.go b/internal/cluster/providers/eks/provider.go index 6d9fad99..89b9a843 100644 --- a/internal/cluster/providers/eks/provider.go +++ b/internal/cluster/providers/eks/provider.go @@ -128,6 +128,25 @@ func (p *Provider) PlanCluster(ctx context.Context, config models.ClusterConfig) return p.engine.Plan(ctx, dir) } +// preflightNameCollision refuses to create a cluster whose name already +// exists in the target account/region but has no openframe workspace — +// terraform would build the VPC first and fail mid-apply on the duplicate +// cluster, leaving partial billed infrastructure (the GKE twin's rationale). +// Existence criterion: describe exits 0 AND prints exactly the name. +func (p *Provider) preflightNameCollision(ctx context.Context, config models.ClusterConfig) error { + args := []string{"eks", "describe-cluster", "--name", config.Name, + "--region", config.Cloud.Region, "--query", "cluster.name", "--output", "text"} + if config.Cloud.Profile != "" { + args = append(args, "--profile", config.Cloud.Profile) + } + result, err := p.executor.Execute(ctx, "aws", args...) + if err != nil || result == nil || strings.TrimSpace(result.Stdout) != config.Name { + return nil // not found (or indeterminate) — proceed + } + return fmt.Errorf("cluster '%s' already exists in region '%s' but is not managed by openframe — refusing to touch it; pick another cluster name", + config.Name, config.Cloud.Region) +} + // 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. @@ -146,6 +165,9 @@ func (p *Provider) CreateCluster(ctx context.Context, config models.ClusterConfi ws := p.registry.Workspace(config.Name) freshWorkspace := !ws.Exists() if freshWorkspace { + if err := p.preflightNameCollision(ctx, config); err != nil { + return nil, err + } vars, err := tfvarsFor(config) if err != nil { return nil, err diff --git a/internal/cluster/providers/eks/provider_test.go b/internal/cluster/providers/eks/provider_test.go index f4d7987a..5511589a 100644 --- a/internal/cluster/providers/eks/provider_test.go +++ b/internal/cluster/providers/eks/provider_test.go @@ -292,3 +292,37 @@ func TestCreateCluster_RejectsGCSBackend(t *testing.T) { assert.Contains(t, err.Error(), "must be s3://") assert.Empty(t, *calls) } + +// TestCreateCluster_RefusesExternalNameCollision mirrors the GKE guard: an +// unmanaged same-name EKS cluster in the account must block create BEFORE +// terraform runs. +func TestCreateCluster_RefusesExternalNameCollision(t *testing.T) { + p, calls, registry := newTestProvider(t, nil) + mock := executor.NewMockCommandExecutor() + mock.SetResponse("eks describe-cluster --name demo", &executor.CommandResult{ExitCode: 0, Stdout: "demo\n"}) + p.executor = mock + + _, err := p.CreateCluster(context.Background(), eksConfig("demo")) + require.Error(t, err) + assert.Contains(t, err.Error(), "not managed by openframe") + assert.Empty(t, *calls, "terraform must not run on a name collision") + + _, err = registry.Get("demo") + var notFound models.ErrClusterNotFound + assert.ErrorAs(t, err, ¬Found) +} + +// TestCreateCluster_DeclinedPlanAppliesNothing mirrors the GKE decline gate. +func TestCreateCluster_DeclinedPlanAppliesNothing(t *testing.T) { + p, calls, registry := newTestProvider(t, nil) + p.confirmApply = func(summary tfengine.PlanSummary) bool { return false } + + _, err := p.CreateCluster(context.Background(), eksConfig("demo")) + require.Error(t, err) + assert.Contains(t, err.Error(), "cancelled") + assert.NotContains(t, *calls, "apply") + + _, err = registry.Get("demo") + var notFound models.ErrClusterNotFound + assert.ErrorAs(t, err, ¬Found, "declined brand-new create must remove the fresh workspace") +} diff --git a/internal/cluster/providers/eks/templates/main.tf b/internal/cluster/providers/eks/templates/main.tf index d1b8e6f3..af7813dc 100644 --- a/internal/cluster/providers/eks/templates/main.tf +++ b/internal/cluster/providers/eks/templates/main.tf @@ -9,7 +9,7 @@ terraform { required_providers { aws = { source = "hashicorp/aws" - version = ">= 6.52" + version = ">= 6.52, < 7.0" } } } diff --git a/internal/cluster/providers/gke/templates/main.tf b/internal/cluster/providers/gke/templates/main.tf index 7ddf4925..48c5f21b 100644 --- a/internal/cluster/providers/gke/templates/main.tf +++ b/internal/cluster/providers/gke/templates/main.tf @@ -162,6 +162,10 @@ module "gke" { disk_size_gb = 100 } ] + + # Private nodes need the NAT for egress (image pulls) from the first boot — + # nothing else creates an implicit ordering on it. + 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..ad57c6d1 100644 --- a/internal/cluster/providers/terraform/confirm.go +++ b/internal/cluster/providers/terraform/confirm.go @@ -26,6 +26,7 @@ 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) confirmed, err := sharedUI.ConfirmActionInteractive( - fmt.Sprintf("Apply this plan (%d resources to add)?", summary.Add), true) + fmt.Sprintf("Apply this plan (%d to add, %d to change, %d to destroy)?", + summary.Add, summary.Change, summary.Destroy), true) return err == nil && confirmed } diff --git a/internal/cluster/providers/terraform/cost.go b/internal/cluster/providers/terraform/cost.go index 7e294501..bf801f8d 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,6 +50,11 @@ func EstimateMonthlyCost(ctx context.Context, execer sharedexec.CommandExecutor, return "", err } + // A local bound: infracost calls its pricing API over the network, and a + // hang here must never stall the dry-run preview (the parent ctx may have + // no deadline). + ctx, cancel := context.WithTimeout(ctx, 60*time.Second) + defer cancel() result, err := execer.Execute(ctx, "infracost", "breakdown", "--path", planPath, "--format", "json", "--no-color") if err != nil { diff --git a/internal/cluster/providers/terraform/workspace.go b/internal/cluster/providers/terraform/workspace.go index 19e1487c..5099ce53 100644 --- a/internal/cluster/providers/terraform/workspace.go +++ b/internal/cluster/providers/terraform/workspace.go @@ -125,13 +125,37 @@ func (w *Workspace) ReadRecord() (Record, error) { return r, nil } -// WriteRecord persists cluster.json atomically enough for a single-user CLI. +// WriteRecord persists cluster.json via temp-file + atomic rename, so a +// crash mid-write can never leave a truncated record — the record is the +// registry's only pointer to the cluster. 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) + tmp, err := os.CreateTemp(w.dir, ".record-*") + if err != nil { + return err + } + tmpName := tmp.Name() + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + _ = os.Remove(tmpName) + return err + } + if err := tmp.Close(); err != nil { + _ = os.Remove(tmpName) + return err + } + if err := os.Chmod(tmpName, 0o600); err != nil { + _ = os.Remove(tmpName) + return err + } + if err := os.Rename(tmpName, filepath.Join(w.dir, recordFile)); err != nil { + _ = os.Remove(tmpName) + return err + } + return nil } // SetStatus updates only the lifecycle status in the record. diff --git a/internal/cluster/service.go b/internal/cluster/service.go index a5fff798..54994ea3 100644 --- a/internal/cluster/service.go +++ b/internal/cluster/service.go @@ -280,7 +280,10 @@ func (s *ClusterService) ListClusters() ([]models.ClusterInfo, error) { for _, cloud := range s.cloudProviders() { cloudClusters, err := cloud.ListAllClusters(ctx) if err != nil { - return nil, err + // A broken cloud registry (local file damage) must not hide the + // local clusters or the other provider's results. + pterm.Debug.Printf("cloud cluster listing skipped: %v\n", err) + continue } clusters = append(clusters, cloudClusters...) } diff --git a/internal/cluster/ui/wizard.go b/internal/cluster/ui/wizard.go index 3d025d03..5fa96d7e 100644 --- a/internal/cluster/ui/wizard.go +++ b/internal/cluster/ui/wizard.go @@ -90,9 +90,9 @@ func (w *ConfigWizard) Run() (ClusterConfig, error) { // 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" + regionLabel, defaultRegion, defaultMachine := "AWS Region", "us-east-1", "m6i.large" if clusterType == models.ClusterTypeGKE { - defaultRegion, defaultMachine = "us-central1", "e2-standard-4" + regionLabel, defaultRegion, defaultMachine = "GCP Region", "us-central1", "e2-standard-4" project, err := steps.PromptProject() if err != nil { @@ -101,7 +101,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..b3ed3f95 100644 --- a/internal/cluster/ui/wizard_steps.go +++ b/internal/cluster/ui/wizard_steps.go @@ -90,10 +90,11 @@ 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 a cluster lands in; the label is +// provider-specific ("GCP Region" / "AWS Region"). +func (ws *WizardSteps) PromptRegion(label, defaultRegion string) (string, error) { prompt := promptui.Prompt{ - Label: "AWS Region", + Label: label, Default: defaultRegion, Validate: sharedUI.ValidateNonEmpty("region"), } From fce68b5266570d4f41ec2f9a3090316fd317707a Mon Sep 17 00:00:00 2001 From: Oleg Tkachuk Date: Thu, 23 Jul 2026 01:25:23 +0300 Subject: [PATCH 27/38] fix(prereq): install AWS CLI v2 on Linux, not legacy v1 (#254) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fedora/RHEL ship v2 as 'awscli2' and Arch as 'aws-cli-v2' (the old 'awscli'/'aws-cli' names are the retired v1). apt keeps 'awscli' (v2 on Debian 12+/Ubuntu 24.04+) but the install now verifies the resulting binary is v2 — legacy v1 repos (e.g. Ubuntu 22.04) get an actionable error instead of a CLI whose 'aws eks get-token' emits an auth API kubectl >=1.24 rejects. --- internal/cluster/prerequisites/aws/aws.go | 26 +++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/internal/cluster/prerequisites/aws/aws.go b/internal/cluster/prerequisites/aws/aws.go index ae5f6048..3161ed53 100644 --- a/internal/cluster/prerequisites/aws/aws.go +++ b/internal/cluster/prerequisites/aws/aws.go @@ -5,6 +5,7 @@ import ( "fmt" "os/exec" "runtime" + "strings" "time" "github.com/flamingo-stack/openframe-cli/internal/platform" @@ -64,6 +65,9 @@ func (a *AwsInstaller) installMacOS() error { return nil } +// installLinux installs the AWS CLI v2 via the platform package manager. The +// v2 package name differs per distro: Debian 12+/Ubuntu 24.04+ ship v2 as +// "awscli", Fedora/RHEL as "awscli2", Arch as "aws-cli-v2". func (a *AwsInstaller) installLinux() error { type pm struct { name string @@ -71,9 +75,9 @@ 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"}}, + {"dnf", []string{"dnf", "install", "-y", "awscli2"}}, + {"yum", []string{"yum", "install", "-y", "awscli2"}}, + {"pacman", []string{"pacman", "-S", "--noconfirm", "aws-cli-v2"}}, } for _, m := range managers { if !commandExists(m.name) { @@ -82,12 +86,26 @@ func (a *AwsInstaller) installLinux() error { // 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 + if installedIsV2() { + return nil + } + // Older repos (e.g. Ubuntu 22.04) ship legacy v1, whose + // `aws eks get-token` emits an auth API kubectl no longer accepts. + return fmt.Errorf("the distro package installed AWS CLI v1, but the EKS flow needs v2. %s", a.GetInstallHelp()) } } return fmt.Errorf("could not install the AWS CLI automatically. %s", a.GetInstallHelp()) } +// installedIsV2 reports whether the aws binary on PATH is the v2 CLI. v1 +// prints its version to stderr, v2 to stdout — check both. +func installedIsV2() bool { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + out, err := exec.CommandContext(ctx, "aws", "--version").CombinedOutput() + return err == nil && strings.HasPrefix(string(out), "aws-cli/2.") +} + 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() From cbc751da82996813a03fd6912737a954fc3623c8 Mon Sep 17 00:00:00 2001 From: Oleg Tkachuk Date: Mon, 27 Jul 2026 18:06:06 +0300 Subject: [PATCH 28/38] guard(cluster): scope terraform destroy to a cluster workspace (#265) --- .../providers/gke/template_guard_test.go | 53 +++++++++++++++++++ .../cluster/providers/terraform/engine.go | 25 +++++++++ .../providers/terraform/engine_test.go | 31 ++++++++++- 3 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 internal/cluster/providers/gke/template_guard_test.go diff --git a/internal/cluster/providers/gke/template_guard_test.go b/internal/cluster/providers/gke/template_guard_test.go new file mode 100644 index 00000000..d4338ada --- /dev/null +++ b/internal/cluster/providers/gke/template_guard_test.go @@ -0,0 +1,53 @@ +package gke + +import ( + "regexp" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// The GKE root module must never MANAGE the GCP project itself: the project is +// an input (var.project) the operator already owns, not a resource the CLI +// creates. If a `resource "google_project"` (or a project-deleting data flow) +// ever appears in the template, `openframe cluster delete` → terraform destroy +// would tear down the whole project — every unrelated workload in it included. +// This test fails loudly the moment the template stops treating the project as +// read-only. +func TestTemplate_NeverManagesTheProject(t *testing.T) { + src := string(mainTF) + + // No resource that owns the project's lifecycle (create ⇒ destroy). + forbidden := []string{ + `resource "google_project"`, + `resource "google_project_iam_policy"`, // authoritative — would overwrite project IAM + } + for _, decl := range forbidden { + assert.NotContainsf(t, src, decl, + "GKE template must not declare %s — the project must stay a read-only input, never a managed/destroyable resource", decl) + } + + // Belt and braces: no google_project* resource of ANY kind. The only + // project-scoped resource we allow is google_project_service (API + // enablement), and even that must never disable APIs on destroy. + projectResRE := regexp.MustCompile(`resource\s+"(google_project[a-z_]*)"`) + for _, m := range projectResRE.FindAllStringSubmatch(src, -1) { + assert.Equalf(t, "google_project_service", m[1], + "unexpected project-scoped resource %q in the GKE template; only google_project_service is permitted", m[1]) + } + + // google_project_service must keep disable_on_destroy=false so a cluster + // teardown never switches off project APIs other workloads depend on. + if assert.Contains(t, src, `resource "google_project_service"`) { + assert.Contains(t, src, "disable_on_destroy = false", + "google_project_service must set disable_on_destroy=false so destroy never disables project APIs") + } +} + +// The project must be consumed strictly as an input variable, proving the +// template reads the project rather than owning it. +func TestTemplate_ProjectIsAnInputVariable(t *testing.T) { + require.Contains(t, string(mainTF), `variable "project"`, + "the project must be a declared input variable, not a resource the template creates") +} diff --git a/internal/cluster/providers/terraform/engine.go b/internal/cluster/providers/terraform/engine.go index 1407c1bd..9c5b8283 100644 --- a/internal/cluster/providers/terraform/engine.go +++ b/internal/cluster/providers/terraform/engine.go @@ -106,7 +106,16 @@ func (e *Engine) Apply(ctx context.Context, dir string) error { } // Destroy runs terraform destroy in dir, streaming progress like Apply. +// +// Guardrail: terraform destroy tears down exactly what the state in `dir` +// tracks, so `dir` must be a materialized cluster workspace — one holding the +// CLI-generated main.tf root module. Refusing an empty, wrong, or parent path +// keeps a destroy strictly inside one cluster's own workspace and never lets +// terraform be pointed at a directory whose state we did not generate. func (e *Engine) Destroy(ctx context.Context, dir string) error { + if err := assertClusterWorkspace(dir); err != nil { + return err + } tf, err := e.newRunner(dir) if err != nil { return err @@ -117,6 +126,22 @@ func (e *Engine) Destroy(ctx context.Context, dir string) error { return nil } +// assertClusterWorkspace verifies dir is a generated cluster workspace before +// a destructive terraform run: it must hold a main.tf root module. This is the +// scoping check behind Destroy — terraform acts on the state in whatever +// directory it is handed, so a directory that is not one of our generated +// workspaces must fail loudly instead of running a destroy. +func assertClusterWorkspace(dir string) error { + info, err := os.Stat(filepath.Join(dir, "main.tf")) + if err != nil { + return fmt.Errorf("refusing to run terraform destroy in %q: not a cluster workspace (no generated main.tf): %w", dir, err) + } + if info.IsDir() { + return fmt.Errorf("refusing to run terraform destroy in %q: main.tf is not a regular file", dir) + } + return nil +} + // PlanChange is one resource-level action of a terraform plan, in // terraform's own diff notation: "+" create, "~" update, "-" destroy, // "-/+" replace. diff --git a/internal/cluster/providers/terraform/engine_test.go b/internal/cluster/providers/terraform/engine_test.go index 7aba2535..87f6db9b 100644 --- a/internal/cluster/providers/terraform/engine_test.go +++ b/internal/cluster/providers/terraform/engine_test.go @@ -5,6 +5,8 @@ import ( "encoding/json" "errors" "io" + "os" + "path/filepath" "testing" "github.com/hashicorp/terraform-exec/tfexec" @@ -13,6 +15,15 @@ import ( "github.com/stretchr/testify/require" ) +// workspaceDir creates a throwaway directory holding a generated main.tf — the +// minimum that makes it a valid destroy target for the workspace guardrail. +func workspaceDir(t *testing.T) string { + t.Helper() + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "main.tf"), []byte("# generated"), 0o600)) + return dir +} + // fakeRunner records calls and returns canned results. type fakeRunner struct { calls []string @@ -83,7 +94,7 @@ func TestEngine_LifecycleCalls(t *testing.T) { 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")) + require.NoError(t, e.Destroy(ctx, workspaceDir(t))) outputs, err := e.Outputs(ctx, "dir") require.NoError(t, err) @@ -94,6 +105,24 @@ func TestEngine_LifecycleCalls(t *testing.T) { assert.Equal(t, []string{"init", "apply", "plan", "destroy", "output"}, f.calls) } +// A destroy pointed at anything but a generated cluster workspace must be +// refused BEFORE terraform runs — this is what confines a destroy to one +// cluster's own state and keeps it from ever touching an unexpected directory. +func TestEngine_DestroyRefusesNonWorkspaceDir(t *testing.T) { + f := &fakeRunner{} + // An empty temp dir has no generated main.tf, so it is not a workspace. + err := engineWith(f).Destroy(context.Background(), t.TempDir()) + require.Error(t, err) + assert.ErrorContains(t, err, "not a cluster workspace") + assert.NotContains(t, f.calls, "destroy", "terraform destroy must not run against a non-workspace dir") +} + +func TestEngine_DestroyRunsInWorkspaceDir(t *testing.T) { + f := &fakeRunner{} + require.NoError(t, engineWith(f).Destroy(context.Background(), workspaceDir(t))) + assert.Equal(t, []string{"destroy"}, f.calls) +} + // action builds a tfjson resource change with the given address and actions. func action(address string, actions ...tfjson.Action) *tfjson.ResourceChange { return &tfjson.ResourceChange{Address: address, Change: &tfjson.Change{Actions: actions}} From e07eb83d35befc550a08e4f0c49861aff9ac50e5 Mon Sep 17 00:00:00 2001 From: Oleg Tkachuk Date: Tue, 28 Jul 2026 14:58:23 +0300 Subject: [PATCH 29/38] fix(gke): zonal clusters by default so --nodes N means N, not N-per-zone (#266) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The module built a regional cluster, so node counts were per-zone: --nodes 3 provisioned 3 nodes in each of the region's ~3 zones (~9 total) while every display still said 3 — roughly 3x the expected node bill, silently. Default to a single-zone cluster (regional=false), so the node pool's counts are the real totals and the existing 'Nodes: N' display becomes truthful with no display change. The provider resolves a concrete zone from the region via gcloud (alphabetically-first, deterministic so a resume never moves the cluster). A new --ha flag opts back into a regional, multi-zone control plane and node pool for HA. NOTE: the terraform template change (regional/zone vars, zones wiring) could not be validated in this environment — run 'make test-tfvalidate' (needs a terraform binary) before merging. fix(gke): release PVC disks before destroy and report any orphans after cluster delete ran a bare terraform destroy under a live platform. PVC- provisioned Persistent Disks live outside terraform state, so when the node pool died they detached and survived as billable orphans, while the CLI reported a clean teardown. Before destroy, delete the app namespaces (argocd first so its controller stops re-syncing, then openframe) and wait, bounded, for their PVC-backed PersistentVolumes to drain so the GKE CSI driver deletes the underlying disks while the cluster is still alive. Every step is best-effort and never blocks the destroy the user asked for. After a successful destroy, sweep for disks still labeled goog-k8s-cluster-name= and report them with a cleanup command (report-only — the CLI never deletes cloud disks directly), so a 'cleaned up' delete never silently leaves orphans --- cmd/cluster/cluster.go | 9 +- cmd/cluster/create.go | 1 + internal/chart/providers/helm/argocd_wait.go | 20 ++- .../chart/providers/helm/argocd_wait_test.go | 29 ++++ internal/cluster/models/cluster.go | 7 + internal/cluster/models/flags.go | 6 +- internal/cluster/providers/gke/orphan_test.go | 32 ++++ internal/cluster/providers/gke/provider.go | 97 +++++++++++- .../cluster/providers/gke/provider_test.go | 47 ++++++ internal/cluster/providers/gke/teardown.go | 143 ++++++++++++++++++ .../cluster/providers/gke/teardown_test.go | 43 ++++++ internal/cluster/providers/gke/template.go | 6 + .../providers/gke/template_guard_test.go | 27 ++++ .../providers/gke/template_validate_test.go | 4 +- .../cluster/providers/gke/templates/main.tf | 31 +++- internal/cluster/service.go | 7 +- internal/cluster/service_test.go | 19 +++ 17 files changed, 517 insertions(+), 11 deletions(-) create mode 100644 internal/chart/providers/helm/argocd_wait_test.go create mode 100644 internal/cluster/providers/gke/orphan_test.go create mode 100644 internal/cluster/providers/gke/teardown.go create mode 100644 internal/cluster/providers/gke/teardown_test.go diff --git a/cmd/cluster/cluster.go b/cmd/cluster/cluster.go index e86e3173..09d84e9b 100644 --- a/cmd/cluster/cluster.go +++ b/cmd/cluster/cluster.go @@ -49,9 +49,12 @@ Examples: } // create runs its own type-aware gate after the cluster type is known // (a cloud cluster must not demand Docker/k3d); use only flips local - // kubeconfig/gcloud state and needs no tools at all. The other - // subcommands are k3d-scoped, so the k3d gate stays here. - if cmd.Name() == "create" || cmd.Name() == "use" { + // kubeconfig/gcloud state and needs no tools at all. status and list + // are cross-provider read-only views: they must work against a cloud + // cluster with Docker stopped, so they skip the k3d gate and degrade + // gracefully instead (k3d enumeration is best-effort in the service). + switch cmd.Name() { + case "create", "use", "status", "list": return nil } return prerequisites.CheckPrerequisites() diff --git a/cmd/cluster/create.go b/cmd/cluster/create.go index 51560a02..962f5e05 100644 --- a/cmd/cluster/create.go +++ b/cmd/cluster/create.go @@ -290,6 +290,7 @@ func runCreateCluster(cmd *cobra.Command, args []string) error { MinNodes: cf.MinNodes, MaxNodes: cf.MaxNodes, Spot: cf.Spot, + HA: cf.HA, BackendConfig: cf.BackendConfig, } } diff --git a/internal/chart/providers/helm/argocd_wait.go b/internal/chart/providers/helm/argocd_wait.go index 6b69fa10..3580cee3 100644 --- a/internal/chart/providers/helm/argocd_wait.go +++ b/internal/chart/providers/helm/argocd_wait.go @@ -163,6 +163,24 @@ func (h *HelmManager) ensureArgoCDNamespace(ctx context.Context, clusterName str }) } +// apiDialAddress turns a rest.Config Host into a dialable host:port. It strips +// the scheme and, when the endpoint carries no explicit port, defaults to the +// HTTPS port. Managed control planes (GKE/EKS) expose a bare host with no port +// (e.g. https://34.9.1.2); a TCP dial requires host:port, so without this the +// dial fails with "missing port in address" on every poll and the wait always +// times out even though the API is up. k3d endpoints already include :6550 and +// pass through unchanged. Returns "" when no address can be determined. +func apiDialAddress(host string) string { + addr := strings.TrimPrefix(strings.TrimPrefix(host, "https://"), "http://") + if addr == "" { + return "" + } + if _, _, err := net.SplitHostPort(addr); err != nil { + addr = net.JoinHostPort(addr, "443") + } + return addr +} + // waitForAPIPort waits for the Kubernetes API port to be open before making API calls // This prevents flooding a dead port with requests on Windows/WSL2 where the port // might not be immediately available after k3d reports success @@ -172,7 +190,7 @@ func (h *HelmManager) waitForAPIPort(ctx context.Context, timeout time.Duration) } // Extract host:port from kubeConfig.Host - apiAddress := strings.TrimPrefix(strings.TrimPrefix(h.kubeConfig.Host, "https://"), "http://") + apiAddress := apiDialAddress(h.kubeConfig.Host) if apiAddress == "" { return nil // Skip if we can't determine the address } diff --git a/internal/chart/providers/helm/argocd_wait_test.go b/internal/chart/providers/helm/argocd_wait_test.go new file mode 100644 index 00000000..d197baf5 --- /dev/null +++ b/internal/chart/providers/helm/argocd_wait_test.go @@ -0,0 +1,29 @@ +package helm + +import "testing" + +// apiDialAddress must produce a dialable host:port for every endpoint shape. +// The regression it guards: GKE/EKS endpoints are a bare host with no port, so +// a naive scheme-strip left the dialer with "missing port in address" and the +// API-port wait always timed out even though ArgoCD was already healthy. +func TestAPIDialAddress(t *testing.T) { + cases := []struct { + name string + host string + want string + }{ + {"gke bare host defaults to 443", "https://34.9.1.2", "34.9.1.2:443"}, + {"eks dns host defaults to 443", "https://ABCD.gr7.us-east-1.eks.amazonaws.com", "ABCD.gr7.us-east-1.eks.amazonaws.com:443"}, + {"k3d endpoint keeps its explicit port", "https://127.0.0.1:6550", "127.0.0.1:6550"}, + {"http scheme is stripped too", "http://10.0.0.1", "10.0.0.1:443"}, + {"host already host:port is untouched", "10.0.0.1:6443", "10.0.0.1:6443"}, + {"empty host yields empty", "", ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := apiDialAddress(tc.host); got != tc.want { + t.Fatalf("apiDialAddress(%q) = %q, want %q", tc.host, got, tc.want) + } + }) + } +} diff --git a/internal/cluster/models/cluster.go b/internal/cluster/models/cluster.go index 3c054118..d85078d1 100644 --- a/internal/cluster/models/cluster.go +++ b/internal/cluster/models/cluster.go @@ -32,6 +32,13 @@ type CloudConfig struct { MinNodes int `json:"min_nodes,omitempty"` MaxNodes int `json:"max_nodes,omitempty"` Spot bool `json:"spot,omitempty"` + // HA requests a regional (multi-zone) control plane and node pool. Default + // (false) is a single-zone cluster, so the node count is exact — N means N, + // not N-per-zone. GKE only. + HA bool `json:"ha,omitempty"` + // Zone is the single zone a zonal (non-HA) GKE cluster lives in, e.g. + // "us-central1-a". Derived from Region by the provider; empty for HA. + Zone string `json:"zone,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. diff --git a/internal/cluster/models/flags.go b/internal/cluster/models/flags.go index 5b52bcc4..7392f7a6 100644 --- a/internal/cluster/models/flags.go +++ b/internal/cluster/models/flags.go @@ -28,6 +28,7 @@ type CreateFlags struct { MinNodes int MaxNodes int Spot bool + HA bool BackendConfig string } @@ -80,6 +81,7 @@ func AddCreateFlags(cmd *cobra.Command, flags *CreateFlags) { 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().BoolVar(&flags.HA, "ha", false, "Regional (multi-zone) control plane and nodes for HA (gke only); default is a single-zone cluster where --nodes is the exact node count") cmd.Flags().StringVar(&flags.BackendConfig, "backend-config", "", "Remote terraform state: s3://bucket/prefix (eks) or gcs://bucket/prefix (gke); default is local state") } @@ -179,8 +181,8 @@ func ValidateCreateFlags(flags *CreateFlags) error { case flags.BackendConfig != "": return fmt.Errorf("--backend-config only applies to cloud cluster types (eks, gke)") case flags.Region != "" || flags.Profile != "" || flags.Project != "" || - flags.MachineType != "" || flags.MinNodes != 0 || flags.MaxNodes != 0 || flags.Spot: - return fmt.Errorf("--region/--profile/--project/--machine-type/--min-nodes/--max-nodes/--spot only apply to cloud cluster types (eks, gke)") + flags.MachineType != "" || flags.MinNodes != 0 || flags.MaxNodes != 0 || flags.Spot || flags.HA: + return fmt.Errorf("--region/--profile/--project/--machine-type/--min-nodes/--max-nodes/--spot/--ha only apply to cloud cluster types (eks, gke)") } } if flags.MinNodes < 0 || flags.MaxNodes < 0 { diff --git a/internal/cluster/providers/gke/orphan_test.go b/internal/cluster/providers/gke/orphan_test.go new file mode 100644 index 00000000..135ebd28 --- /dev/null +++ b/internal/cluster/providers/gke/orphan_test.go @@ -0,0 +1,32 @@ +package gke + +import ( + "errors" + "strings" + "testing" +) + +func TestOrphanFromInterruptedCreate(t *testing.T) { + t.Run("names the orphaned resource on a 409", func(t *testing.T) { + // Shape of a real terraform google-provider 409 during a resume. + err := errors.New("Error: Error creating Network: googleapi: Error 409: The resource " + + "'projects/tenant-y0/global/networks/test-resume-vpc' already exists, alreadyExists") + hint, ok := orphanFromInterruptedCreate(err, "/ws/test/terraform") + if !ok { + t.Fatal("expected a 409 AlreadyExists to be detected as an interrupted-create orphan") + } + if !strings.Contains(hint, "projects/tenant-y0/global/networks/test-resume-vpc") { + t.Fatalf("hint must name the exact orphaned resource, got:\n%s", hint) + } + if !strings.Contains(hint, "/ws/test/terraform") { + t.Fatalf("hint must reference the workspace terraform dir for import, got:\n%s", hint) + } + }) + + t.Run("ignores unrelated apply errors", func(t *testing.T) { + err := errors.New("Error: quota 'CPUS' exceeded, limit 24.0 in region us-central1") + if _, ok := orphanFromInterruptedCreate(err, "/ws/test/terraform"); ok { + t.Fatal("a non-409 error must not be reported as an orphan") + } + }) +} diff --git a/internal/cluster/providers/gke/provider.go b/internal/cluster/providers/gke/provider.go index dbabfe37..bed39ebc 100644 --- a/internal/cluster/providers/gke/provider.go +++ b/internal/cluster/providers/gke/provider.go @@ -10,6 +10,7 @@ import ( "context" "fmt" "os" + "regexp" "strings" "time" @@ -20,6 +21,42 @@ import ( "k8s.io/client-go/tools/clientcmd" ) +// gcpResourcePathRE pulls the GCP resource path (projects/...//) +// out of a terraform 409 error so an orphan can be named concretely. +var gcpResourcePathRE = regexp.MustCompile(`projects/[^'"\s]+`) + +// orphanFromInterruptedCreate detects the specific failure where terraform +// tries to create a resource that already exists in GCP (HTTP 409 / +// alreadyExists). This is the signature of a create interrupted (SIGINT) after +// the cloud API created a resource but before terraform saved it to state: the +// resource is real but state-invisible, so every resume collides with it (409) +// and 'cluster delete' — which only knows state-tracked resources — cannot +// remove it. Returns human-readable remediation and true when this is that +// case, so the caller can replace the generic "re-run to resume" hint (which +// would loop forever here) with something actionable. +func orphanFromInterruptedCreate(err error, terraformDir string) (string, bool) { + msg := err.Error() + low := strings.ToLower(msg) + if !strings.Contains(low, "alreadyexists") && !strings.Contains(low, "409") { + return "", false + } + resource := "the resource named in the error above" + if m := gcpResourcePathRE.FindString(msg); m != "" { + resource = m + } + return fmt.Sprintf( + "a resource already exists in GCP that terraform is not tracking:\n"+ + " %s\n"+ + "This is the signature of a create that was interrupted after the resource was\n"+ + "created but before its state was saved — so resume keeps colliding with it and\n"+ + "'cluster delete' cannot remove it (delete only knows state-tracked resources).\n"+ + "Resolve it one of two ways, then re-run create:\n"+ + " • delete the orphan in GCP (Cloud Console or the matching 'gcloud ... delete'), or\n"+ + " • import it into this cluster's state, e.g.:\n"+ + " terraform -chdir=%s import %s", + resource, terraformDir, resource), true +} + // Provider provisions and manages GKE clusters. type Provider struct { engine *tfengine.Engine @@ -89,6 +126,44 @@ func (p *Provider) preflightNameCollision(ctx context.Context, config models.Clu return nil } +// firstZoneInRegion returns the alphabetically-first zone of a region (e.g. +// "us-central1-a"), used as the single location for a zonal cluster. Sorting +// makes the choice deterministic across runs — a resume must not pick a +// different zone and try to move the cluster. gcloud is already a hard GKE +// prerequisite, so this adds no new dependency. +func (p *Provider) firstZoneInRegion(ctx context.Context, project, region string) (string, error) { + res, err := p.executor.Execute(ctx, "gcloud", "compute", "zones", "list", + "--project", project, + "--filter", "name~^"+region+"-", + "--format=value(name)", "--sort-by=name", "--limit=1") + if err != nil { + return "", fmt.Errorf("listing zones for region %s: %w", region, err) + } + var zone string + if res != nil { + zone = strings.TrimSpace(strings.SplitN(strings.TrimSpace(res.Stdout), "\n", 2)[0]) + } + if zone == "" { + return "", fmt.Errorf("no zones found for region %q in project %q", region, project) + } + return zone, nil +} + +// ensureZone fills config.Cloud.Zone for a zonal (non-HA) cluster so the module +// has a concrete location and the node count is exact. No-op for HA (regional) +// clusters and when a zone is already set. +func (p *Provider) ensureZone(ctx context.Context, config *models.ClusterConfig) error { + if config.Cloud == nil || config.Cloud.HA || config.Cloud.Zone != "" { + return nil + } + zone, err := p.firstZoneInRegion(ctx, config.Cloud.Project, config.Cloud.Region) + if err != nil { + return err + } + config.Cloud.Zone = zone + return nil +} + // backendTF renders the gcs backend block for a GKE workspace. func backendTF(cfg tfengine.BackendConfig) []byte { return []byte(fmt.Sprintf( @@ -122,6 +197,9 @@ func (p *Provider) PlanCluster(ctx context.Context, config models.ClusterConfig) if err := p.preflightCredentials(ctx, config.Cloud.Project); err != nil { return tfengine.PlanSummary{}, err } + if err := p.ensureZone(ctx, &config); err != nil { + return tfengine.PlanSummary{}, err + } dir := p.registry.Workspace(config.Name).TerraformDir() if !p.registry.Workspace(config.Name).Exists() { @@ -159,6 +237,9 @@ func (p *Provider) CreateCluster(ctx context.Context, config models.ClusterConfi if err := p.preflightCredentials(ctx, config.Cloud.Project); err != nil { return nil, err } + if err := p.ensureZone(ctx, &config); err != nil { + return nil, err + } ws := p.registry.Workspace(config.Name) freshWorkspace := !ws.Exists() @@ -230,6 +311,10 @@ func (p *Provider) CreateCluster(ctx context.Context, config models.ClusterConfi } if err := p.engine.ApplyPlan(ctx, ws.TerraformDir(), planFile); err != nil { _ = ws.SetStatus(tfengine.StatusFailed) + if hint, ok := orphanFromInterruptedCreate(err, ws.TerraformDir()); ok { + return nil, models.NewClusterOperationError("create", config.Name, + fmt.Errorf("%w\n\n%s", err, hint)) + } 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)) } @@ -278,14 +363,24 @@ func (p *Provider) DeleteCluster(ctx context.Context, name string, clusterType m return models.NewClusterNotFoundError(name) } // Read the record BEFORE destroy: the endpoint in it is what proves the - // kubeconfig entry is ours to remove afterwards. + // kubeconfig entry is ours to remove afterwards, and the project is needed + // for the post-destroy orphan-disk sweep. rec, recErr := ws.ReadRecord() + if recErr == nil { + // Tear down app workloads first so the CSI driver deletes PVC-backed + // Persistent Disks before the node pool dies — otherwise they orphan as + // billable leftovers. Best-effort; never blocks the destroy. + releaseWorkloadDisks(ctx, rec) + } 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())) } if recErr == nil { _ = removeFromDefaultKubeconfig(rec) + // Surface any PVC-provisioned disks that outlived the destroy so a + // "cleaned up" delete never silently leaves billable orphans. + p.reportOrphanedDisks(ctx, rec.Project, name) } return ws.Remove() } diff --git a/internal/cluster/providers/gke/provider_test.go b/internal/cluster/providers/gke/provider_test.go index 2b835bf9..7494d29b 100644 --- a/internal/cluster/providers/gke/provider_test.go +++ b/internal/cluster/providers/gke/provider_test.go @@ -240,6 +240,53 @@ func TestTfvarsFor_VersionMapping(t *testing.T) { } } +// The default cluster is zonal so --nodes N is the exact node count; --ha +// (HA=true) makes it regional. Zone is passed through to the module. +func TestTfvarsFor_TopologyMapping(t *testing.T) { + zonal := gkeConfig("demo") + zonal.Cloud.Zone = "us-central1-a" + vars, err := tfvarsFor(zonal) + require.NoError(t, err) + assert.False(t, vars.Regional, "default must be zonal (exact node count)") + assert.Equal(t, "us-central1-a", vars.Zone) + + ha := gkeConfig("demo") + ha.Cloud.HA = true + vars, err = tfvarsFor(ha) + require.NoError(t, err) + assert.True(t, vars.Regional, "--ha must produce a regional cluster") +} + +// A zonal cluster resolves a concrete zone from the region via gcloud; the +// alphabetically-first is chosen deterministically. +func TestEnsureZone_ResolvesFirstZoneForZonal(t *testing.T) { + p, _, _ := newTestProvider(t, nil) + mock := executor.NewMockCommandExecutor() + mock.SetResponse("gcloud compute zones list", &executor.CommandResult{ + ExitCode: 0, + Stdout: "us-central1-a\n", + }) + p.executor = mock + + config := gkeConfig("demo") + require.NoError(t, p.ensureZone(context.Background(), &config)) + assert.Equal(t, "us-central1-a", config.Cloud.Zone) +} + +// An HA (regional) cluster needs no single zone; ensureZone must not even query. +func TestEnsureZone_SkipsForHA(t *testing.T) { + p, _, _ := newTestProvider(t, nil) + mock := executor.NewMockCommandExecutor() + p.executor = mock + + config := gkeConfig("demo") + config.Cloud.HA = true + require.NoError(t, p.ensureZone(context.Background(), &config)) + assert.Empty(t, config.Cloud.Zone) + assert.False(t, mock.WasCommandExecuted("gcloud compute zones list"), + "a regional cluster must not resolve a single zone") +} + func TestTemplateEmbedsModulePins(t *testing.T) { tf := string(mainTF) assert.Contains(t, tf, `source = "terraform-google-modules/kubernetes-engine/google//modules/private-cluster"`) diff --git a/internal/cluster/providers/gke/teardown.go b/internal/cluster/providers/gke/teardown.go new file mode 100644 index 00000000..179840c2 --- /dev/null +++ b/internal/cluster/providers/gke/teardown.go @@ -0,0 +1,143 @@ +package gke + +import ( + "context" + "strings" + "time" + + tfengine "github.com/flamingo-stack/openframe-cli/internal/cluster/providers/terraform" + "github.com/pterm/pterm" + corev1 "k8s.io/api/core/v1" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/wait" + "k8s.io/client-go/kubernetes" +) + +// appNamespaces are the namespaces OpenFrame installs into. They are deleted +// (argocd first, so its controller stops re-syncing, then openframe) before the +// infra is destroyed, so the PVCs living in them are removed and the GKE CSI +// driver deletes their backing Persistent Disks while the cluster is still +// alive. Order matters — see releaseWorkloadDisks. +var appNamespaces = []string{"argocd", "openframe"} + +// diskDrainTimeout bounds how long a delete waits for PVC-backed disks to be +// reclaimed before proceeding to destroy anyway. A live-platform teardown must +// never block indefinitely; anything not drained in time is surfaced by the +// post-destroy sweep instead. +const diskDrainTimeout = 4 * time.Minute + +// releaseWorkloadDisks deletes the OpenFrame app namespaces on the cluster and +// waits (bounded) for their PVC-backed PersistentVolumes to drain, so the GKE +// CSI driver deletes the underlying Persistent Disks BEFORE terraform destroys +// the node pool. Without this, PVC-provisioned disks — which live outside the +// terraform state — detach and survive as billable orphans. +// +// Entirely best-effort: an unreachable cluster, missing namespaces, RBAC +// denial, or a drain timeout are all logged and ignored. The infra destroy the +// user asked for must never be blocked by teardown, and the post-destroy sweep +// reports anything this misses. +func releaseWorkloadDisks(ctx context.Context, rec tfengine.Record) { + restCfg, err := restConfigFor(rec) + if err != nil { + pterm.Debug.Printf("skip pre-destroy disk release (no rest config): %v\n", err) + return + } + client, err := kubernetes.NewForConfig(restCfg) + if err != nil { + pterm.Debug.Printf("skip pre-destroy disk release (no kube client): %v\n", err) + return + } + + var deleting []string + for _, ns := range appNamespaces { + // Short per-call context so an unreachable API server fails fast rather + // than hanging the whole delete. + getCtx, cancel := context.WithTimeout(ctx, 15*time.Second) + _, err := client.CoreV1().Namespaces().Get(getCtx, ns, metav1.GetOptions{}) + cancel() + if err != nil { + continue // absent or unreachable — nothing to release here + } + delCtx, cancel := context.WithTimeout(ctx, 15*time.Second) + err = client.CoreV1().Namespaces().Delete(delCtx, ns, metav1.DeleteOptions{}) + cancel() + if err == nil || k8serrors.IsNotFound(err) { + deleting = append(deleting, ns) + } + } + if len(deleting) == 0 { + return // no OpenFrame workloads on the cluster; nothing to drain + } + pterm.Info.Printf("Releasing persistent disks (removing %s) before destroy...\n", strings.Join(deleting, ", ")) + + _ = wait.PollUntilContextTimeout(ctx, 5*time.Second, diskDrainTimeout, true, func(ctx context.Context) (bool, error) { + listCtx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + pvs, err := client.CoreV1().PersistentVolumes().List(listCtx, metav1.ListOptions{}) + if err != nil { + return false, nil // transient — keep polling until the outer timeout + } + return countClaimedPVs(pvs.Items, deleting) == 0, nil + }) +} + +// countClaimedPVs counts PersistentVolumes still claimed by any of the given +// namespaces. Once a PVC is deleted its PV is released and (reclaimPolicy +// Delete) removed, so this reaching zero means the backing disks are gone. +func countClaimedPVs(pvs []corev1.PersistentVolume, namespaces []string) int { + inScope := make(map[string]struct{}, len(namespaces)) + for _, ns := range namespaces { + inScope[ns] = struct{}{} + } + var n int + for _, pv := range pvs { + ref := pv.Spec.ClaimRef + if ref == nil { + continue + } + if _, ok := inScope[ref.Namespace]; ok { + n++ + } + } + return n +} + +// reportOrphanedDisks lists any Persistent Disks still labeled for this cluster +// after the destroy and prints them with a cleanup command. It is report-only: +// the CLI never deletes cloud disks directly, so a user is told the truth +// ("these survived") instead of a false "fully cleaned up". Best-effort — a +// gcloud hiccup is silently skipped. +func (p *Provider) reportOrphanedDisks(ctx context.Context, project, name string) { + if project == "" { + return + } + res, err := p.executor.Execute(ctx, "gcloud", "compute", "disks", "list", + "--project", project, + "--filter", "labels.goog-k8s-cluster-name="+name, + "--format=value(name,location,sizeGb)") + if err != nil || res == nil { + return + } + disks := parseDiskList(res.Stdout) + if len(disks) == 0 { + return + } + pterm.Warning.Printf("%d Persistent Disk(s) labeled for cluster %q survived the destroy (PVC-provisioned, outside terraform state):\n", len(disks), name) + for _, d := range disks { + pterm.Warning.Printf(" - %s\n", d) + } + pterm.Warning.Printf("Review and remove them with:\n gcloud compute disks list --project %s --filter=\"labels.goog-k8s-cluster-name=%s\"\n", project, name) +} + +// parseDiskList turns `gcloud compute disks list --format=value(...)` output +// into one entry per non-empty line. +func parseDiskList(out string) []string { + var disks []string + for _, line := range strings.Split(out, "\n") { + if s := strings.TrimSpace(line); s != "" { + disks = append(disks, s) + } + } + return disks +} diff --git a/internal/cluster/providers/gke/teardown_test.go b/internal/cluster/providers/gke/teardown_test.go new file mode 100644 index 00000000..c8ccb1ed --- /dev/null +++ b/internal/cluster/providers/gke/teardown_test.go @@ -0,0 +1,43 @@ +package gke + +import ( + "testing" + + corev1 "k8s.io/api/core/v1" +) + +func pv(ns string) corev1.PersistentVolume { + p := corev1.PersistentVolume{} + if ns != "" { + p.Spec.ClaimRef = &corev1.ObjectReference{Namespace: ns} + } + return p +} + +func TestCountClaimedPVs(t *testing.T) { + pvs := []corev1.PersistentVolume{ + pv("openframe"), // in scope + pv("openframe"), // in scope + pv("argocd"), // in scope + pv("kube-system"), // out of scope — must not be counted + pv(""), // unbound (no claimRef) — must not be counted + } + if got := countClaimedPVs(pvs, []string{"argocd", "openframe"}); got != 3 { + t.Fatalf("countClaimedPVs = %d, want 3 (only app-namespace claims)", got) + } + if got := countClaimedPVs(nil, []string{"openframe"}); got != 0 { + t.Fatalf("countClaimedPVs(nil) = %d, want 0", got) + } +} + +func TestParseDiskList(t *testing.T) { + // gcloud value() output: one disk per line, blank lines when there are none. + out := "pvc-abc\tus-central1-a\t20\npvc-def\tus-central1-b\t8\n\n" + got := parseDiskList(out) + if len(got) != 2 { + t.Fatalf("parseDiskList returned %d entries, want 2: %v", len(got), got) + } + if parseDiskList("") != nil || len(parseDiskList("\n \n")) != 0 { + t.Fatal("empty/whitespace gcloud output must yield no disks") + } +} diff --git a/internal/cluster/providers/gke/template.go b/internal/cluster/providers/gke/template.go index 57e422da..db6922fa 100644 --- a/internal/cluster/providers/gke/template.go +++ b/internal/cluster/providers/gke/template.go @@ -20,6 +20,10 @@ type tfvars struct { ClusterName string `json:"cluster_name"` Project string `json:"project"` Region string `json:"region"` + // Regional is always emitted (not omitempty): the template default is zonal, + // and dropping a false here would be correct-by-accident — keep it explicit. + Regional bool `json:"regional"` + Zone string `json:"zone,omitempty"` KubernetesVersion string `json:"kubernetes_version,omitempty"` InstanceType string `json:"instance_type,omitempty"` MinNodes int `json:"min_nodes,omitempty"` @@ -48,6 +52,8 @@ func tfvarsFor(config models.ClusterConfig) (tfvars, error) { ClusterName: config.Name, Project: cloud.Project, Region: cloud.Region, + Regional: cloud.HA, + Zone: cloud.Zone, KubernetesVersion: version, InstanceType: cloud.MachineType, MinNodes: cloud.MinNodes, diff --git a/internal/cluster/providers/gke/template_guard_test.go b/internal/cluster/providers/gke/template_guard_test.go index d4338ada..872c162c 100644 --- a/internal/cluster/providers/gke/template_guard_test.go +++ b/internal/cluster/providers/gke/template_guard_test.go @@ -2,6 +2,7 @@ package gke import ( "regexp" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -51,3 +52,29 @@ func TestTemplate_ProjectIsAnInputVariable(t *testing.T) { require.Contains(t, string(mainTF), `variable "project"`, "the project must be a declared input variable, not a resource the template creates") } + +// The GKE cluster's subnet/secondary ranges are passed to module.gke as string +// literals, not module.network outputs, so terraform builds no implicit edge +// from the cluster to the subnet. module.gke must therefore depend_on +// module.network explicitly — otherwise destroy can delete the subnet in +// parallel with the still-running node pool and GCP rejects it. This asserts +// the ordering edge stays in place. +func TestTemplate_GKEDependsOnNetworkForDestroyOrdering(t *testing.T) { + src := string(mainTF) + + // The literal wiring that removes the implicit edge (the reason the explicit + // depends_on is required). If these ever become module.network references, + // this guard can be revisited. + require.Contains(t, src, `subnetwork = "${var.cluster_name}-subnet"`, + "subnetwork is expected to be a string literal — the premise of the explicit dependency") + + depRE := regexp.MustCompile(`depends_on\s*=\s*\[([^\]]*)\]`) + var gkeDep string + for _, m := range depRE.FindAllStringSubmatch(src, -1) { + if strings.Contains(m[1], "module.network") { + gkeDep = m[1] + } + } + require.NotEmpty(t, gkeDep, + "module.gke must depend_on module.network so destroy tears the cluster down before the subnet") +} diff --git a/internal/cluster/providers/gke/template_validate_test.go b/internal/cluster/providers/gke/template_validate_test.go index 5b4d9ab5..a134c0fe 100644 --- a/internal/cluster/providers/gke/template_validate_test.go +++ b/internal/cluster/providers/gke/template_validate_test.go @@ -22,11 +22,13 @@ func TestTerraformValidate(t *testing.T) { t.Fatalf("the tfvalidate tag requires a terraform binary: %v", err) } + // A zonal config with a concrete zone — the default topology the provider + // produces (ensureZone fills Zone before scaffolding). vars, err := tfvarsFor(models.ClusterConfig{ Name: "validate-only", Type: models.ClusterTypeGKE, NodeCount: 3, - Cloud: &models.CloudConfig{Region: "us-central1", Project: "validate-only"}, + Cloud: &models.CloudConfig{Region: "us-central1", Project: "validate-only", Zone: "us-central1-a"}, }) if err != nil { t.Fatal(err) diff --git a/internal/cluster/providers/gke/templates/main.tf b/internal/cluster/providers/gke/templates/main.tf index 48c5f21b..174c8baa 100644 --- a/internal/cluster/providers/gke/templates/main.tf +++ b/internal/cluster/providers/gke/templates/main.tf @@ -19,6 +19,22 @@ variable "cluster_name" { type = string } variable "project" { type = string } variable "region" { type = string } +# regional=false (the default) builds a single-zone cluster in `zone`, so the +# node pool's counts are the real totals: desired_nodes nodes, not +# desired_nodes-per-zone. regional=true spreads the control plane and nodes +# across the region's zones for HA, at ~zones× the node count. +variable "regional" { + type = bool + default = false +} + +# The zone for a zonal (regional=false) cluster, e.g. "us-central1-a". Ignored +# when regional=true. The CLI fills this from the region. +variable "zone" { + type = string + default = "" +} + # Kubernetes . (e.g. "1.33"); empty means the GKE default. variable "kubernetes_version" { type = string @@ -129,7 +145,11 @@ module "gke" { project_id = var.project name = var.cluster_name region = var.region - regional = true + regional = var.regional + # Zonal clusters need their zone; a regional cluster ignores this and uses the + # region's default zone set. zones[0] is also the control-plane zone when + # zonal. + zones = var.regional ? [] : [var.zone] network = module.network.network_name subnetwork = "${var.cluster_name}-subnet" @@ -165,7 +185,14 @@ module "gke" { # Private nodes need the NAT for egress (image pulls) from the first boot — # nothing else creates an implicit ordering on it. - depends_on = [google_compute_router_nat.nat] + # + # module.network is listed explicitly because subnetwork/ip_range_pods/ + # ip_range_services are passed as string literals (not module outputs), so + # terraform would otherwise build NO edge from the cluster to the subnet. + # Without it, destroy can remove the subnet in parallel with the still-running + # node pool and GCP rejects it ("subnetwork in use by instance"); the explicit + # dependency makes the whole cluster tear down before any network resource. + depends_on = [google_compute_router_nat.nat, module.network] } output "cluster_name" { diff --git a/internal/cluster/service.go b/internal/cluster/service.go index 54994ea3..ab400e14 100644 --- a/internal/cluster/service.go +++ b/internal/cluster/service.go @@ -273,9 +273,14 @@ func (s *ClusterService) cloudProviders() []provider.Provider { // in the workspace registry. func (s *ClusterService) ListClusters() ([]models.ClusterInfo, error) { ctx := context.Background() + // k3d enumeration shells out to `k3d cluster list`, which needs a running + // Docker daemon. Treat its failure as best-effort (like the cloud loop + // below): a stopped Docker must not hide the cloud clusters. The warning + // goes to stderr so json/yaml output on stdout stays machine-clean. clusters, err := s.manager.ListAllClusters(ctx) if err != nil { - return nil, err + pterm.Warning.WithWriter(os.Stderr).Printf("local (k3d) clusters could not be listed (is Docker running?): %v\n", err) + clusters = nil } for _, cloud := range s.cloudProviders() { cloudClusters, err := cloud.ListAllClusters(ctx) diff --git a/internal/cluster/service_test.go b/internal/cluster/service_test.go index 15ec7c92..8da4d3b9 100644 --- a/internal/cluster/service_test.go +++ b/internal/cluster/service_test.go @@ -84,6 +84,25 @@ func TestClusterService_CreateCluster_CloudWithoutRegionFailsBeforeAnyCommand(t } } +// ListClusters must not hard-fail when local k3d enumeration fails (e.g. the +// Docker daemon is stopped): a cloud-only user must still be able to list and +// see status of their GKE/EKS clusters. The k3d error degrades to best-effort. +func TestClusterService_ListClusters_K3dFailureIsBestEffort(t *testing.T) { + t.Setenv("OPENFRAME_CLUSTERS_DIR", t.TempDir()) + mock := executor.NewMockCommandExecutor() + // Simulate Docker down: every k3d shell-out fails. + mock.SetShouldFail(true, "Cannot connect to the Docker daemon") + service := NewClusterServiceSuppressed(mock) + + clusters, err := service.ListClusters() + if err != nil { + t.Fatalf("ListClusters must degrade gracefully when k3d fails, got error: %v", err) + } + if len(clusters) != 0 { + t.Fatalf("expected no clusters (k3d down, empty cloud registry), got %d", len(clusters)) + } +} + func TestClusterService_DeleteCluster_UnknownCloudClusterIsNotFound(t *testing.T) { t.Setenv("OPENFRAME_CLUSTERS_DIR", t.TempDir()) for _, clusterType := range []models.ClusterType{models.ClusterTypeEKS, models.ClusterTypeGKE} { From 62a0c6129915f50d457dca13c2c24bac5a396b91 Mon Sep 17 00:00:00 2001 From: Oleg Tkachuk Date: Wed, 29 Jul 2026 12:05:07 +0300 Subject: [PATCH 30/38] =?UTF-8?q?fix(gke):=20actually=20release=20PVC=20di?= =?UTF-8?q?sks=20on=20delete=20=E2=80=94=20target=20the=20real=20namespace?= =?UTF-8?q?s=20(#267)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/cluster/providers/gke/provider.go | 7 +- internal/cluster/providers/gke/teardown.go | 251 ++++++++++++------ .../cluster/providers/gke/teardown_test.go | 119 +++++++-- 3 files changed, 271 insertions(+), 106 deletions(-) diff --git a/internal/cluster/providers/gke/provider.go b/internal/cluster/providers/gke/provider.go index bed39ebc..e0e5a0cb 100644 --- a/internal/cluster/providers/gke/provider.go +++ b/internal/cluster/providers/gke/provider.go @@ -378,9 +378,10 @@ func (p *Provider) DeleteCluster(ctx context.Context, name string, clusterType m } if recErr == nil { _ = removeFromDefaultKubeconfig(rec) - // Surface any PVC-provisioned disks that outlived the destroy so a - // "cleaned up" delete never silently leaves billable orphans. - p.reportOrphanedDisks(ctx, rec.Project, name) + // Sweep any PVC-provisioned disks that outlived the destroy: delete them + // when the caller consented to an unattended teardown (--force), else + // report them — a "cleaned up" delete must never silently leave orphans. + p.sweepOrphanedDisks(ctx, rec.Project, name, force) } return ws.Remove() } diff --git a/internal/cluster/providers/gke/teardown.go b/internal/cluster/providers/gke/teardown.go index 179840c2..a1303428 100644 --- a/internal/cluster/providers/gke/teardown.go +++ b/internal/cluster/providers/gke/teardown.go @@ -14,29 +14,97 @@ import ( "k8s.io/client-go/kubernetes" ) -// appNamespaces are the namespaces OpenFrame installs into. They are deleted -// (argocd first, so its controller stops re-syncing, then openframe) before the -// infra is destroyed, so the PVCs living in them are removed and the GKE CSI -// driver deletes their backing Persistent Disks while the cluster is still -// alive. Order matters — see releaseWorkloadDisks. -var appNamespaces = []string{"argocd", "openframe"} - -// diskDrainTimeout bounds how long a delete waits for PVC-backed disks to be -// reclaimed before proceeding to destroy anyway. A live-platform teardown must -// never block indefinitely; anything not drained in time is surfaced by the -// post-destroy sweep instead. -const diskDrainTimeout = 4 * time.Minute - -// releaseWorkloadDisks deletes the OpenFrame app namespaces on the cluster and -// waits (bounded) for their PVC-backed PersistentVolumes to drain, so the GKE -// CSI driver deletes the underlying Persistent Disks BEFORE terraform destroys -// the node pool. Without this, PVC-provisioned disks — which live outside the -// terraform state — detach and survive as billable orphans. +// systemNamespaces and systemNamespacePrefixes are never deleted during +// teardown. They are the cluster's own control-plane/system namespaces (torn +// down with the cluster anyway) and — critically — kube-system hosts the GKE PD +// CSI controller that must keep running to delete the Persistent Disks as their +// PVCs go away. +var systemNamespaces = map[string]struct{}{ + "default": {}, + "kube-system": {}, + "kube-public": {}, + "kube-node-lease": {}, +} + +var systemNamespacePrefixes = []string{"kube-", "gke-", "gmp-"} + +const ( + // diskDrainTimeout bounds how long a delete waits for PVC-backed disks to be + // reclaimed before proceeding to destroy anyway. A live-platform teardown + // must never block indefinitely; anything not drained in time is caught by + // the post-destroy sweep. + diskDrainTimeout = 5 * time.Minute + // kubeCallTimeout bounds each individual API call so an unreachable cluster + // fails fast instead of hanging the whole delete. + kubeCallTimeout = 20 * time.Second +) + +// isSystemNamespace reports whether ns is a cluster/system namespace that +// teardown must never delete. +func isSystemNamespace(ns string) bool { + if _, ok := systemNamespaces[ns]; ok { + return true + } + for _, p := range systemNamespacePrefixes { + if strings.HasPrefix(ns, p) { + return true + } + } + return false +} + +// appNamespacesToDelete returns the application namespaces (everything that is +// not a system namespace), with argocd first. OpenFrame's stateful services +// (Kafka, MongoDB, Cassandra, Pinot, … in the 'datasources' namespace) hold the +// PVCs whose backing disks must be released; deleting by discovery rather than a +// hardcoded list keeps this correct as the platform layout changes. argocd goes +// first so its controller stops re-syncing before the workloads it manages are +// deleted, otherwise self-heal could recreate a StatefulSet (and its PVC) +// mid-teardown. +func appNamespacesToDelete(all []string) []string { + var argocd []string + var rest []string + for _, ns := range all { + if isSystemNamespace(ns) { + continue + } + if ns == "argocd" { + argocd = append(argocd, ns) + } else { + rest = append(rest, ns) + } + } + return append(argocd, rest...) +} + +// countDeletablePVs counts PersistentVolumes whose reclaim policy is Delete. +// These are the volumes whose backing cloud disk the CSI driver removes once +// their PVC is gone, so the release step waits for this to reach zero. +// Retain-policy PVs are excluded on purpose — their disks are meant to survive, +// and the post-destroy sweep reports (never silently drops) them. +func countDeletablePVs(pvs []corev1.PersistentVolume) int { + var n int + for _, pv := range pvs { + if pv.Spec.PersistentVolumeReclaimPolicy == corev1.PersistentVolumeReclaimDelete { + n++ + } + } + return n +} + +// releaseWorkloadDisks deletes every application namespace on the cluster and +// waits (bounded) for the Delete-reclaim PersistentVolumes to drain, so the GKE +// CSI driver deletes the backing Persistent Disks BEFORE terraform destroys the +// node pool. Those disks are PVC-provisioned and live OUTSIDE the terraform +// state, so without this they detach and survive as billable orphans. +// +// Deleting whole namespaces (rather than draining nodes) also avoids the +// autoscaler racing to reschedule evicted pods onto a fresh node mid-teardown. // -// Entirely best-effort: an unreachable cluster, missing namespaces, RBAC -// denial, or a drain timeout are all logged and ignored. The infra destroy the -// user asked for must never be blocked by teardown, and the post-destroy sweep -// reports anything this misses. +// Entirely best-effort: an unreachable cluster, RBAC denial, or a drain timeout +// are logged and ignored. The infra destroy the user asked for must never be +// blocked by teardown, and the post-destroy sweep reports/cleans anything this +// misses. func releaseWorkloadDisks(ctx context.Context, rec tfengine.Record) { restCfg, err := restConfigFor(rec) if err != nil { @@ -49,95 +117,120 @@ func releaseWorkloadDisks(ctx context.Context, rec tfengine.Record) { return } - var deleting []string - for _, ns := range appNamespaces { - // Short per-call context so an unreachable API server fails fast rather - // than hanging the whole delete. - getCtx, cancel := context.WithTimeout(ctx, 15*time.Second) - _, err := client.CoreV1().Namespaces().Get(getCtx, ns, metav1.GetOptions{}) - cancel() - if err != nil { - continue // absent or unreachable — nothing to release here - } - delCtx, cancel := context.WithTimeout(ctx, 15*time.Second) - err = client.CoreV1().Namespaces().Delete(delCtx, ns, metav1.DeleteOptions{}) + listCtx, cancel := context.WithTimeout(ctx, kubeCallTimeout) + nsList, err := client.CoreV1().Namespaces().List(listCtx, metav1.ListOptions{}) + cancel() + if err != nil { + pterm.Debug.Printf("skip pre-destroy disk release (cluster unreachable): %v\n", err) + return + } + names := make([]string, 0, len(nsList.Items)) + for _, ns := range nsList.Items { + names = append(names, ns.Name) + } + targets := appNamespacesToDelete(names) + if len(targets) == 0 { + return // no application namespaces — nothing to release + } + + pterm.Info.Printf("Releasing persistent disks (removing namespaces: %s) before destroy...\n", strings.Join(targets, ", ")) + for _, ns := range targets { + delCtx, cancel := context.WithTimeout(ctx, kubeCallTimeout) + err := client.CoreV1().Namespaces().Delete(delCtx, ns, metav1.DeleteOptions{}) cancel() - if err == nil || k8serrors.IsNotFound(err) { - deleting = append(deleting, ns) + if err != nil && !k8serrors.IsNotFound(err) { + pterm.Debug.Printf("namespace %s delete: %v\n", ns, err) } } - if len(deleting) == 0 { - return // no OpenFrame workloads on the cluster; nothing to drain - } - pterm.Info.Printf("Releasing persistent disks (removing %s) before destroy...\n", strings.Join(deleting, ", ")) + // Wait for Delete-reclaim PVs to disappear — the signal that the CSI driver + // has deleted the backing disks. Bounded; on timeout we proceed and let the + // sweep handle the remainder. _ = wait.PollUntilContextTimeout(ctx, 5*time.Second, diskDrainTimeout, true, func(ctx context.Context) (bool, error) { - listCtx, cancel := context.WithTimeout(ctx, 15*time.Second) + pvCtx, cancel := context.WithTimeout(ctx, kubeCallTimeout) defer cancel() - pvs, err := client.CoreV1().PersistentVolumes().List(listCtx, metav1.ListOptions{}) + pvs, err := client.CoreV1().PersistentVolumes().List(pvCtx, metav1.ListOptions{}) if err != nil { return false, nil // transient — keep polling until the outer timeout } - return countClaimedPVs(pvs.Items, deleting) == 0, nil + return countDeletablePVs(pvs.Items) == 0, nil }) } -// countClaimedPVs counts PersistentVolumes still claimed by any of the given -// namespaces. Once a PVC is deleted its PV is released and (reclaimPolicy -// Delete) removed, so this reaching zero means the backing disks are gone. -func countClaimedPVs(pvs []corev1.PersistentVolume, namespaces []string) int { - inScope := make(map[string]struct{}, len(namespaces)) - for _, ns := range namespaces { - inScope[ns] = struct{}{} - } - var n int - for _, pv := range pvs { - ref := pv.Spec.ClaimRef - if ref == nil { - continue - } - if _, ok := inScope[ref.Namespace]; ok { - n++ - } - } - return n +// disk is one labeled Persistent Disk found by the post-destroy sweep. +type disk struct { + name string + zone string // basename, e.g. "us-central1-a"; empty for a regional disk } -// reportOrphanedDisks lists any Persistent Disks still labeled for this cluster -// after the destroy and prints them with a cleanup command. It is report-only: -// the CLI never deletes cloud disks directly, so a user is told the truth -// ("these survived") instead of a false "fully cleaned up". Best-effort — a -// gcloud hiccup is silently skipped. -func (p *Provider) reportOrphanedDisks(ctx context.Context, project, name string) { +// sweepOrphanedDisks finds Persistent Disks still labeled for this cluster after +// the destroy. Post-destroy they are unambiguous orphans of a cluster that no +// longer exists. When the caller consented to an unattended teardown (force), +// it deletes them so the cluster leaves zero billable leftovers; otherwise it +// reports them with the exact cleanup command and never deletes cloud data +// without consent. Best-effort throughout. +func (p *Provider) sweepOrphanedDisks(ctx context.Context, project, name string, force bool) { if project == "" { return } res, err := p.executor.Execute(ctx, "gcloud", "compute", "disks", "list", "--project", project, "--filter", "labels.goog-k8s-cluster-name="+name, - "--format=value(name,location,sizeGb)") + "--format=value(name,zone.basename())") if err != nil || res == nil { return } - disks := parseDiskList(res.Stdout) + disks := parseDisks(res.Stdout) if len(disks) == 0 { return } - pterm.Warning.Printf("%d Persistent Disk(s) labeled for cluster %q survived the destroy (PVC-provisioned, outside terraform state):\n", len(disks), name) + + if !force { + pterm.Warning.Printf("%d Persistent Disk(s) labeled for cluster %q survived the destroy (PVC-provisioned, outside terraform state):\n", len(disks), name) + for _, d := range disks { + pterm.Warning.Printf(" - %s\n", d.name) + } + pterm.Warning.Printf("They are orphans of the now-deleted cluster. Remove them with:\n"+ + " gcloud compute disks list --project %s --filter=\"labels.goog-k8s-cluster-name=%s\"\n"+ + " (or re-run delete with --force to have them cleaned up automatically)\n", project, name) + return + } + + pterm.Info.Printf("Cleaning up %d orphaned Persistent Disk(s) from cluster %q...\n", len(disks), name) + var failed []string for _, d := range disks { - pterm.Warning.Printf(" - %s\n", d) + if d.zone == "" { + failed = append(failed, d.name+" (regional — delete manually)") + continue + } + if _, err := p.executor.Execute(ctx, "gcloud", "compute", "disks", "delete", d.name, + "--zone", d.zone, "--project", project, "--quiet"); err != nil { + failed = append(failed, d.name) + } } - pterm.Warning.Printf("Review and remove them with:\n gcloud compute disks list --project %s --filter=\"labels.goog-k8s-cluster-name=%s\"\n", project, name) + if len(failed) > 0 { + pterm.Warning.Printf("Could not delete %d disk(s): %s\n Remove manually: gcloud compute disks delete --zone --project %s\n", + len(failed), strings.Join(failed, ", "), project) + return + } + pterm.Success.Printf("Removed %d orphaned Persistent Disk(s)\n", len(disks)) } -// parseDiskList turns `gcloud compute disks list --format=value(...)` output -// into one entry per non-empty line. -func parseDiskList(out string) []string { - var disks []string +// parseDisks turns `gcloud compute disks list +// --format=value(name,zone.basename())` output into disks — one per non-empty +// line, fields separated by whitespace (name, then optional zone). +func parseDisks(out string) []disk { + var disks []disk for _, line := range strings.Split(out, "\n") { - if s := strings.TrimSpace(line); s != "" { - disks = append(disks, s) + if strings.TrimSpace(line) == "" { + continue + } + fields := strings.Fields(line) + d := disk{name: fields[0]} + if len(fields) > 1 { + d.zone = fields[1] } + disks = append(disks, d) } return disks } diff --git a/internal/cluster/providers/gke/teardown_test.go b/internal/cluster/providers/gke/teardown_test.go index c8ccb1ed..b59270ba 100644 --- a/internal/cluster/providers/gke/teardown_test.go +++ b/internal/cluster/providers/gke/teardown_test.go @@ -1,43 +1,114 @@ package gke import ( + "context" "testing" + "github.com/flamingo-stack/openframe-cli/internal/shared/executor" corev1 "k8s.io/api/core/v1" ) -func pv(ns string) corev1.PersistentVolume { - p := corev1.PersistentVolume{} - if ns != "" { - p.Spec.ClaimRef = &corev1.ObjectReference{Namespace: ns} +func TestAppNamespacesToDelete(t *testing.T) { + all := []string{ + "kube-system", "kube-public", "kube-node-lease", "default", // system — skipped + "gke-managed-system", "gmp-system", // GKE-managed — skipped + "tenant", "datasources", "platform", "argocd", // app — kept + } + got := appNamespacesToDelete(all) + + // argocd must come first so its controller stops re-syncing before its + // managed workloads (and their PVCs) are deleted. + if len(got) == 0 || got[0] != "argocd" { + t.Fatalf("argocd must be deleted first, got %v", got) + } + want := map[string]bool{"argocd": true, "datasources": true, "platform": true, "tenant": true} + if len(got) != len(want) { + t.Fatalf("expected exactly the app namespaces, got %v", got) + } + for _, ns := range got { + if !want[ns] { + t.Fatalf("system namespace %q must never be deleted; got %v", ns, got) + } } - return p } -func TestCountClaimedPVs(t *testing.T) { - pvs := []corev1.PersistentVolume{ - pv("openframe"), // in scope - pv("openframe"), // in scope - pv("argocd"), // in scope - pv("kube-system"), // out of scope — must not be counted - pv(""), // unbound (no claimRef) — must not be counted +func TestIsSystemNamespace(t *testing.T) { + for _, ns := range []string{"kube-system", "kube-public", "default", "gke-managed-cim", "gmp-public"} { + if !isSystemNamespace(ns) { + t.Errorf("%q must be treated as a system namespace", ns) + } } - if got := countClaimedPVs(pvs, []string{"argocd", "openframe"}); got != 3 { - t.Fatalf("countClaimedPVs = %d, want 3 (only app-namespace claims)", got) + for _, ns := range []string{"datasources", "tenant", "argocd", "platform"} { + if isSystemNamespace(ns) { + t.Errorf("%q is an app namespace, not system", ns) + } + } +} + +func TestCountDeletablePVs(t *testing.T) { + pvs := []corev1.PersistentVolume{ + {Spec: corev1.PersistentVolumeSpec{PersistentVolumeReclaimPolicy: corev1.PersistentVolumeReclaimDelete}}, + {Spec: corev1.PersistentVolumeSpec{PersistentVolumeReclaimPolicy: corev1.PersistentVolumeReclaimDelete}}, + {Spec: corev1.PersistentVolumeSpec{PersistentVolumeReclaimPolicy: corev1.PersistentVolumeReclaimRetain}}, // not counted } - if got := countClaimedPVs(nil, []string{"openframe"}); got != 0 { - t.Fatalf("countClaimedPVs(nil) = %d, want 0", got) + if got := countDeletablePVs(pvs); got != 2 { + t.Fatalf("countDeletablePVs = %d, want 2 (Retain excluded)", got) } } -func TestParseDiskList(t *testing.T) { - // gcloud value() output: one disk per line, blank lines when there are none. - out := "pvc-abc\tus-central1-a\t20\npvc-def\tus-central1-b\t8\n\n" - got := parseDiskList(out) - if len(got) != 2 { - t.Fatalf("parseDiskList returned %d entries, want 2: %v", len(got), got) +func TestParseDisks(t *testing.T) { + out := "pvc-abc\tus-central1-a\npvc-def\tus-central1-b\nregional-disk\n\n" + got := parseDisks(out) + if len(got) != 3 { + t.Fatalf("parseDisks returned %d, want 3: %+v", len(got), got) } - if parseDiskList("") != nil || len(parseDiskList("\n \n")) != 0 { - t.Fatal("empty/whitespace gcloud output must yield no disks") + if got[0].name != "pvc-abc" || got[0].zone != "us-central1-a" { + t.Fatalf("first disk = %+v", got[0]) } + if got[2].name != "regional-disk" || got[2].zone != "" { + t.Fatalf("zoneless disk must parse with empty zone, got %+v", got[2]) + } + if len(parseDisks("")) != 0 { + t.Fatal("empty output must yield no disks") + } +} + +// The post-destroy sweep must only delete cloud disks when the caller consented +// to an unattended teardown (--force); otherwise it reports and touches nothing. +func TestSweepOrphanedDisks_ConsentGating(t *testing.T) { + listResp := &executor.CommandResult{ExitCode: 0, Stdout: "pvc-abc\tus-central1-a\n"} + + t.Run("no force: reports, never deletes", func(t *testing.T) { + p, _, _ := newTestProvider(t, nil) + mock := executor.NewMockCommandExecutor() + mock.SetResponse("gcloud compute disks list", listResp) + p.executor = mock + + p.sweepOrphanedDisks(context.Background(), "proj", "demo", false) + if mock.WasCommandExecuted("gcloud compute disks delete") { + t.Fatal("without --force the sweep must NOT delete disks") + } + }) + + t.Run("force: deletes the labeled orphan", func(t *testing.T) { + p, _, _ := newTestProvider(t, nil) + mock := executor.NewMockCommandExecutor() + mock.SetResponse("gcloud compute disks list", listResp) + p.executor = mock + + p.sweepOrphanedDisks(context.Background(), "proj", "demo", true) + if !mock.WasCommandExecuted("gcloud compute disks delete pvc-abc --zone us-central1-a") { + t.Fatalf("with --force the sweep must delete the orphan; ran: %v", mock.GetExecutedCommands()) + } + }) + + t.Run("empty project is a no-op", func(t *testing.T) { + p, _, _ := newTestProvider(t, nil) + mock := executor.NewMockCommandExecutor() + p.executor = mock + p.sweepOrphanedDisks(context.Background(), "", "demo", true) + if mock.GetCommandCount() != 0 { + t.Fatal("no project → no gcloud calls") + } + }) } From 85c7c15f11b9cf079785edace4882b196008d6ee Mon Sep 17 00:00:00 2001 From: Oleg Tkachuk Date: Wed, 29 Jul 2026 12:21:35 +0300 Subject: [PATCH 31/38] fix(cluster): stop showing k3d-specific details for GKE clusters (#268) --- cmd/cluster/delete.go | 2 +- internal/cluster/discovery/auth.go | 24 ++++++++++- internal/cluster/discovery/auth_test.go | 36 ++++++++++++++++ internal/cluster/service.go | 56 ++++++++++++++++--------- internal/cluster/ui/operations.go | 25 ++++++----- internal/cluster/ui/operations_test.go | 16 +++++-- 6 files changed, 125 insertions(+), 34 deletions(-) diff --git a/cmd/cluster/delete.go b/cmd/cluster/delete.go index c986f8f5..8275a262 100644 --- a/cmd/cluster/delete.go +++ b/cmd/cluster/delete.go @@ -121,6 +121,6 @@ func runDeleteCluster(cmd *cobra.Command, args []string) error { } // Show friendly success message - operationsUI.ShowOperationSuccess("delete", clusterName) + operationsUI.ShowOperationSuccess("delete", clusterName, clusterType) return nil } diff --git a/internal/cluster/discovery/auth.go b/internal/cluster/discovery/auth.go index f4e9c0cb..2eb8a8bb 100644 --- a/internal/cluster/discovery/auth.go +++ b/internal/cluster/discovery/auth.go @@ -66,12 +66,25 @@ func (f *AuthFlow) Ensure(ctx context.Context, requireADC bool) error { case NotAuthenticated: if err := f.login(ctx, "Google Cloud login required. Log in now (opens a browser)?", []string{"auth", "login"}, - func() bool { return d.AuthStatus(ctx) == Authenticated }, + func() bool { return f.hasFreshLogin(ctx) }, "gcloud is not authenticated — run 'gcloud auth login'"); err != nil { return err } } + // A listed ACTIVE account can still have an EXPIRED token — `gcloud auth + // list` reports it as active regardless. Force a refresh here so an expired + // primary login is caught up front with a clear hint, instead of surfacing + // later as a raw gcloud error (after the ADC step, or mid-terraform). + if !f.hasFreshLogin(ctx) { + if err := f.login(ctx, "Google Cloud login has expired. Log in again now (opens a browser)?", + []string{"auth", "login"}, + func() bool { return f.hasFreshLogin(ctx) }, + "gcloud login has expired — run 'gcloud auth login'"); err != nil { + return err + } + } + if !requireADC { return nil } @@ -90,6 +103,15 @@ func (f *AuthFlow) hasADC(ctx context.Context) bool { return err == nil && result != nil && strings.TrimSpace(result.Stdout) != "" } +// hasFreshLogin reports whether the primary gcloud login can actually mint a +// token — present AND not expired. `gcloud auth print-access-token` forces a +// refresh and fails on an expired login, unlike `gcloud auth list` (which +// reports an expired account as still ACTIVE). +func (f *AuthFlow) hasFreshLogin(ctx context.Context) bool { + result, err := f.exec.Execute(ctx, "gcloud", "auth", "print-access-token", "--quiet") + return err == nil && result != nil && strings.TrimSpace(result.Stdout) != "" +} + // login runs one interactive login step: prompt → run → re-verify. func (f *AuthFlow) login(ctx context.Context, prompt string, args []string, verified func() bool, manualHint string) error { if !f.interactive() { diff --git a/internal/cluster/discovery/auth_test.go b/internal/cluster/discovery/auth_test.go index 397b211f..38eb45da 100644 --- a/internal/cluster/discovery/auth_test.go +++ b/internal/cluster/discovery/auth_test.go @@ -37,7 +37,10 @@ func newAuthHarness(t *testing.T, interactive bool) *authFlowHarness { if strings.Contains(joined, "application-default") { h.mock.SetResponse("application-default print-access-token", &executor.CommandResult{ExitCode: 0, Stdout: "ya29.token\n"}) } else { + // A fresh `auth login` makes the account listed AND able to mint a + // token (the freshness probe). h.mock.SetResponse("gcloud auth list", &executor.CommandResult{ExitCode: 0, Stdout: "me@example.com\n"}) + h.mock.SetResponse("gcloud auth print-access-token", &executor.CommandResult{ExitCode: 0, Stdout: "ya29.token\n"}) } return nil }) @@ -46,11 +49,13 @@ func newAuthHarness(t *testing.T, interactive bool) *authFlowHarness { func (h *authFlowHarness) loggedOut() { h.mock.SetResponse("gcloud auth list", &executor.CommandResult{ExitCode: 0, Stdout: ""}) + h.mock.SetResponse("gcloud auth print-access-token", &executor.CommandResult{ExitCode: 1, Stderr: "reauth required"}) h.mock.SetResponse("application-default print-access-token", &executor.CommandResult{ExitCode: 1, Stderr: "no credentials"}) } func (h *authFlowHarness) loggedIn(withADC bool) { h.mock.SetResponse("gcloud auth list", &executor.CommandResult{ExitCode: 0, Stdout: "me@example.com\n"}) + h.mock.SetResponse("gcloud auth print-access-token", &executor.CommandResult{ExitCode: 0, Stdout: "ya29.token\n"}) if withADC { h.mock.SetResponse("application-default print-access-token", &executor.CommandResult{ExitCode: 0, Stdout: "ya29.token\n"}) } else { @@ -58,6 +63,15 @@ func (h *authFlowHarness) loggedIn(withADC bool) { } } +// primaryExpired models the M6 case: the account is listed ACTIVE but its token +// is expired, so `gcloud auth list` still shows it while `print-access-token` +// fails. +func (h *authFlowHarness) primaryExpired() { + h.mock.SetResponse("gcloud auth list", &executor.CommandResult{ExitCode: 0, Stdout: "me@example.com\n"}) + h.mock.SetResponse("gcloud auth print-access-token", &executor.CommandResult{ExitCode: 1, Stderr: "reauth required"}) + h.mock.SetResponse("application-default print-access-token", &executor.CommandResult{ExitCode: 0, Stdout: "ya29.token\n"}) +} + func TestAuthFlow_AlreadyAuthenticatedIsSilent(t *testing.T) { h := newAuthHarness(t, true) h.loggedIn(true) @@ -91,6 +105,28 @@ func TestAuthFlow_ADCOnlyWhenCLICredsPresent(t *testing.T) { assert.Equal(t, []string{"auth application-default login"}, h.logins) } +// M6: an expired primary login (listed ACTIVE but no mintable token) must be +// caught and re-logged-in BEFORE the ADC step, not surface later as a raw error. +func TestAuthFlow_ExpiredPrimaryLoginReLogsIn(t *testing.T) { + h := newAuthHarness(t, true) + h.primaryExpired() + + require.NoError(t, h.flow.Ensure(context.Background(), true)) + assert.Equal(t, []string{"auth login"}, h.logins, + "expired primary login must trigger re-login, and before any ADC step") +} + +func TestAuthFlow_ExpiredPrimaryNonInteractiveErrors(t *testing.T) { + h := newAuthHarness(t, false) + h.primaryExpired() + + err := h.flow.Ensure(context.Background(), true) + require.Error(t, err) + assert.Contains(t, err.Error(), "expired", "the error must name the expired login, with the gcloud auth login hint") + assert.Contains(t, err.Error(), "gcloud auth login") + assert.Empty(t, h.logins) +} + func TestAuthFlow_NonInteractiveNeverPrompts(t *testing.T) { h := newAuthHarness(t, false) h.loggedOut() diff --git a/internal/cluster/service.go b/internal/cluster/service.go index ab400e14..3b2ddcc8 100644 --- a/internal/cluster/service.go +++ b/internal/cluster/service.go @@ -216,7 +216,7 @@ func (s *ClusterService) CreateCluster(ctx context.Context, config models.Cluste } // Show next steps - s.showNextSteps(config.Name) + s.showNextSteps(config.Name, config.Type) return restConfig, nil } @@ -728,8 +728,11 @@ func (s *ClusterService) isK3dWorkerNode(nodeName, clusterName string) bool { // when the port is taken (see providers/k3d/ports.go). So on any machine with // a second cluster the box pointed the user at a different cluster's API // server. The kubeconfig records the port that was actually bound. -func (s *ClusterService) apiServerEndpoint(ctx context.Context, name string) string { - cfg, err := s.manager.GetRestConfig(ctx, name) +func (s *ClusterService) apiServerEndpoint(name string) string { + // Resolve via the cluster-type-aware path (cloud registry first, then k3d), + // not the k3d manager directly — otherwise a GKE cluster's endpoint always + // read back empty and the box printed "(unknown — kubeconfig not readable)". + cfg, err := s.GetRestConfig(name) if err != nil || cfg == nil { return "" } @@ -754,16 +757,20 @@ func (s *ClusterService) displayClusterCreationSummary(info models.ClusterInfo) "NAME: %s\n"+ "TYPE: %s\n"+ "STATUS: %s\n"+ - "NODES: %d\n"+ - "NETWORK: k3d-%s\n"+ - "%s", + "NODES: %d", pterm.Bold.Sprint(info.Name), strings.ToUpper(string(info.Type)), pterm.Green("Ready"), info.NodeCount, - info.Name, - apiServerLine(s.apiServerEndpoint(context.Background(), info.Name)), ) + // The k3d- Docker network is k3d-specific; a cloud cluster shows its + // region instead so the box never invents a k3d network for GKE/EKS. + if info.Type == models.ClusterTypeK3d { + boxContent += fmt.Sprintf("\nNETWORK: k3d-%s", info.Name) + } else if info.Region != "" { + boxContent += fmt.Sprintf("\nREGION: %s", info.Region) + } + boxContent += "\n" + apiServerLine(s.apiServerEndpoint(info.Name)) pterm.DefaultBox. WithTitle(" ✅ Cluster Created "). @@ -772,7 +779,7 @@ func (s *ClusterService) displayClusterCreationSummary(info models.ClusterInfo) } // showNextSteps displays clean next steps after cluster creation -func (s *ClusterService) showNextSteps(clusterName string) { +func (s *ClusterService) showNextSteps(clusterName string, clusterType models.ClusterType) { // Skip showing next steps if UI is suppressed (e.g., during bootstrap) if s.suppressUI { return @@ -780,7 +787,14 @@ func (s *ClusterService) showNextSteps(clusterName string) { pterm.DefaultBasicText.Println() pterm.Info.Printf("🚀 Next Steps:\n") - pterm.DefaultBasicText.Printf(" 1. Bootstrap platform: openframe bootstrap\n") + // k3d bootstraps the whole platform in one shot; a cloud cluster already + // exists, so the next step is installing the platform onto it (per the GKE + // guide) — 'openframe bootstrap' would try to build a local cluster. + if clusterType == models.ClusterTypeK3d { + pterm.DefaultBasicText.Printf(" 1. Bootstrap platform: openframe bootstrap\n") + } else { + pterm.DefaultBasicText.Printf(" 1. Install the platform: openframe app install\n") + } pterm.DefaultBasicText.Printf(" 2. Check cluster nodes: kubectl get nodes\n") pterm.DefaultBasicText.Printf(" 3. View cluster status: openframe cluster status %s\n", clusterName) pterm.DefaultBasicText.Printf(" 4. View running pods: kubectl get pods -A\n") @@ -869,23 +883,23 @@ func (s *ClusterService) displayDetailedClusterStatus(status models.ClusterInfo, } } - endpoint := s.apiServerEndpoint(context.Background(), status.Name) + endpoint := s.apiServerEndpoint(status.Name) boxContent := fmt.Sprintf( "NAME: %s\n"+ "TYPE: %s\n"+ "STATUS: %s\n"+ - "NODES: %d\n"+ - "NETWORK: k3d-%s\n"+ - "%s\n"+ - "AGE: %s", + "NODES: %d", pterm.Bold.Sprint(status.Name), strings.ToUpper(string(status.Type)), statusDisplay, status.NodeCount, - status.Name, - apiServerLine(endpoint), - ageStr, ) + if status.Type == models.ClusterTypeK3d { + boxContent += fmt.Sprintf("\nNETWORK: k3d-%s", status.Name) + } else if status.Region != "" { + boxContent += fmt.Sprintf("\nREGION: %s", status.Region) + } + boxContent += fmt.Sprintf("\n%s\nAGE: %s", apiServerLine(endpoint), ageStr) pterm.DefaultBox. WithTitle(" 📊 Cluster Status "). @@ -895,7 +909,11 @@ func (s *ClusterService) displayDetailedClusterStatus(status models.ClusterInfo, // Network information pterm.DefaultBasicText.Println() pterm.Info.Printf("🌐 Network Information:\n") - pterm.DefaultBasicText.Printf(" Network: k3d-%s\n", status.Name) + if status.Type == models.ClusterTypeK3d { + pterm.DefaultBasicText.Printf(" Network: k3d-%s\n", status.Name) + } else if status.Region != "" { + pterm.DefaultBasicText.Printf(" Region: %s\n", status.Region) + } if endpoint != "" { pterm.DefaultBasicText.Printf(" API Server: %s\n", endpoint) } diff --git a/internal/cluster/ui/operations.go b/internal/cluster/ui/operations.go index ae213579..c6f2ce2e 100644 --- a/internal/cluster/ui/operations.go +++ b/internal/cluster/ui/operations.go @@ -271,8 +271,10 @@ func (ui *OperationsUI) ShowCleanupSummary(clusterName string, result models.Cle } } -// ShowOperationSuccess displays a friendly success message -func (ui *OperationsUI) ShowOperationSuccess(operation, clusterName string) { +// ShowOperationSuccess displays a friendly success message. clusterType lets +// the box tell the truth per backend — a GKE delete must not claim "TYPE: k3d" +// or "Docker containers cleaned up". +func (ui *OperationsUI) ShowOperationSuccess(operation, clusterName string, clusterType models.ClusterType) { switch strings.ToLower(operation) { case "delete": pterm.Success.Printf("Cluster '%s' deleted successfully\n", pterm.Cyan(clusterName)) @@ -283,12 +285,10 @@ func (ui *OperationsUI) ShowOperationSuccess(operation, clusterName string) { "NAME: %s\n"+ "TYPE: %s\n"+ "STATUS: %s\n"+ - "NETWORK: %s\n"+ "RESOURCES: %s", pterm.Bold.Sprint(clusterName), - "k3d", + strings.ToUpper(string(clusterType)), pterm.Red("Deleted"), - pterm.Gray("Removed"), pterm.Gray("Cleaned up"), ) @@ -297,13 +297,18 @@ func (ui *OperationsUI) ShowOperationSuccess(operation, clusterName string) { WithTitleTopCenter(). Println(boxContent) - // Show deletion summary + // Show deletion summary — the mechanics differ per backend. pterm.DefaultBasicText.Println() pterm.Info.Printf("Deletion Summary:\n") - pterm.DefaultBasicText.Printf(" Cluster and nodes removed\n") - pterm.DefaultBasicText.Printf(" Docker containers cleaned up\n") - pterm.DefaultBasicText.Printf(" Network configuration removed\n") - pterm.DefaultBasicText.Printf(" Kubeconfig entries cleaned\n") + if clusterType == models.ClusterTypeK3d { + pterm.DefaultBasicText.Printf(" Cluster and nodes removed\n") + pterm.DefaultBasicText.Printf(" Docker containers cleaned up\n") + pterm.DefaultBasicText.Printf(" Network configuration removed\n") + pterm.DefaultBasicText.Printf(" Kubeconfig entries cleaned\n") + } else { + pterm.DefaultBasicText.Printf(" Cloud infrastructure destroyed (terraform)\n") + pterm.DefaultBasicText.Printf(" Kubeconfig entries cleaned\n") + } default: pterm.Success.Printf("Operation '%s' completed for cluster '%s'\n", operation, pterm.Cyan(clusterName)) diff --git a/internal/cluster/ui/operations_test.go b/internal/cluster/ui/operations_test.go index 299b3b98..71f873ce 100644 --- a/internal/cluster/ui/operations_test.go +++ b/internal/cluster/ui/operations_test.go @@ -119,14 +119,24 @@ func TestOperationsUI_ShowOperationSuccess(t *testing.T) { // "cleanup" is deliberately absent: cleanup reports through // ShowCleanupSummary, which prints the counts the run actually produced // rather than a fixed list of accomplishments. - t.Run("shows delete success without panicking", func(t *testing.T) { + t.Run("shows k3d delete success without panicking", func(t *testing.T) { defer func() { if r := recover(); r != nil { t.Errorf("ShowOperationSuccess panicked: %v", r) } }() - ui.ShowOperationSuccess("delete", "test-cluster") + ui.ShowOperationSuccess("delete", "test-cluster", models.ClusterTypeK3d) + }) + + t.Run("shows gke delete success without panicking", func(t *testing.T) { + defer func() { + if r := recover(); r != nil { + t.Errorf("ShowOperationSuccess panicked: %v", r) + } + }() + + ui.ShowOperationSuccess("delete", "test-cluster", models.ClusterTypeGKE) }) t.Run("shows generic success without panicking", func(t *testing.T) { @@ -136,7 +146,7 @@ func TestOperationsUI_ShowOperationSuccess(t *testing.T) { } }() - ui.ShowOperationSuccess("unknown", "test-cluster") + ui.ShowOperationSuccess("unknown", "test-cluster", models.ClusterTypeK3d) }) } From fa66ebe0cf1f4df5449b8df8a5653999717d190a Mon Sep 17 00:00:00 2001 From: Oleg Tkachuk Date: Wed, 29 Jul 2026 12:42:42 +0300 Subject: [PATCH 32/38] fix(version): identify dev builds from the embedded VCS stamp (#271) --- cmd/root.go | 53 +++++++++++++++++++++++++++++--- cmd/root_test.go | 46 +++++++++++++++++++++++++-- internal/shared/errors/errors.go | 4 ++- 3 files changed, 96 insertions(+), 7 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index de4dc128..babaca0d 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "os/signal" + "runtime/debug" "syscall" "github.com/flamingo-stack/openframe-cli/cmd/app" @@ -40,10 +41,54 @@ var ( // DefaultVersionInfo provides default version information, populated from the // build-time vars above (overridden via -ldflags -X at release time). -var DefaultVersionInfo = VersionInfo{ - Version: version, - Commit: commit, - Date: date, +var DefaultVersionInfo = resolveVersionInfo(version, commit, date) + +// resolveVersionInfo returns the build-time version metadata. On a dev build — +// where the release-time -ldflags were not applied, so commit is "none" and +// date "unknown" — it backfills them from the VCS stamp Go embeds in every +// `go build`/`go install`/`make build` binary, so the build is identifiable +// ("dev (85c7c15f11b9) built on