Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ Cluster lifecycle:

```bash
openframe cluster create dev --type k3d --nodes 1 --skip-wizard
openframe cluster create my-eks --type eks --region us-east-1 --skip-wizard # cloud (billed!)
openframe cluster create my-eks --type eks --region us-east-1 --skip-wizard # EKS creation currently gated (coming soon), does not create billed resources
openframe cluster list # add -o json|yaml for scripts
openframe cluster status dev
openframe cluster delete dev --force
Expand Down
13 changes: 7 additions & 6 deletions cmd/cluster/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,14 @@ By default, shows a selection menu where you can choose:
1. Quick start with defaults (press Enter) - creates cluster with default settings
2. Interactive configuration wizard - step-by-step cluster customization

Creates a local k3d cluster or a cloud EKS cluster for OpenFrame. If a cluster
Creates a local k3d cluster or a cloud GKE cluster for OpenFrame. If a cluster
with the same name already exists it is left untouched and reused — delete it
first to start from scratch. Use the bootstrap command to install OpenFrame
components after creation.
components after creation. EKS cluster creation is temporarily disabled and
will be available soon.

EKS clusters are provisioned with Terraform (installed automatically) and
create AWS resources that incur costs; the workspace and state live under
GKE clusters are provisioned with Terraform (installed automatically) and
create GCP resources that incur costs; the workspace and state live under
~/.openframe/clusters/<name>. A failed create can be re-run to resume, or
torn down with 'openframe cluster delete'.

Expand All @@ -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()
Expand Down Expand Up @@ -300,7 +301,7 @@ func runCreateCluster(cmd *cobra.Command, args []string) error {
// creates are gated.
if config.Type == models.ClusterTypeEKS {
showEKSComingSoonBanner()
return nil
return fmt.Errorf("EKS cluster creation is not yet available")
}

// Show configuration summary for dry-run or skip-wizard modes
Expand Down
2 changes: 1 addition & 1 deletion cmd/cluster/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ func discoverExternalClusters(ctx context.Context, managed []models.ClusterInfo)

isManaged := func(c models.ClusterInfo) bool {
for _, m := range managed {
if m.Name == c.Name && (m.Project == "" || m.Project == c.Project) {
if m.Name == c.Name && m.Project == c.Project {
return true
}
}
Expand Down
2 changes: 1 addition & 1 deletion cmd/cluster/use.go
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ func useExternalGKE(ctx context.Context, exec executor.CommandExecutor, kubeconf
// No kubeconfig entry yet — fetch credentials (adds the gke_* context).
pterm.Info.Printf("Fetching credentials for '%s' (project %s, %s)...\n", name, found.Project, found.Region)
if _, err := exec.Execute(ctx, "gcloud", "container", "clusters", "get-credentials", name,
"--project", found.Project, "--region", found.Region); err != nil {
"--project", found.Project, "--location", found.Region); err != nil {
return fmt.Errorf("could not fetch credentials for '%s' (for private clusters try 'gcloud container fleet memberships get-credentials %s'): %w", name, name, err)
}
contextName = fmt.Sprintf("gke_%s_%s_%s", found.Project, found.Region, name)
Expand Down
4 changes: 1 addition & 3 deletions internal/cluster/prerequisites/aws/aws.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,7 @@ func (a *AwsInstaller) installLinux() error {
}
managers := []pm{
{"apt", []string{"apt", "install", "-y", "awscli"}},
{"dnf", []string{"dnf", "install", "-y", "awscli"}},
{"yum", []string{"yum", "install", "-y", "awscli"}},
{"pacman", []string{"pacman", "-S", "--noconfirm", "aws-cli"}},
{"pacman", []string{"pacman", "-S", "--noconfirm", "aws-cli-v2"}},
}
for _, m := range managers {
if !commandExists(m.name) {
Expand Down
10 changes: 5 additions & 5 deletions internal/cluster/provider/provider.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
// Package provider defines the unified cluster-provider abstraction.
//
// A Provider creates and manages Kubernetes clusters. Today only k3d (local) is
// implemented; for the recognized cloud types (GKE, EKS) the factory returns
// ErrProviderNotFound until their backends land. New backends implement the
// same Provider interface, so the rest of the CLI never needs to know which
// backend is used.
// A Provider creates and manages Kubernetes clusters. k3d (local), EKS, and
// GKE are implemented and routed to their respective backends (k3d.New,
// eks.New, gke.New) through the factory. New backends implement the same
// Provider interface, so the rest of the CLI never needs to know which backend
// is used.
package provider

import (
Expand Down
22 changes: 22 additions & 0 deletions internal/cluster/providers/eks/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,25 @@ func (p *Provider) preflightCredentials(ctx context.Context, profile string) err
return nil
}

// preflightNameCollision refuses to create a cluster whose name already
// exists in the target region but has no openframe workspace: terraform would
// fail mid-apply on the duplicate cluster, leaving partial billed
// infrastructure. External clusters are strictly not ours to touch — the user
// must pick another name.
func (p *Provider) preflightNameCollision(ctx context.Context, region, profile, name string) error {
args := []string{"eks", "describe-cluster", "--name", name, "--region", region, "--output", "json"}
if profile != "" {
args = append(args, "--profile", profile)
}
result, err := p.executor.Execute(ctx, "aws", args...)
if err != nil || result == nil {
return nil // cluster does not exist or call failed — proceed
}
// If describe-cluster succeeds, the cluster exists
return fmt.Errorf("cluster '%s' already exists in region '%s' but is not managed by openframe — refusing to touch it; pick another cluster name",
name, region)
}

// backendTF renders the s3 backend block for an EKS workspace.
func backendTF(cfg tfengine.BackendConfig, region string) []byte {
key := "terraform.tfstate"
Expand Down Expand Up @@ -150,6 +169,9 @@ func (p *Provider) CreateCluster(ctx context.Context, config models.ClusterConfi
if err != nil {
return nil, err
}
if err := p.preflightNameCollision(ctx, config.Cloud.Region, config.Cloud.Profile, config.Name); err != nil {
return nil, err
}
record := tfengine.Record{
Name: config.Name,
Type: models.ClusterTypeEKS,
Expand Down
2 changes: 2 additions & 0 deletions internal/cluster/providers/gke/templates/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,8 @@ module "gke" {
disk_size_gb = 100
}
]

depends_on = [google_compute_router_nat.nat]
}

output "cluster_name" {
Expand Down
3 changes: 2 additions & 1 deletion internal/cluster/providers/terraform/confirm.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ func ConfirmApplyInteractive(summary PlanSummary) bool {
}
pterm.DefaultBasicText.Printf("\nPlan: %d to add, %d to change, %d to destroy\n\n", summary.Add, summary.Change, summary.Destroy)

total := summary.Add + summary.Change + summary.Destroy
confirmed, err := sharedUI.ConfirmActionInteractive(
fmt.Sprintf("Apply this plan (%d resources to add)?", summary.Add), true)
fmt.Sprintf("Apply this plan (%d resources)?", total), true)
return err == nil && confirmed
}
7 changes: 6 additions & 1 deletion internal/cluster/providers/terraform/cost.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"os/exec"
"path/filepath"
"strings"
"time"

sharedexec "github.com/flamingo-stack/openframe-cli/internal/shared/executor"
)
Expand Down Expand Up @@ -49,7 +50,11 @@ func EstimateMonthlyCost(ctx context.Context, execer sharedexec.CommandExecutor,
return "", err
}

result, err := execer.Execute(ctx, "infracost", "breakdown",
// Bound the infracost execution with a 30-second timeout to prevent hangs
costCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()

result, err := execer.Execute(costCtx, "infracost", "breakdown",
"--path", planPath, "--format", "json", "--no-color")
if err != nil {
return "", fmt.Errorf("infracost breakdown failed: %w", err)
Expand Down
4 changes: 3 additions & 1 deletion internal/cluster/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -280,7 +280,9 @@ func (s *ClusterService) ListClusters() ([]models.ClusterInfo, error) {
for _, cloud := range s.cloudProviders() {
cloudClusters, err := cloud.ListAllClusters(ctx)
if err != nil {
return nil, err
// Skip errors from individual cloud providers to preserve local
// clusters and results from other providers
continue
}
clusters = append(clusters, cloudClusters...)
}
Expand Down
4 changes: 3 additions & 1 deletion internal/cluster/ui/wizard.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,8 +91,10 @@ func (w *ConfigWizard) Run() (ClusterConfig, error) {
// the module default.
if clusterType == models.ClusterTypeEKS || clusterType == models.ClusterTypeGKE {
defaultRegion, defaultMachine := "us-east-1", "m6i.large"
regionLabel := "AWS Region"
if clusterType == models.ClusterTypeGKE {
defaultRegion, defaultMachine = "us-central1", "e2-standard-4"
regionLabel = "GCP Region"

project, err := steps.PromptProject()
if err != nil {
Expand All @@ -101,7 +103,7 @@ func (w *ConfigWizard) Run() (ClusterConfig, error) {
w.config.Project = project
}

region, err := steps.PromptRegion(defaultRegion)
region, err := steps.PromptRegion(regionLabel, defaultRegion)
if err != nil {
return ClusterConfig{}, err
}
Expand Down
6 changes: 3 additions & 3 deletions internal/cluster/ui/wizard_steps.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,10 +90,10 @@ func (ws *WizardSteps) PromptProject() (string, error) {
return strings.TrimSpace(result), nil
}

// PromptRegion prompts for the AWS region an EKS cluster lands in.
func (ws *WizardSteps) PromptRegion(defaultRegion string) (string, error) {
// PromptRegion prompts for the cloud region with a customizable label.
func (ws *WizardSteps) PromptRegion(label, defaultRegion string) (string, error) {
prompt := promptui.Prompt{
Label: "AWS Region",
Label: label,
Default: defaultRegion,
Validate: sharedUI.ValidateNonEmpty("region"),
}
Expand Down
1 change: 1 addition & 0 deletions internal/shared/download/pins.go
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ const (
var Infracost = PinnedTool{
Name: "infracost",
Version: infracostVersion,
Tarball: true,
Assets: map[string]PinnedAsset{
"linux/amd64": {URL: infracostBaseURL + "linux-amd64.tar.gz", SHA256: infracostSHA256LinuxAMD64},
"linux/arm64": {URL: infracostBaseURL + "linux-arm64.tar.gz", SHA256: infracostSHA256LinuxARM64},
Expand Down
3 changes: 3 additions & 0 deletions internal/shared/download/pins_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,9 @@ func TestInfracost_Pins(t *testing.T) {
if !strings.HasPrefix(Infracost.Version, "v") {
t.Fatalf("Infracost.Version must be a v-prefixed tag, got %q", Infracost.Version)
}
if !Infracost.Tarball {
t.Errorf("Infracost.Tarball must be true (assets are .tar.gz)")
}
for _, p := range []struct{ os, arch string }{
{"linux", "amd64"}, {"linux", "arm64"},
{"darwin", "amd64"}, {"darwin", "arm64"},
Expand Down