diff --git a/cmd/prerequisites/prerequisites.go b/cmd/prerequisites/prerequisites.go index 61600ee3..6a9f50b4 100644 --- a/cmd/prerequisites/prerequisites.go +++ b/cmd/prerequisites/prerequisites.go @@ -6,6 +6,7 @@ package prerequisites import ( "fmt" + "github.com/flamingo-stack/openframe-cli/internal/cluster/models" clusterprereq "github.com/flamingo-stack/openframe-cli/internal/cluster/prerequisites" fw "github.com/flamingo-stack/openframe-cli/internal/prerequisites" "github.com/pterm/pterm" @@ -20,7 +21,11 @@ func GetPrerequisitesCmd() *cobra.Command { Short: "Check and install the tools OpenFrame needs", Long: `Prerequisites - check and install the tools OpenFrame needs -Verifies that Docker, kubectl, k3d, and helm are available (and Docker running). +Verifies the tools for the chosen cluster type (--type, default k3d): + + • k3d - Docker (running), k3d, helm + • eks - terraform, AWS CLI + • gke - terraform, gcloud, gke-gcloud-auth-plugin • check - report what is installed, without changing anything • install - install anything missing (macOS/Linux); on Windows, print the docs @@ -28,39 +33,78 @@ Verifies that Docker, kubectl, k3d, and helm are available (and Docker running). Examples: openframe prerequisites check - openframe prerequisites install`, + openframe prerequisites check --type eks + openframe prerequisites install --type gke`, RunE: func(cmd *cobra.Command, _ []string) error { return cmd.Help() }, } cmd.AddCommand(checkCmd(), installCmd()) return cmd } +// addTypeFlag registers the --type flag on a subcommand, matching the flag's +// shape on `cluster create` (-t shorthand, same value set and aliases). +func addTypeFlag(cmd *cobra.Command, clusterType *string) { + cmd.Flags().StringVarP(clusterType, "type", "t", "k3d", "Cluster type (k3d, eks, gke; aws/gcp work as aliases)") +} + +// installCommandFor renders the recovery command for a failed check, carrying +// the selected --type: after `check --type eks` a bare install would default +// back to k3d and install the wrong toolset. The default type stays unspoken +// so the common local case keeps the short command. +func installCommandFor(clusterType models.ClusterType) string { + cmd := "openframe prerequisites install" + if clusterType != models.ClusterTypeK3d && clusterType != "" { + cmd += " --type " + string(clusterType) + } + return cmd +} + func checkCmd() *cobra.Command { - return &cobra.Command{ + var clusterType string + cmd := &cobra.Command{ Use: "check", Short: "Report which prerequisites are installed (no changes)", SilenceUsage: true, SilenceErrors: true, RunE: func(cmd *cobra.Command, _ []string) error { - set := clusterprereq.ClusterSet() + // ParseClusterType, not a raw cast: the aliases (aws/gcp) and + // case-insensitivity must behave exactly as on `cluster create`. + parsedType, err := models.ParseClusterType(clusterType) + if err != nil { + return err + } + set, err := clusterprereq.SetForClusterType(parsedType) + if err != nil { + return err + } res := fw.NewRunner().Check(set) printResult(res) if !res.OK() { - return fmt.Errorf("%d prerequisite(s) missing — run 'openframe prerequisites install'", len(res.Missing)) + return fmt.Errorf("%d prerequisite(s) missing — run '%s'", len(res.Missing), installCommandFor(parsedType)) } return nil }, } + addTypeFlag(cmd, &clusterType) + return cmd } func installCmd() *cobra.Command { - return &cobra.Command{ + var clusterType string + cmd := &cobra.Command{ Use: "install", Short: "Install any missing prerequisites (macOS/Linux)", SilenceUsage: true, SilenceErrors: true, RunE: func(cmd *cobra.Command, _ []string) error { - set := clusterprereq.ClusterSet() + parsedType, err := models.ParseClusterType(clusterType) + if err != nil { + return err + } + set, err := clusterprereq.SetForClusterType(parsedType) + if err != nil { + return err + } runner := fw.NewRunner() if !runner.AutoInstalls() { pterm.Warning.Println("Automatic install isn't supported on this OS — please install the tools below manually.") @@ -73,6 +117,8 @@ func installCmd() *cobra.Command { return nil }, } + addTypeFlag(cmd, &clusterType) + return cmd } // printResult renders a friendly, plain-language summary for non-technical users. diff --git a/cmd/prerequisites/prerequisites_test.go b/cmd/prerequisites/prerequisites_test.go index 9ac22652..630093a6 100644 --- a/cmd/prerequisites/prerequisites_test.go +++ b/cmd/prerequisites/prerequisites_test.go @@ -1,8 +1,10 @@ package prerequisites import ( + "bytes" "testing" + "github.com/flamingo-stack/openframe-cli/internal/cluster/models" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -22,3 +24,81 @@ func TestGetPrerequisitesCmd_Structure(t *testing.T) { assert.True(t, sub["check"], "must have a check subcommand") assert.True(t, sub["install"], "must have an install subcommand") } + +// TestSubcommands_TypeFlag verifies both subcommands carry the --type flag in +// the same shape as `cluster create` (-t shorthand) with the local default. +func TestSubcommands_TypeFlag(t *testing.T) { + for _, c := range GetPrerequisitesCmd().Commands() { + t.Run(c.Name(), func(t *testing.T) { + flag := c.Flags().Lookup("type") + require.NotNilf(t, flag, "%s must have a --type flag", c.Name()) + assert.Equal(t, "k3d", flag.DefValue) + assert.Equal(t, "t", flag.Shorthand) + }) + } +} + +// TestSubcommands_UnknownType verifies an unknown --type fails with a clear +// error before any host checks or installs run. +func TestSubcommands_UnknownType(t *testing.T) { + for _, name := range []string{"check", "install"} { + t.Run(name, func(t *testing.T) { + root := GetPrerequisitesCmd() + root.SetOut(&bytes.Buffer{}) + root.SetErr(&bytes.Buffer{}) + root.SetArgs([]string{name, "--type", "minikube"}) + + err := root.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "minikube") + assert.Contains(t, err.Error(), "supported: k3d, eks, gke") + }) + } +} + +// installCommandFor must carry the selected --type: after `check --type eks` +// a bare `prerequisites install` would default back to k3d and install the +// wrong toolset. The default type stays unspoken to keep the common local +// command short. +func TestInstallCommandFor(t *testing.T) { + assert.Equal(t, "openframe prerequisites install", installCommandFor(models.ClusterTypeK3d)) + assert.Equal(t, "openframe prerequisites install --type eks", installCommandFor(models.ClusterTypeEKS)) + assert.Equal(t, "openframe prerequisites install --type gke", installCommandFor(models.ClusterTypeGKE)) +} + +// End-to-end shape of the recovery hint: a typed check that finds tools +// missing must point at a typed install. HOME and PATH are pointed at empty +// temp dirs so every eks tool (terraform, aws — both PATH lookups) reads as +// missing regardless of the host. +func TestCheck_MissingCloudToolsPointAtTypedInstall(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("PATH", t.TempDir()) + + root := GetPrerequisitesCmd() + root.SetOut(&bytes.Buffer{}) + root.SetErr(&bytes.Buffer{}) + root.SetArgs([]string{"check", "--type", "eks"}) + + err := root.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "openframe prerequisites install --type eks", + "the recovery command must keep the selected type, or install defaults back to k3d") +} + +// The provider-name aliases must behave exactly like the canonical types — +// and the recovery hint must echo the CANONICAL type, teaching the shorter +// spelling as a side effect. +func TestCheck_AliasTypeMapsToCanonicalSet(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("PATH", t.TempDir()) + + root := GetPrerequisitesCmd() + root.SetOut(&bytes.Buffer{}) + root.SetErr(&bytes.Buffer{}) + root.SetArgs([]string{"check", "--type", "aws"}) + + err := root.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "openframe prerequisites install --type eks", + "--type aws must select the eks set and hint its canonical name") +} diff --git a/internal/cluster/models/cluster.go b/internal/cluster/models/cluster.go index ae334bab..80e2cc46 100644 --- a/internal/cluster/models/cluster.go +++ b/internal/cluster/models/cluster.go @@ -1,6 +1,10 @@ package models -import "time" +import ( + "fmt" + "strings" + "time" +) // ClusterType represents different types of Kubernetes clusters type ClusterType string @@ -11,6 +15,27 @@ const ( ClusterTypeEKS ClusterType = "eks" ) +// ParseClusterType normalizes a user-supplied cluster type: case-insensitive, +// accepting the provider names as aliases (aws → eks, gcp → gke) — the mental +// model "my AWS cluster" is as common as the product name. Every --type flag +// must parse through here so the aliases work identically across commands. +// Empty stays empty (the caller's default applies); unknown values are an +// error naming the accepted set. +func ParseClusterType(s string) (ClusterType, error) { + switch strings.ToLower(strings.TrimSpace(s)) { + case "": + return "", nil + case "k3d": + return ClusterTypeK3d, nil + case "eks", "aws": + return ClusterTypeEKS, nil + case "gke", "gcp": + return ClusterTypeGKE, nil + default: + return "", fmt.Errorf("unknown cluster type '%s' (supported: k3d, eks, gke)", s) + } +} + // ClusterConfig holds cluster configuration type ClusterConfig struct { Name string `json:"name"` diff --git a/internal/cluster/models/cluster_test.go b/internal/cluster/models/cluster_test.go index 341a430b..8a71a2bd 100644 --- a/internal/cluster/models/cluster_test.go +++ b/internal/cluster/models/cluster_test.go @@ -6,6 +6,7 @@ import ( "time" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestClusterType(t *testing.T) { @@ -257,3 +258,28 @@ func TestJSONSerialization(t *testing.T) { assert.NotContains(t, string(local), "cloud") }) } + +// ParseClusterType is the single parser behind every --type flag: canonical +// names, provider aliases (aws→eks, gcp→gke), case-insensitivity, empty as +// "caller's default", and a clear error for anything else. +func TestParseClusterType(t *testing.T) { + for in, want := range map[string]ClusterType{ + "k3d": ClusterTypeK3d, + "eks": ClusterTypeEKS, + "aws": ClusterTypeEKS, + "gke": ClusterTypeGKE, + "gcp": ClusterTypeGKE, + "EKS": ClusterTypeEKS, + "AWS": ClusterTypeEKS, + "": "", + } { + got, err := ParseClusterType(in) + require.NoErrorf(t, err, "input %q", in) + assert.Equalf(t, want, got, "input %q", in) + } + + _, err := ParseClusterType("minikube") + require.Error(t, err) + assert.Contains(t, err.Error(), "unknown cluster type 'minikube'") + assert.Contains(t, err.Error(), "supported: k3d, eks, gke") +} diff --git a/internal/cluster/models/flags.go b/internal/cluster/models/flags.go index 6c6fb82a..3c116ebe 100644 --- a/internal/cluster/models/flags.go +++ b/internal/cluster/models/flags.go @@ -69,7 +69,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, eks, gke)") + cmd.Flags().StringVarP(&flags.ClusterType, "type", "t", "", "Cluster type (k3d, eks, gke; aws/gcp work as aliases)") 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") @@ -155,14 +155,14 @@ func ValidateCreateFlags(flags *CreateFlags) error { return err } - // Reject unknown --type values up front. - clusterType := ClusterType(flags.ClusterType) - switch clusterType { - case "", ClusterTypeK3d, ClusterTypeGKE, ClusterTypeEKS: - // known - default: - return fmt.Errorf("unknown cluster type '%s' (supported: k3d, eks, gke)", flags.ClusterType) + // Reject unknown --type values up front, and write the canonical form + // back so every later ClusterType(flags.ClusterType) cast sees eks/gke, + // never a raw alias like "aws". + clusterType, err := ParseClusterType(flags.ClusterType) + if err != nil { + return err } + flags.ClusterType = string(clusterType) // The wizard prompts for these; in skip-wizard mode they must come from // flags. diff --git a/internal/cluster/prerequisites/sets.go b/internal/cluster/prerequisites/sets.go index a32dd316..e78c6c9f 100644 --- a/internal/cluster/prerequisites/sets.go +++ b/internal/cluster/prerequisites/sets.go @@ -2,10 +2,15 @@ package prerequisites import ( "context" + "fmt" + "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" fw "github.com/flamingo-stack/openframe-cli/internal/prerequisites" ) @@ -46,6 +51,60 @@ func ClusterSet() fw.Set { } } +// EKSSet returns the prerequisites 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 EKSSet() fw.Set { + terraformInstaller := terraform.NewTerraformInstaller() + awsInstaller := aws.NewAwsInstaller() + + return fw.Set{ + Name: "eks", + Items: []fw.Prerequisite{ + toolPrerequisite("terraform", terraformInstaller.IsInstalled, terraformInstaller.Install, terraformInstaller.GetInstallHelp), + toolPrerequisite("AWS CLI", awsInstaller.IsInstalled, awsInstaller.Install, awsInstaller.GetInstallHelp), + }, + } +} + +// GKESet returns the prerequisites 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 GKESet() fw.Set { + terraformInstaller := terraform.NewTerraformInstaller() + gcloudInstaller := gcloud.NewGcloudInstaller() + authPluginInstaller := gcloud.NewAuthPluginInstaller() + + return fw.Set{ + Name: "gke", + Items: []fw.Prerequisite{ + toolPrerequisite("terraform", terraformInstaller.IsInstalled, terraformInstaller.Install, terraformInstaller.GetInstallHelp), + toolPrerequisite("gcloud", gcloudInstaller.IsInstalled, gcloudInstaller.Install, gcloudInstaller.GetInstallHelp), + toolPrerequisite("gke-gcloud-auth-plugin", authPluginInstaller.IsInstalled, authPluginInstaller.Install, authPluginInstaller.GetInstallHelp), + }, + } +} + +// SetForClusterType maps a cluster type to its prerequisite set: Docker/k3d/ +// helm for local k3d clusters, terraform + the cloud CLI for the cloud types. +// An empty type means the local default. Unknown types are an error — unlike +// the create-time gate there is no later provider factory to catch them here. +func SetForClusterType(clusterType models.ClusterType) (fw.Set, error) { + switch clusterType { + case models.ClusterTypeK3d, "": + return ClusterSet(), nil + case models.ClusterTypeEKS: + return EKSSet(), nil + case models.ClusterTypeGKE: + return GKESet(), nil + default: + return fw.Set{}, fmt.Errorf("unknown cluster type %q (expected k3d, eks, or gke)", clusterType) + } +} + // toolPrerequisite adapts the uniform tool-installer API (IsInstalled/Install/ // GetInstallHelp) to a framework Prerequisite. func toolPrerequisite(name string, isInstalled func() bool, install func() error, help func() string) fw.Prerequisite { diff --git a/internal/cluster/prerequisites/sets_test.go b/internal/cluster/prerequisites/sets_test.go index 291036c7..0bfe1b26 100644 --- a/internal/cluster/prerequisites/sets_test.go +++ b/internal/cluster/prerequisites/sets_test.go @@ -3,16 +3,18 @@ package prerequisites import ( "testing" + "github.com/flamingo-stack/openframe-cli/internal/cluster/models" + fw "github.com/flamingo-stack/openframe-cli/internal/prerequisites" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -// TestClusterSet_Structure verifies the cluster prerequisite set is assembled -// correctly. It does not invoke IsSatisfied/Install (those touch the host), only -// checks the set's shape so a mis-wired adapter is caught. -func TestClusterSet_Structure(t *testing.T) { - set := ClusterSet() - assert.Equal(t, "cluster", set.Name) +// assertSetStructure verifies a prerequisite set is assembled correctly. It +// does not invoke IsSatisfied/Install (those touch the host), only checks the +// set's shape so a mis-wired adapter is caught. +func assertSetStructure(t *testing.T, set fw.Set, wantName string, wantItems []string) { + t.Helper() + assert.Equal(t, wantName, set.Name) names := make([]string, 0, len(set.Items)) for _, it := range set.Items { @@ -22,5 +24,47 @@ func TestClusterSet_Structure(t *testing.T) { assert.NotEmptyf(t, it.DocsURL, "%s must carry manual setup guidance", it.Name) } - require.ElementsMatch(t, []string{"Docker", "k3d", "helm"}, names) + require.ElementsMatch(t, wantItems, names) +} + +func TestClusterSet_Structure(t *testing.T) { + assertSetStructure(t, ClusterSet(), "cluster", []string{"Docker", "k3d", "helm"}) +} + +func TestEKSSet_Structure(t *testing.T) { + assertSetStructure(t, EKSSet(), "eks", []string{"terraform", "AWS CLI"}) +} + +func TestGKESet_Structure(t *testing.T) { + assertSetStructure(t, GKESet(), "gke", []string{"terraform", "gcloud", "gke-gcloud-auth-plugin"}) +} + +// TestSetForClusterType verifies the type→set mapping without invoking any +// checks or installs: k3d (and the empty default) map to the local set, the +// cloud types to their cloud sets, and unknown types are an error. +func TestSetForClusterType(t *testing.T) { + cases := []struct { + clusterType models.ClusterType + wantName string + }{ + {models.ClusterTypeK3d, "cluster"}, + {"", "cluster"}, + {models.ClusterTypeEKS, "eks"}, + {models.ClusterTypeGKE, "gke"}, + } + for _, tc := range cases { + t.Run(string(tc.clusterType), func(t *testing.T) { + set, err := SetForClusterType(tc.clusterType) + require.NoError(t, err) + assert.Equal(t, tc.wantName, set.Name) + assert.NotEmpty(t, set.Items) + }) + } + + t.Run("unknown", func(t *testing.T) { + _, err := SetForClusterType("minikube") + require.Error(t, err) + assert.Contains(t, err.Error(), "minikube") + assert.Contains(t, err.Error(), "k3d, eks, or gke") + }) }