Skip to content
Merged
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
60 changes: 53 additions & 7 deletions cmd/prerequisites/prerequisites.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -20,47 +21,90 @@ 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
links to install them manually

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
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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.")
Expand All @@ -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.
Expand Down
80 changes: 80 additions & 0 deletions cmd/prerequisites/prerequisites_test.go
Original file line number Diff line number Diff line change
@@ -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"
)
Expand All @@ -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")
}
27 changes: 26 additions & 1 deletion internal/cluster/models/cluster.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
package models

import "time"
import (
"fmt"
"strings"
"time"
)

// ClusterType represents different types of Kubernetes clusters
type ClusterType string
Expand All @@ -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"`
Expand Down
26 changes: 26 additions & 0 deletions internal/cluster/models/cluster_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestClusterType(t *testing.T) {
Expand Down Expand Up @@ -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")
}
16 changes: 8 additions & 8 deletions internal/cluster/models/flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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.
Expand Down
59 changes: 59 additions & 0 deletions internal/cluster/prerequisites/sets.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading