From 85e87f1334551948daa99b726dd54e7ca0cc1a50 Mon Sep 17 00:00:00 2001 From: eding Date: Wed, 15 Jul 2026 20:39:39 +0200 Subject: [PATCH] feat: add bootstrap workflow --- .vscode/launch.json | 13 +++ How2Auth.md | 2 + MaidnCLI | 1 + README.md | 6 ++ cmd/bootstrap.go | 53 +++++++++++ cmd/repo.go | 6 +- cmd/vault.go | 2 +- docs/maintenance.md | 22 +++++ internal/bootstrap/bootstrap.go | 159 +++++++++++++++++++++++++++++++ internal/config/config.go | 65 +++++++++++++ internal/config/types.go | 87 +++++++++++++++++ internal/github/repository.go | 6 ++ internal/ui/wizard.go | 164 ++++++++++++++++++++++++++++++++ 13 files changed, 583 insertions(+), 3 deletions(-) create mode 100644 How2Auth.md create mode 160000 MaidnCLI create mode 100644 cmd/bootstrap.go create mode 100644 docs/maintenance.md create mode 100644 internal/bootstrap/bootstrap.go create mode 100644 internal/config/config.go create mode 100644 internal/config/types.go create mode 100644 internal/ui/wizard.go diff --git a/.vscode/launch.json b/.vscode/launch.json index 5ceff7f..c0fae80 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -44,6 +44,19 @@ ], "cwd": "${workspaceFolder}", "console": "integratedTerminal" + }, + { + "name": "Run and Debug CICD Tool ghcr auth creation", + "type": "go", + "request": "launch", + "mode": "auto", + "program": "${workspaceFolder}", + "args": [ + "vault", + "create-ghcr-secret" + ], + "cwd": "${workspaceFolder}", + "console": "integratedTerminal" } ] } \ No newline at end of file diff --git a/How2Auth.md b/How2Auth.md new file mode 100644 index 0000000..ad7fa76 --- /dev/null +++ b/How2Auth.md @@ -0,0 +1,2 @@ +edingrech +dckr_pat_bfKKDH4g3qUxchs9UMLxFx2oTiU \ No newline at end of file diff --git a/MaidnCLI b/MaidnCLI new file mode 160000 index 0000000..cc8fab6 --- /dev/null +++ b/MaidnCLI @@ -0,0 +1 @@ +Subproject commit cc8fab6832a3a51bd072155f90f91354e440077a diff --git a/README.md b/README.md index 7282c8e..41446a6 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,12 @@ go install github.com/go-delve/delve/cmd/dlv@latest dlv version ``` +## Commands + +- `cicd-tool repo init --org --flux-repo ` creates the manifests and Flux repos +- `cicd-tool bootstrap` runs the user-facing wizard, saves a config YAML, clones the Talos IaC repo, writes `terraform.tfvars`, stages Talos images on Proxmox when enabled, and can execute Terraform, Talos bootstrap, and Flux bootstrap +- `cicd-tool bootstrap --config maidn-bootstrap.yaml` skips the wizard and uses the saved config + ## TODO create the file: within the main flux repo dynamically changing the org and repo location based on the user's input diff --git a/cmd/bootstrap.go b/cmd/bootstrap.go new file mode 100644 index 0000000..103c260 --- /dev/null +++ b/cmd/bootstrap.go @@ -0,0 +1,53 @@ +package cmd + +import ( + "fmt" + "os" + + "github.com/Pingu-Studio/MaidnCLI/internal/bootstrap" + "github.com/Pingu-Studio/MaidnCLI/internal/config" + "github.com/Pingu-Studio/MaidnCLI/internal/ui" + "github.com/spf13/cobra" +) + +var bootstrapConfigPath string +var bootstrapOutputPath string + +var bootstrapCmd = &cobra.Command{ + Use: "bootstrap", + Short: "Bootstrap Talos and Flux from config or an interactive wizard.", + Run: runBootstrap, +} + +func init() { + rootCmd.AddCommand(bootstrapCmd) + bootstrapCmd.Flags().StringVar(&bootstrapConfigPath, "config", "", "Path to bootstrap config YAML") + bootstrapCmd.Flags().StringVar(&bootstrapOutputPath, "out", "maidn-bootstrap.yaml", "Path to save generated config") +} + +func runBootstrap(cmd *cobra.Command, args []string) { + var cfg config.Config + var err error + + if bootstrapConfigPath != "" { + cfg, err = config.Load(bootstrapConfigPath) + } else { + cfg, err = ui.RunBootstrapWizard() + if err == nil { + err = config.Save(bootstrapOutputPath, cfg) + if err == nil { + fmt.Printf("[INFO] Saved config to %s\n", bootstrapOutputPath) + } + } + } + if err != nil { + fmt.Printf("[ERROR] %v\n", err) + os.Exit(1) + } + + runner := bootstrap.Runner{Config: cfg} + if err = runner.Run(); err != nil { + fmt.Printf("[ERROR] %v\n", err) + os.Exit(1) + } +} diff --git a/cmd/repo.go b/cmd/repo.go index b01a3a7..8577bc3 100644 --- a/cmd/repo.go +++ b/cmd/repo.go @@ -48,8 +48,10 @@ func runInitRepo(cmd *cobra.Command, args []string) { fmt.Println("[SUCCESS] Dependencies and authentication are OK.") repoManager := github.NewRepoManager(orgName, manifestsRepoName, fluxRepoName) - repoManager.InitializeManifestsRepo() - repoManager.InitializeFluxRepo() + if err := repoManager.InitializeAll(); err != nil { + fmt.Printf("[ERROR] %v\n", err) + os.Exit(1) + } fmt.Println("\nšŸŽ‰ Onboarding complete! šŸŽ‰") fmt.Printf("āœ… Manifests repository: https://github.com/%s/%s\n", orgName, manifestsRepoName) diff --git a/cmd/vault.go b/cmd/vault.go index e54fd28..ff5da93 100644 --- a/cmd/vault.go +++ b/cmd/vault.go @@ -33,7 +33,7 @@ var createDockerSecretCmd = &cobra.Command{ var createGhcrSecretCmd = &cobra.Command{ Use: "create-ghcr-secret", - Short: "Creates secrets in Vault for GHCR.", + Short: "Creates secrets in Vault for GHCR and applies it to Kubernetes.", Run: runCreateGhcrSecret, } diff --git a/docs/maintenance.md b/docs/maintenance.md new file mode 100644 index 0000000..3bc86cc --- /dev/null +++ b/docs/maintenance.md @@ -0,0 +1,22 @@ +# Cluster maintenance + +## What the CLI handles now + +- Bootstraps the Talos IaC repo +- Writes cluster-specific Terraform config +- Can trigger Terraform, Talos bootstrap, kubeconfig generation, and Flux bootstrap +- Can request Proxmox-side Talos ISO staging through Terraform inputs + +## Manual operations still required + +- Talos upgrades remain operator-driven +- Certificate rotation remains operator-driven +- etcd snapshots and restore drills remain operator-driven + +## Candidate automation + +- Add a `cicd-tool talos upgrade` workflow that updates version pins, stages images, and rolls nodes one by one +- Add a `cicd-tool talos certs check` workflow that reports expiry windows +- Add a scheduled GitHub Action in the Talos repo that opens PRs for new factory-compatible image pins + +Use the standalone Talos repo maintenance guide for the actual runbook: `maidn/maidn-talos-proxmox/docs/maintenance.md`. diff --git a/internal/bootstrap/bootstrap.go b/internal/bootstrap/bootstrap.go new file mode 100644 index 0000000..dd86abe --- /dev/null +++ b/internal/bootstrap/bootstrap.go @@ -0,0 +1,159 @@ +package bootstrap + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/Pingu-Studio/MaidnCLI/internal/config" + ghrepo "github.com/Pingu-Studio/MaidnCLI/internal/github" + "github.com/Pingu-Studio/MaidnCLI/internal/utils" +) + +type Runner struct { + Config config.Config +} + +func (r Runner) Run() error { + workspace := r.Config.WorkspaceDir + if err := os.MkdirAll(workspace, 0755); err != nil { + return err + } + + if err := ghrepo.NewRepoManager(r.Config.GitHub.Org, "cicd-deployment-manifests", r.Config.Flux.RepoName).InitializeAll(); err != nil { + return err + } + + repoDir := filepath.Join(workspace, r.Config.Talos.RepoDirName) + if err := ensureRepo(repoDir, r.Config.Talos.RepoURL, r.Config.Talos.RepoRef); err != nil { + return err + } + + terraformDir := filepath.Join(repoDir, r.Config.Talos.TerraformDir) + generatedDir := filepath.Join(repoDir, r.Config.Talos.GeneratedDir) + if err := os.MkdirAll(generatedDir, 0755); err != nil { + return err + } + + if err := os.WriteFile(filepath.Join(terraformDir, r.Config.Talos.ConfigFileName), []byte(renderTerraformTFVars(r.Config)), 0644); err != nil { + return err + } + + if r.Config.Talos.AutoRunTerraform { + if err := utils.RunCommandInDir(terraformDir, "terraform", "init"); err != nil { + return err + } + if err := utils.RunCommandInDir(terraformDir, "terraform", "apply", "-auto-approve"); err != nil { + return err + } + } + + if r.Config.Talos.AutoBootstrap { + if err := utils.RunCommandInDir(generatedDir, "talosctl", "bootstrap", "--talosconfig=./clusterconfig/talosconfig", "--nodes="+r.Config.Talos.BootstrapNode); err != nil { + return err + } + if err := utils.RunCommandInDir(generatedDir, "talosctl", "kubeconfig", "--talosconfig=./clusterconfig/talosconfig", "--nodes="+r.Config.Talos.KubeconfigNode, "."); err != nil { + return err + } + } + + if r.Config.Talos.AutoBootstrapFlux { + if err := utils.RunCommand("flux", "bootstrap", "github", "--owner="+r.Config.Flux.Owner, "--repository="+r.Config.Flux.RepoName, "--branch="+r.Config.Flux.Branch, "--path="+r.Config.Flux.ClusterPath, "--cluster-domain="+r.Config.Flux.ClusterDomain); err != nil { + return err + } + } + + return nil +} + +func ensureRepo(dir, repoURL, ref string) error { + if _, err := os.Stat(dir); os.IsNotExist(err) { + return utils.RunCommand("git", "clone", "--branch", ref, repoURL, dir) + } + if err := utils.RunCommandInDir(dir, "git", "fetch", "origin"); err != nil { + return err + } + return utils.RunCommandInDir(dir, "git", "checkout", ref) +} + +func renderTerraformTFVars(cfg config.Config) string { + var builder strings.Builder + builder.WriteString(fmt.Sprintf("proxmox_api_url = %q\n", cfg.Talos.Proxmox.APIURL)) + builder.WriteString(fmt.Sprintf("proxmox_node = %q\n", cfg.Talos.Proxmox.DefaultNode)) + builder.WriteString(fmt.Sprintf("proxmox_api_token = %q\n", cfg.Talos.Proxmox.APIToken)) + builder.WriteString(fmt.Sprintf("proxmox_pool = %q\n", cfg.Talos.Proxmox.Pool)) + builder.WriteString(fmt.Sprintf("cluster_name = %q\n", cfg.Talos.Cluster.Name)) + builder.WriteString(fmt.Sprintf("cluster_domain = %q\n", cfg.Talos.Cluster.Domain)) + builder.WriteString(fmt.Sprintf("talos_factory_schematic_id = %q\n", cfg.Talos.Image.FactorySchematicID)) + builder.WriteString(fmt.Sprintf("talos_version = %q\n", cfg.Talos.Image.TalosVersion)) + builder.WriteString(fmt.Sprintf("cni_name = %q\n", cfg.Talos.Cluster.CNI)) + builder.WriteString(fmt.Sprintf("talos_iso_file = %q\n", cfg.Talos.Image.TalosISOFile)) + updateMode := cfg.Talos.Image.UpdateMode + if updateMode == "" { + updateMode = "manual" + } + storage := cfg.Talos.Image.Storage + if storage == "" { + storage = "local" + } + architecture := cfg.Talos.Image.Architecture + if architecture == "" { + architecture = "amd64" + } + builder.WriteString(fmt.Sprintf("talos_image_update_mode = %q\n", updateMode)) + builder.WriteString(fmt.Sprintf("talos_image_storage = %q\n", storage)) + builder.WriteString(fmt.Sprintf("talos_image_architecture = %q\n", architecture)) + builder.WriteString(fmt.Sprintf("disk_storage = %q\n", cfg.Talos.Cluster.DiskStorage)) + builder.WriteString(fmt.Sprintf("additional_disk_storage = %q\n", cfg.Talos.Cluster.AdditionalStorage)) + builder.WriteString(fmt.Sprintf("dns_servers = [%s]\n", quoteList(cfg.Talos.Cluster.DNSServers))) + builder.WriteString(fmt.Sprintf("control_plane_vip = %q\n", cfg.Talos.Cluster.ControlPlaneVIP)) + builder.WriteString("node_interfaces = {\n") + for node, iface := range cfg.Talos.Proxmox.NodeInterfaces { + builder.WriteString(fmt.Sprintf(" %q = %q\n", node, iface)) + } + builder.WriteString("}\n") + builder.WriteString(fmt.Sprintf("create_vlan_interface = %t\n", cfg.Talos.Cluster.CreateVLANInterface)) + builder.WriteString("image_cache_proxy = { enabled = false, ip = \"\", port = 3128 }\n") + builder.WriteString("nodes = [\n") + for _, node := range cfg.Talos.Nodes { + builder.WriteString(" {\n") + builder.WriteString(fmt.Sprintf(" name = %q\n", node.Name)) + builder.WriteString(fmt.Sprintf(" vmid = %d\n", node.VMID)) + builder.WriteString(fmt.Sprintf(" role = %q\n", node.Role)) + builder.WriteString(fmt.Sprintf(" cores = %d\n", node.Cores)) + builder.WriteString(fmt.Sprintf(" memory = %d\n", node.Memory)) + builder.WriteString(fmt.Sprintf(" disk_size = %q\n", node.DiskSize)) + if node.AdditionalDiskSize != "" { + builder.WriteString(fmt.Sprintf(" additional_disk_size = %q\n", node.AdditionalDiskSize)) + } + builder.WriteString(fmt.Sprintf(" tags = [%s]\n", quoteList(node.Tags))) + builder.WriteString(fmt.Sprintf(" proxmox_node = %q\n", node.ProxmoxNode)) + builder.WriteString(" networks = [\n") + for _, net := range node.Networks { + builder.WriteString(" {\n") + builder.WriteString(fmt.Sprintf(" mac_address = %q\n", net.MACAddress)) + builder.WriteString(fmt.Sprintf(" cidr = %q\n", net.CIDR)) + if net.IP != "" { + builder.WriteString(fmt.Sprintf(" ip = %q\n", net.IP)) + } + if net.Gateway != "" { + builder.WriteString(fmt.Sprintf(" gateway = %q\n", net.Gateway)) + } + builder.WriteString(fmt.Sprintf(" vlan_id = %d\n", net.VLANID)) + builder.WriteString(" },\n") + } + builder.WriteString(" ]\n") + builder.WriteString(" },\n") + } + builder.WriteString("]\n") + return builder.String() +} + +func quoteList(values []string) string { + quoted := make([]string, 0, len(values)) + for _, value := range values { + quoted = append(quoted, fmt.Sprintf("%q", value)) + } + return strings.Join(quoted, ", ") +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..9a020fa --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,65 @@ +package config + +import ( + "errors" + "os" + + "gopkg.in/yaml.v3" +) + +func Load(path string) (Config, error) { + var cfg Config + data, err := os.ReadFile(path) + if err != nil { + return cfg, err + } + if err := yaml.Unmarshal(data, &cfg); err != nil { + return cfg, err + } + return cfg, Validate(cfg) +} + +func Save(path string, cfg Config) error { + if err := Validate(cfg); err != nil { + return err + } + data, err := yaml.Marshal(cfg) + if err != nil { + return err + } + return os.WriteFile(path, data, 0644) +} + +func Validate(cfg Config) error { + if cfg.WorkspaceDir == "" { + return errors.New("workspaceDir is required") + } + if cfg.GitHub.Org == "" { + return errors.New("github.org is required") + } + if cfg.Flux.RepoName == "" || cfg.Flux.ClusterPath == "" { + return errors.New("flux repoName and clusterPath are required") + } + if cfg.Talos.RepoURL == "" || cfg.Talos.RepoDirName == "" { + return errors.New("talos repoUrl and repoDirName are required") + } + if cfg.Talos.TerraformDir == "" || cfg.Talos.GeneratedDir == "" || cfg.Talos.ConfigFileName == "" { + return errors.New("talos terraformDir, generatedDir, and configFileName are required") + } + if cfg.Talos.Proxmox.APIURL == "" || cfg.Talos.Proxmox.APIToken == "" { + return errors.New("talos proxmox apiUrl and apiToken are required") + } + if cfg.Talos.Cluster.Name == "" || cfg.Talos.Cluster.Domain == "" { + return errors.New("talos cluster name and domain are required") + } + if cfg.Talos.Image.FactorySchematicID == "" || cfg.Talos.Image.TalosVersion == "" || cfg.Talos.Image.TalosISOFile == "" { + return errors.New("talos image pins are required") + } + if cfg.Talos.Image.UpdateMode != "" && cfg.Talos.Image.UpdateMode != "manual" && cfg.Talos.Image.UpdateMode != "download" { + return errors.New("talos image updateMode must be manual or download") + } + if len(cfg.Talos.Nodes) == 0 { + return errors.New("at least one talos node is required") + } + return nil +} diff --git a/internal/config/types.go b/internal/config/types.go new file mode 100644 index 0000000..bf891ec --- /dev/null +++ b/internal/config/types.go @@ -0,0 +1,87 @@ +package config + +type Config struct { + WorkspaceDir string `yaml:"workspaceDir"` + GitHub GitHubConfig `yaml:"github"` + Flux FluxConfig `yaml:"flux"` + Talos TalosConfig `yaml:"talos"` +} + +type GitHubConfig struct { + Org string `yaml:"org"` +} + +type FluxConfig struct { + RepoName string `yaml:"repoName"` + Branch string `yaml:"branch"` + ClusterPath string `yaml:"clusterPath"` + ClusterDomain string `yaml:"clusterDomain"` + Owner string `yaml:"owner"` +} + +type TalosConfig struct { + RepoURL string `yaml:"repoUrl"` + RepoRef string `yaml:"repoRef"` + RepoDirName string `yaml:"repoDirName"` + TerraformDir string `yaml:"terraformDir"` + GeneratedDir string `yaml:"generatedDir"` + ConfigFileName string `yaml:"configFileName"` + AutoRunTerraform bool `yaml:"autoRunTerraform"` + AutoBootstrap bool `yaml:"autoBootstrap"` + AutoBootstrapFlux bool `yaml:"autoBootstrapFlux"` + BootstrapNode string `yaml:"bootstrapNode"` + KubeconfigNode string `yaml:"kubeconfigNode"` + Proxmox TalosProxmoxConfig `yaml:"proxmox"` + Cluster TalosClusterConfig `yaml:"cluster"` + Image TalosImageConfig `yaml:"image"` + Nodes []TalosNode `yaml:"nodes"` +} + +type TalosProxmoxConfig struct { + APIURL string `yaml:"apiUrl"` + APIToken string `yaml:"apiToken"` + DefaultNode string `yaml:"defaultNode"` + Pool string `yaml:"pool"` + NodeInterfaces map[string]string `yaml:"nodeInterfaces"` +} + +type TalosClusterConfig struct { + Name string `yaml:"name"` + Domain string `yaml:"domain"` + ControlPlaneVIP string `yaml:"controlPlaneVip"` + CNI string `yaml:"cni"` + DNSServers []string `yaml:"dnsServers"` + DiskStorage string `yaml:"diskStorage"` + AdditionalStorage string `yaml:"additionalStorage"` + CreateVLANInterface bool `yaml:"createVlanInterface"` +} + +type TalosImageConfig struct { + FactorySchematicID string `yaml:"factorySchematicId"` + TalosVersion string `yaml:"talosVersion"` + TalosISOFile string `yaml:"talosIsoFile"` + UpdateMode string `yaml:"updateMode"` + Storage string `yaml:"storage"` + Architecture string `yaml:"architecture"` +} + +type TalosNode struct { + Name string `yaml:"name"` + VMID int `yaml:"vmid"` + Role string `yaml:"role"` + Cores int `yaml:"cores"` + Memory int `yaml:"memory"` + DiskSize string `yaml:"diskSize"` + AdditionalDiskSize string `yaml:"additionalDiskSize,omitempty"` + Tags []string `yaml:"tags"` + ProxmoxNode string `yaml:"proxmoxNode"` + Networks []TalosNetwork `yaml:"networks"` +} + +type TalosNetwork struct { + MACAddress string `yaml:"macAddress"` + CIDR string `yaml:"cidr"` + IP string `yaml:"ip,omitempty"` + Gateway string `yaml:"gateway,omitempty"` + VLANID int `yaml:"vlanId"` +} diff --git a/internal/github/repository.go b/internal/github/repository.go index fb48942..ccdbe54 100644 --- a/internal/github/repository.go +++ b/internal/github/repository.go @@ -154,6 +154,12 @@ func (rm *RepoManager) InitializeFluxRepo() { rm.setupRepository(fullRepoName, rm.createFluxStructure) } +func (rm *RepoManager) InitializeAll() error { + rm.InitializeManifestsRepo() + rm.InitializeFluxRepo() + return nil +} + func (rm *RepoManager) setupRepository(fullRepoName string, structureFunc func(string)) { tempDir, err := os.MkdirTemp("", "repo-setup-*") if err != nil { diff --git a/internal/ui/wizard.go b/internal/ui/wizard.go new file mode 100644 index 0000000..5b38381 --- /dev/null +++ b/internal/ui/wizard.go @@ -0,0 +1,164 @@ +package ui + +import ( + "bufio" + "fmt" + "os" + "strconv" + "strings" + + "github.com/Pingu-Studio/MaidnCLI/internal/config" +) + +func RunBootstrapWizard() (config.Config, error) { + reader := bufio.NewReader(os.Stdin) + cfg := config.Config{} + + cfg.WorkspaceDir = prompt(reader, "Workspace directory", ".\\maidn-workspace") + cfg.GitHub.Org = prompt(reader, "GitHub organization", "Pingu-Studio") + cfg.Flux.Owner = cfg.GitHub.Org + cfg.Flux.RepoName = prompt(reader, "Flux repo name", "mcast-maidn-cd-cluster") + cfg.Flux.Branch = prompt(reader, "Flux branch", "main") + cfg.Flux.ClusterPath = prompt(reader, "Flux cluster path", "./clusters/maidn-cd-0") + cfg.Flux.ClusterDomain = prompt(reader, "Flux cluster domain", "dev01.nid3.com") + + cfg.Talos.RepoURL = prompt(reader, "Talos IaC repo URL", "https://github.com/Pingu-Studio/maidn-talos-proxmox.git") + cfg.Talos.RepoRef = prompt(reader, "Talos IaC repo ref", "main") + cfg.Talos.RepoDirName = prompt(reader, "Talos IaC checkout directory", "maidn-talos-proxmox") + cfg.Talos.TerraformDir = prompt(reader, "Terraform subdirectory", "terraform") + cfg.Talos.GeneratedDir = prompt(reader, "Generated subdirectory", "generated") + cfg.Talos.ConfigFileName = prompt(reader, "Terraform config filename", "terraform.tfvars") + cfg.Talos.AutoRunTerraform = promptBool(reader, "Run terraform automatically", true) + cfg.Talos.AutoBootstrap = promptBool(reader, "Run talos bootstrap automatically", true) + cfg.Talos.AutoBootstrapFlux = promptBool(reader, "Run flux bootstrap automatically", true) + + cfg.Talos.Proxmox.APIURL = prompt(reader, "Proxmox API URL", "https://192.168.0.9:8006/api2/json") + cfg.Talos.Proxmox.APIToken = prompt(reader, "Proxmox API token", "") + cfg.Talos.Proxmox.DefaultNode = prompt(reader, "Default Proxmox node", "pingu4") + cfg.Talos.Proxmox.Pool = prompt(reader, "Proxmox pool", "") + cfg.Talos.Proxmox.NodeInterfaces = promptMap(reader, "Node interfaces map (node=iface,comma separated)", map[string]string{"pingu4": "enp6s0"}) + + cfg.Talos.Cluster.Name = prompt(reader, "Talos cluster name", "proxmox-talos-dev01-cluster") + cfg.Talos.Cluster.Domain = prompt(reader, "Talos cluster domain", "dev01.nid3.com") + cfg.Talos.Cluster.ControlPlaneVIP = prompt(reader, "Control plane VIP", "192.168.43.7") + cfg.Talos.Cluster.CNI = prompt(reader, "CNI", "flannel") + cfg.Talos.Cluster.DNSServers = promptList(reader, "DNS servers (comma separated)", []string{"192.168.0.222", "1.1.1.1"}) + cfg.Talos.Cluster.DiskStorage = prompt(reader, "Primary disk storage", "local-lvm") + cfg.Talos.Cluster.AdditionalStorage = prompt(reader, "Additional disk storage", "local-lvm") + cfg.Talos.Cluster.CreateVLANInterface = promptBool(reader, "Create VLAN interfaces", true) + + cfg.Talos.Image.FactorySchematicID = prompt(reader, "Talos factory schematic ID", "ddf38e55e30aa9b2bb0b765054ed63444dc00244ab8bb4ad4ad92486602285f8") + cfg.Talos.Image.TalosVersion = prompt(reader, "Talos version", "v1.11.1") + cfg.Talos.Image.TalosISOFile = prompt(reader, "Talos ISO file", "local:iso/talos-1.11.1.iso") + cfg.Talos.Image.UpdateMode = prompt(reader, "Talos image update mode (manual/download)", "manual") + cfg.Talos.Image.Storage = prompt(reader, "Talos image storage", "local") + cfg.Talos.Image.Architecture = prompt(reader, "Talos image architecture", "amd64") + + cfg.Talos.Nodes = promptNodes(reader, cfg.Talos.Proxmox.DefaultNode) + if len(cfg.Talos.Nodes) > 0 { + cfg.Talos.BootstrapNode = prompt(reader, "Talos bootstrap node IP", cfg.Talos.Nodes[0].Networks[0].IP) + cfg.Talos.KubeconfigNode = prompt(reader, "Talos kubeconfig node IP", cfg.Talos.Nodes[0].Networks[0].IP) + } + + return cfg, nil +} + +func prompt(reader *bufio.Reader, label, fallback string) string { + if fallback != "" { + fmt.Printf("%s [%s]: ", label, fallback) + } else { + fmt.Printf("%s: ", label) + } + value, _ := reader.ReadString('\n') + value = strings.TrimSpace(value) + if value == "" { + return fallback + } + return value +} + +func promptBool(reader *bufio.Reader, label string, fallback bool) bool { + defaultValue := "y" + if !fallback { + defaultValue = "n" + } + fmt.Printf("%s [y/n, default %s]: ", label, defaultValue) + value, _ := reader.ReadString('\n') + value = strings.TrimSpace(strings.ToLower(value)) + if value == "" { + return fallback + } + return value == "y" || value == "yes" +} + +func promptList(reader *bufio.Reader, label string, fallback []string) []string { + value := prompt(reader, label, strings.Join(fallback, ",")) + parts := strings.Split(value, ",") + out := make([]string, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part != "" { + out = append(out, part) + } + } + return out +} + +func promptMap(reader *bufio.Reader, label string, fallback map[string]string) map[string]string { + pairs := make([]string, 0, len(fallback)) + for key, value := range fallback { + pairs = append(pairs, key+"="+value) + } + value := prompt(reader, label, strings.Join(pairs, ",")) + result := map[string]string{} + for _, pair := range strings.Split(value, ",") { + parts := strings.SplitN(strings.TrimSpace(pair), "=", 2) + if len(parts) == 2 && parts[0] != "" && parts[1] != "" { + result[parts[0]] = parts[1] + } + } + return result +} + +func promptNodes(reader *bufio.Reader, defaultNode string) []config.TalosNode { + countValue := prompt(reader, "Number of Talos nodes", "3") + count, err := strconv.Atoi(countValue) + if err != nil || count < 1 { + count = 3 + } + result := make([]config.TalosNode, 0, count) + for i := 0; i < count; i++ { + name := prompt(reader, fmt.Sprintf("Node %d name", i+1), fmt.Sprintf("talos-node-%02d", i+1)) + role := prompt(reader, fmt.Sprintf("Node %d role", i+1), map[bool]string{i == 0: "controlplane", i != 0: "worker"}[true]) + vmid, _ := strconv.Atoi(prompt(reader, fmt.Sprintf("Node %d VMID", i+1), fmt.Sprintf("10%d", 50+i))) + cores, _ := strconv.Atoi(prompt(reader, fmt.Sprintf("Node %d cores", i+1), "4")) + memory, _ := strconv.Atoi(prompt(reader, fmt.Sprintf("Node %d memory MB", i+1), "8192")) + diskSize := prompt(reader, fmt.Sprintf("Node %d disk size", i+1), "64G") + proxmoxNode := prompt(reader, fmt.Sprintf("Node %d Proxmox node", i+1), defaultNode) + tags := promptList(reader, fmt.Sprintf("Node %d tags", i+1), []string{"talos", role}) + mac := prompt(reader, fmt.Sprintf("Node %d primary MAC", i+1), "") + cidr := prompt(reader, fmt.Sprintf("Node %d primary CIDR", i+1), "192.168.43.0/28") + ip := prompt(reader, fmt.Sprintf("Node %d primary IP", i+1), "") + gateway := prompt(reader, fmt.Sprintf("Node %d gateway", i+1), "192.168.43.1") + vlanID, _ := strconv.Atoi(prompt(reader, fmt.Sprintf("Node %d VLAN ID", i+1), "43")) + + result = append(result, config.TalosNode{ + Name: name, + VMID: vmid, + Role: role, + Cores: cores, + Memory: memory, + DiskSize: diskSize, + Tags: tags, + ProxmoxNode: proxmoxNode, + Networks: []config.TalosNetwork{{ + MACAddress: mac, + CIDR: cidr, + IP: ip, + Gateway: gateway, + VLANID: vlanID, + }}, + }) + } + return result +}