Skip to content
Draft
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
11 changes: 10 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand All @@ -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
Expand Down
8 changes: 7 additions & 1 deletion cmd/cluster/cluster.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand Down
90 changes: 84 additions & 6 deletions cmd/cluster/create.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
package cluster

import (
"context"
"fmt"
"strings"

"github.com/flamingo-stack/openframe-cli/internal/cluster/models"
"github.com/flamingo-stack/openframe-cli/internal/cluster/prerequisites"
"github.com/flamingo-stack/openframe-cli/internal/cluster/provider"
"github.com/flamingo-stack/openframe-cli/internal/cluster/providers/terraform"
"github.com/flamingo-stack/openframe-cli/internal/cluster/ui"
"github.com/flamingo-stack/openframe-cli/internal/cluster/utils"
"github.com/flamingo-stack/openframe-cli/internal/shared/executor"
"github.com/pterm/pterm"
"github.com/spf13/cobra"
)

Expand All @@ -23,16 +29,22 @@ By default, shows a selection menu where you can choose:
1. Quick start with defaults (press Enter) - creates cluster with default settings
2. Interactive configuration wizard - step-by-step cluster customization

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

EKS clusters are provisioned with Terraform (installed automatically) and
create AWS resources that incur costs; the workspace and state live under
~/.openframe/clusters/<name>. 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()
Expand All @@ -57,6 +69,44 @@ Examples:
return createCmd
}

// planPreviewFn is the cloud dry-run implementation. A package variable so
// cmd-layer tests can stub it out: the real one shells out to terraform,
// which unit tests must never do.
var planPreviewFn = cloudPlanPreview

// cloudPlanPreview runs a real terraform plan for a cloud config and prints
// the resource footprint. Terraform not being installed is a soft skip — the
// prerequisite gate only runs on a real create, and a dry-run must not
// install anything.
func cloudPlanPreview(ctx context.Context, config models.ClusterConfig) error {
if _, err := terraform.FindTerraform(); err != nil {
pterm.Info.Println("terraform is not installed — skipping the plan preview (it installs automatically on a real create)")
return nil
}

exec := executor.NewRealCommandExecutor(false, utils.GetGlobalFlags().Global.Verbose)
p, err := provider.New(config.Type, exec)
if err != nil {
return err
}
planner, ok := p.(provider.Planner)
if !ok {
return nil
}

pterm.Info.Printf("Computing terraform plan for %s cluster '%s'...\n", config.Type, config.Name)
summary, err := planner.PlanCluster(ctx, config)
if err != nil {
return err
}
if !summary.HasChanges() {
pterm.Success.Println("Plan: no changes — the cluster already matches this configuration")
return nil
}
pterm.Success.Printf("Plan: %d to add, %d to change, %d to destroy\n", summary.Add, summary.Change, summary.Destroy)
return nil
}

func runCreateCluster(cmd *cobra.Command, args []string) error {
service := utils.GetCommandService()
globalFlags := utils.GetGlobalFlags()
Expand Down Expand Up @@ -117,19 +167,47 @@ func runCreateCluster(cmd *cobra.Command, args []string) error {
if config.Type == "" {
config.Type = models.ClusterTypeK3d
}

// Cloud settings only exist for cloud types; the k3d backend rejects a
// non-nil Cloud by design.
if config.Type == models.ClusterTypeEKS || config.Type == models.ClusterTypeGKE {
cf := globalFlags.Create
config.Cloud = &models.CloudConfig{
Region: cf.Region,
Profile: cf.Profile,
Project: cf.Project,
MachineType: cf.MachineType,
MinNodes: cf.MinNodes,
MaxNodes: cf.MaxNodes,
Spot: cf.Spot,
BackendConfig: cf.BackendConfig,
}
}
}

// Show configuration summary for dry-run or skip-wizard modes
if globalFlags.Create.DryRun || globalFlags.Create.SkipWizard || globalFlags.Global.Verbose {
operationsUI := ui.NewOperationsUI()
operationsUI.ShowConfigurationSummary(config, globalFlags.Create.DryRun, globalFlags.Create.SkipWizard)

// If dry-run, don't actually create the cluster
// If dry-run, don't actually create the cluster. For cloud types the
// dry-run is a real terraform plan of what create would provision.
if globalFlags.Create.DryRun {
if config.Type == models.ClusterTypeEKS || config.Type == models.ClusterTypeGKE {
return planPreviewFn(cmd.Context(), config)
}
return nil
}
}

// Type-aware prerequisite gate: runs after the type is known (wizard or
// flags), so only the tools the chosen backend needs are demanded. It sits
// after the dry-run return on purpose — the gate may INSTALL tools, and
// dry-run must not mutate the system.
if err := prerequisites.CheckForClusterType(config.Type); err != nil {
return err
}

// Execute cluster creation through service layer
// We ignore the returned rest.Config as it's not needed for standalone cluster creation
_, err := service.CreateCluster(cmd.Context(), config)
Expand Down
59 changes: 59 additions & 0 deletions cmd/cluster/create_behavior_test.go
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -72,6 +74,63 @@ func TestRunCreateCluster_DryRunDefaultsNameWhenNoArgs(t *testing.T) {
}
}

func TestRunCreateCluster_CloudDryRunRunsPlanPreview(t *testing.T) {
for _, clusterType := range []string{"eks", "gke"} {
t.Run(clusterType, func(t *testing.T) {
setupCreate(t)
// Stub the preview: the real one shells out to terraform.
var previewed *models.ClusterConfig
orig := planPreviewFn
planPreviewFn = func(ctx context.Context, config models.ClusterConfig) error {
previewed = &config
return nil
}
t.Cleanup(func() { planPreviewFn = orig })

cmd := getCreateCmd()
gf := utils.GetGlobalFlags()
gf.Create.SkipWizard = true
gf.Create.DryRun = true
gf.Create.ClusterType = clusterType
gf.Create.Region = "us-east-1"
gf.Create.Project = "my-project"

if err := runCreateCluster(cmd, []string{"cloud-cluster"}); err != nil {
t.Fatalf("%s dry-run should return nil, got %v", clusterType, err)
}
if previewed == nil {
t.Fatal("cloud dry-run must invoke the terraform plan preview")
}
if previewed.Cloud == nil || previewed.Cloud.Region != "us-east-1" {
t.Fatalf("preview received wrong config: %+v", previewed)
}
})
}
}

func TestRunCreateCluster_K3dDryRunSkipsPlanPreview(t *testing.T) {
setupCreate(t)
called := false
orig := planPreviewFn
planPreviewFn = func(ctx context.Context, config models.ClusterConfig) error {
called = true
return nil
}
t.Cleanup(func() { planPreviewFn = orig })

cmd := getCreateCmd()
gf := utils.GetGlobalFlags()
gf.Create.SkipWizard = true
gf.Create.DryRun = true

if err := runCreateCluster(cmd, []string{"local-cluster"}); err != nil {
t.Fatalf("k3d dry-run should return nil, got %v", err)
}
if called {
t.Fatal("k3d dry-run must not invoke the terraform plan preview")
}
}

// setupWithExecutor wires a specific mock executor into the command service.
func setupWithExecutor(t *testing.T, exec *executor.MockCommandExecutor) {
t.Helper()
Expand Down
31 changes: 31 additions & 0 deletions cmd/cluster/delete.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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 {
Expand Down
37 changes: 37 additions & 0 deletions cmd/cluster/delete_test.go
Original file line number Diff line number Diff line change
@@ -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"
)
Expand All @@ -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)
}
}
})
}
2 changes: 1 addition & 1 deletion cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading