diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1e73824..538b3c3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,7 @@ jobs: - name: Setup Go uses: actions/setup-go@v5 with: - go-version: '1.22' + go-version: '1.26' cache: true - name: Go fmt diff --git a/.gitignore b/.gitignore index b985455..4780583 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ # Binary -microshift-installer -microshift-installer.exe +/microshift-installer +/microshift-installer.exe # Asset / state directories my-cluster/ diff --git a/cmd/microshift-installer/main.go b/cmd/microshift-installer/main.go new file mode 100644 index 0000000..e643760 --- /dev/null +++ b/cmd/microshift-installer/main.go @@ -0,0 +1,270 @@ +package main + +import ( + "context" + "fmt" + "log" + "os" + "os/signal" + "path/filepath" + "syscall" + + "github.com/hchenxa/microshift-installer/pkg/asset" + "github.com/hchenxa/microshift-installer/pkg/config" + "github.com/hchenxa/microshift-installer/pkg/platform" + "github.com/hchenxa/microshift-installer/pkg/state" + "github.com/hchenxa/microshift-installer/pkg/types" + "github.com/spf13/cobra" +) + +const stateFileName = "cluster-state.json" + +var ( + version = "0.1.0" + assetDir string +) + +func main() { + rootCmd := &cobra.Command{ + Use: "microshift-installer", + Short: "Install and manage MicroShift clusters on AWS and GCP", + Long: `microshift-installer creates and manages MicroShift clusters +on AWS and GCP using the cloud provider's native Go SDKs. + +Example: + microshift-installer create cluster --dir=./my-cluster + microshift-installer destroy cluster --dir=./my-cluster +`, + } + + rootCmd.PersistentFlags().StringVarP(&assetDir, "dir", "d", "", "Asset directory (default: ~/.microshift-installer/)") + + createCmd := &cobra.Command{ + Use: "create", + Short: "Create a MicroShift cluster", + } + createClusterCmd := &cobra.Command{ + Use: "cluster", + Short: "Create a new MicroShift cluster", + RunE: runCreateCluster, + } + createCmd.AddCommand(createClusterCmd) + + destroyCmd := &cobra.Command{ + Use: "destroy", + Short: "Destroy a MicroShift cluster", + } + destroyClusterCmd := &cobra.Command{ + Use: "cluster", + Short: "Destroy an existing MicroShift cluster", + RunE: runDestroyCluster, + } + destroyCmd.AddCommand(destroyClusterCmd) + + versionCmd := &cobra.Command{ + Use: "version", + Short: "Print the version", + Run: func(cmd *cobra.Command, args []string) { + fmt.Printf("microshift-installer %s\n", version) + }, + } + + rootCmd.AddCommand(createCmd, destroyCmd, versionCmd) + + if err := rootCmd.Execute(); err != nil { + os.Exit(1) + } +} + +func resolveAssetDir(dir string, name string) (string, error) { + if dir != "" { + return filepath.Abs(dir) + } + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + clusterName := name + if clusterName == "" { + clusterName = "default" + } + return filepath.Join(home, ".microshift-installer", clusterName), nil +} + +func loadOrCreateConfig(store *asset.Store) (*types.InstallConfig, error) { + if store.ConfigExists() { + log.Printf("Found existing install-config.yaml at %s", store.ConfigPath()) + cfg, err := config.LoadInstallConfig(store.ConfigPath()) + if err != nil { + return nil, err + } + if err := cfg.Validate(); err != nil { + return nil, fmt.Errorf("install config validation failed: %w", err) + } + return cfg, nil + } + + log.Print("No install-config.yaml found — starting interactive setup...") + cfg := types.DefaultInstallConfig() + if err := config.Interactive(cfg); err != nil { + return nil, err + } + + // Resolve the final asset directory now that we have the cluster name. + resolved, err := resolveAssetDir(assetDir, cfg.Metadata.Name) + if err != nil { + return nil, err + } + assetDir = resolved + store, err = asset.NewStore(assetDir) + if err != nil { + return nil, err + } + + if err := config.SaveInstallConfig(cfg, store.ConfigPath()); err != nil { + return nil, err + } + log.Printf("Saved install config to %s", store.ConfigPath()) + + return cfg, nil +} + +func runCreateCluster(cmd *cobra.Command, args []string) error { + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + // 1. Setup initial store (may be temporary before we know cluster name). + if assetDir == "" { + assetDir = "/tmp/_microshift_temp" + } + store, err := asset.NewStore(assetDir) + if err != nil { + return fmt.Errorf("setting up asset directory: %w", err) + } + + // 2. Load or create install config. + cfg, err := loadOrCreateConfig(store) + if err != nil { + return err + } + + // 3. Validate and get platform. + if err := cfg.Validate(); err != nil { + return fmt.Errorf("invalid configuration: %w", err) + } + plat := platform.New(cfg) + if plat == nil { + return fmt.Errorf("unsupported cloud provider: %s", cfg.CloudProvider) + } + + // 4. Load or create state tracker. + statePath := filepath.Join(store.Dir(), stateFileName) + st, err := state.Load(statePath) + if err != nil { + return fmt.Errorf("loading state: %w", err) + } + + fmt.Println() + fmt.Println("=== Starting cluster creation ===") + fmt.Printf(" Cluster: %s\n", cfg.Metadata.Name) + fmt.Printf(" Platform: %s\n", cfg.CloudProvider) + fmt.Printf(" Asset dir: %s\n", store.Dir()) + fmt.Println() + + // 5. Create infrastructure via platform SDK. + info, err := plat.Create(ctx, cfg, st) + if err != nil { + return fmt.Errorf("cluster creation failed: %w", err) + } + + // 6. Save state. + if err := st.Save(); err != nil { + return fmt.Errorf("saving state: %w", err) + } + + fmt.Println() + fmt.Println("=== Cluster created successfully ===") + fmt.Printf("%-25s %s\n", "Node Public IP:", info.NodePublicIP) + fmt.Printf("%-25s %s\n", "SSH Command:", info.SSHCommand) + fmt.Printf("%-25s %s\n", "Kubeconfig Retrieval:", info.KubeconfigCmd) + if info.DNSFQDN != "" { + fmt.Printf("%-25s %s\n", "DNS FQDN:", info.DNSFQDN) + } + fmt.Println() + + return nil +} + +func runDestroyCluster(cmd *cobra.Command, args []string) error { + if assetDir == "" { + return fmt.Errorf("--dir is required for destroy") + } + + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + store, err := asset.NewStore(assetDir) + if err != nil { + return fmt.Errorf("setting up asset directory: %w", err) + } + + // Load install config first — it has the correct region/project settings. + var cfg *types.InstallConfig + if store.ConfigExists() { + cfg, err = config.LoadInstallConfig(store.ConfigPath()) + if err != nil { + return err + } + fmt.Printf("Using install config from %s\n", store.ConfigPath()) + } + + statePath := filepath.Join(store.Dir(), stateFileName) + st, err := state.Load(statePath) + if err != nil { + return fmt.Errorf("loading state: %w", err) + } + if len(st.Resources) == 0 { + return fmt.Errorf("no cluster resources found in %s (nothing to destroy)", statePath) + } + + // Determine platform — prefer install config for region/project, fall back to state. + plat := platformFromState(cfg, st) + if plat == nil { + return fmt.Errorf("unable to determine platform from state file") + } + + fmt.Println("Destroying cluster resources...") + if err := plat.Destroy(ctx, st); err != nil { + return fmt.Errorf("cluster destruction failed: %w", err) + } + + fmt.Println("Cluster destroyed successfully.") + return nil +} + +func platformFromState(cfg *types.InstallConfig, st *state.State) platform.Platform { + provider := "" + for _, r := range st.All() { + if r.Provider == "aws" || r.Provider == "gcp" { + provider = r.Provider + break + } + } + if provider == "" { + return nil + } + + // If install config exists, use it directly for all settings. + if cfg != nil { + return platform.New(cfg) + } + + // Fallback: minimal config from state (region/project won't be accurate). + return platform.New(&types.InstallConfig{ + CloudProvider: provider, + Platform: types.Platform{ + AWS: &types.AWSPlatform{Region: "us-east-2"}, + GCP: &types.GCPPlatform{Project: "", Zone: "us-central1-a"}, + }, + }) +}