package ui import ( "bufio" "encoding/base64" "encoding/json" "errors" "fmt" "os" "strconv" "strings" "github.com/Pingu-Studio/MaidnCLI/internal/config" "github.com/Pingu-Studio/MaidnCLI/internal/proxmox" "github.com/Pingu-Studio/MaidnCLI/internal/talos" ) func RunBootstrapWizard(initial config.Config) (config.Config, error) { reader := bufio.NewReader(os.Stdin) cfg := initial cwd, _ := os.Getwd() if cfg.WorkspaceDir == "" { cfg.WorkspaceDir = cwd } cfg.Git.Provider = "forgejo" cfg.Git.BaseURL = prompt(reader, "Git server URL", fallback(cfg.Git.BaseURL, "https://git.pingu.pw")) cfg.Git.Username = prompt(reader, "Git username", fallback(cfg.Git.Username, "eding")) cfg.Git.Owner = prompt(reader, "Repo owner/user", fallback(cfg.Git.Owner, "Maidn")) cfg.Git.Token = promptSecret(reader, "Forgejo PAT", cfg.Git.Token) cfg.WorkspaceDir = prompt(reader, "Working directory", cfg.WorkspaceDir) cfg.Git.CloneParent = prompt(reader, "Where should repos be cloned locally", fallback(cfg.Git.CloneParent, cfg.WorkspaceDir)) cfg.Flux.RepoName = prompt(reader, "Flux repo name", fallback(cfg.Flux.RepoName, "test-maidn-cd-cluster")) cfg.Flux.ManifestsRepo = prompt(reader, "Manifests repo name", fallback(cfg.Flux.ManifestsRepo, "cicd-deployment-manifests")) cfg.Flux.Branch = "main" cfg.Flux.ClusterPath = "./clusters/maidn-cd-0" cfg.Templates.TalosRepoURL = prompt(reader, "Talos template repo URL", fallback(cfg.Templates.TalosRepoURL, "https://git.pingu.pw/Maidn/maidn-talos-proxmox.git")) cfg.Templates.CICDRepoURL = prompt(reader, "CI/CD template repo URL", fallback(cfg.Templates.CICDRepoURL, "https://git.pingu.pw/Maidn/maidn-cicd-cluster-template.git")) cfg.Templates.ManifestsRepoURL = prompt(reader, "Manifests template repo URL", fallback(cfg.Templates.ManifestsRepoURL, "https://git.pingu.pw/Maidn/cicd-deployment-manifests-template.git")) fmt.Printf("Template repos are cloned under: %s\n", cfg.Git.CloneParent) cfg.Templates.TalosRepoRef = "main" cfg.Templates.CICDRepoRef = "main" cfg.Templates.ManifestsRepoRef = "main" cfg.Talos.RepoDirName = "maidn-talos-proxmox" cfg.Talos.TerraformDir = "terraform" cfg.Talos.GeneratedDir = "generated" cfg.Talos.ConfigFileName = "terraform.tfvars" cfg.Talos.AutoRunTerraform = promptBool(reader, "Run terraform automatically", cfg.Talos.AutoRunTerraform || !hasInitial(initial)) cfg.Talos.AutoBootstrap = promptBool(reader, "Run talos bootstrap automatically", cfg.Talos.AutoBootstrap || !hasInitial(initial)) cfg.Talos.AutoBootstrapFlux = promptBool(reader, "Run flux bootstrap automatically", cfg.Talos.AutoBootstrapFlux || !hasInitial(initial)) cfg.Talos.Proxmox.APIURL = prompt(reader, "Proxmox API URL", fallback(cfg.Talos.Proxmox.APIURL, "https://192.168.0.15:8006")) cfg.Talos.Proxmox.APITokenID = prompt(reader, "Proxmox API token ID", fallback(cfg.Talos.Proxmox.APITokenID, "root@pam!iac")) cfg.Talos.Proxmox.APITokenSecret = promptSecret(reader, "Proxmox API token secret", cfg.Talos.Proxmox.APITokenSecret) cfg.Talos.Proxmox.Insecure = true cfg.Talos.Proxmox.Pool = prompt(reader, "Proxmox pool", cfg.Talos.Proxmox.Pool) discovered, err := discoverProxmox(reader, cfg) if err != nil { return cfg, err } printNodes(discovered) cfg.Talos.Proxmox.DefaultNode = prompt(reader, "Default Proxmox node", fallback(cfg.Talos.Proxmox.DefaultNode, discovered[0].Name)) cfg.Talos.Proxmox.NodeInterfaces = map[string]string{} cfg.Talos.Proxmox.NodeAddresses = map[string]string{} for _, node := range discovered { fallbackIface := first(node.Networks, "vmbr0") cfg.Talos.Proxmox.NodeInterfaces[node.Name] = prompt(reader, fmt.Sprintf("Primary interface for %s", node.Name), fallbackIface) cfg.Talos.Proxmox.NodeAddresses[node.Name] = prompt(reader, fmt.Sprintf("SSH/API hostname or IP for %s", node.Name), fallback(cfg.Talos.Proxmox.NodeAddresses[node.Name], cfg.Talos.Proxmox.APIURLHost())) } cfg.Talos.Cluster.Name = prompt(reader, "Talos cluster name", fallback(cfg.Talos.Cluster.Name, "proxmox-talos-dev02-cluster")) cfg.ClusterID = prompt(reader, "Cluster ID", fallback(cfg.ClusterID, cfg.Talos.Cluster.Name)) cfg.Talos.Cluster.Domain = prompt(reader, "Talos cluster domain", fallback(cfg.Talos.Cluster.Domain, "dev02.nid3.com")) cfg.Flux.ClusterDomain = cfg.Talos.Cluster.Domain cfg.Talos.Cluster.ControlPlaneVIP = prompt(reader, "Control plane VIP", fallback(cfg.Talos.Cluster.ControlPlaneVIP, "192.168.45.2")) cfg.Talos.Cluster.DNSServers = promptList(reader, "DNS servers", fallbackList(cfg.Talos.Cluster.DNSServers, []string{"192.168.0.222", "1.1.1.1"})) cfg.Talos.Cluster.DiskStorage = prompt(reader, "Primary disk storage", fallback(cfg.Talos.Cluster.DiskStorage, firstStorageByCapability(discovered, storageSupportsDisk, "local-lvm"))) cfg.Talos.Cluster.AdditionalStorage = prompt(reader, "Additional disk storage", fallback(cfg.Talos.Cluster.AdditionalStorage, cfg.Talos.Cluster.DiskStorage)) cfg.Talos.Cluster.CreateVLANInterface = promptBool(reader, "Create VLAN interfaces", true) cfg.Talos.Cluster.ManageNetworkBridges = promptBool(reader, "Terraform manages Proxmox bridges", cfg.Talos.Cluster.ManageNetworkBridges) latestTalos := talos.LatestVersion() cfg.Talos.Image.TalosVersion = talos.NormalizeVersion(prompt(reader, "Talos version", fallback(cfg.Talos.Image.TalosVersion, latestTalos))) if err := talos.RequireCLICompatibility(cfg.Talos.Image.TalosVersion); err != nil { return cfg, err } cfg.Talos.Image.Architecture = chooseArchitecture(reader, cfg.Talos.Image.Architecture) cfg.Talos.Image.KubernetesVersion = prompt(reader, "Kubernetes version", fallback(cfg.Talos.Image.KubernetesVersion, "v1.33.4")) fmt.Printf("Talos factory: %s\n", talos.FactoryURL(cfg.Talos.Image.TalosVersion, fallback(cfg.Talos.Image.Architecture, "amd64"))) schematicID, err := talos.ResolveSchematicID() if err != nil { return cfg, err } cfg.Talos.Image.SchematicID = schematicID fmt.Printf("Talos schematic: %s\n", cfg.Talos.Image.SchematicID) cfg.Talos.Image.UpdateMode = "download" cfg.Talos.Image.Storage = prompt(reader, "ISO storage", fallback(cfg.Talos.Image.Storage, firstStorageByCapability(discovered, storageSupportsISO, "local"))) nodePreset := prompt(reader, "Cluster size preset (single/ha/custom)", "single") cfg.Talos.Nodes = buildNodes(reader, cfg, discovered, nodePreset) cfg.Cilium.TrafficInterface = prompt(reader, "Cilium traffic interface", fallback(cfg.Cilium.TrafficInterface, "eth1")) cfg.Cilium.LoadBalancerStart = prompt(reader, "Cilium LoadBalancer range start", cfg.Cilium.LoadBalancerStart) cfg.Cilium.LoadBalancerEnd = prompt(reader, "Cilium LoadBalancer range end", cfg.Cilium.LoadBalancerEnd) cfg = PromptDemocraticCSI(cfg) cfg.Talos.BootstrapNode = cfg.Talos.Nodes[0].Networks[0].IP cfg.Talos.BootstrapEndpoint = cfg.Talos.BootstrapNode cfg.Talos.KubeconfigNode = cfg.Talos.Nodes[0].Networks[0].IP cfg.Talos.KubeconfigEndpoint = cfg.Talos.KubeconfigNode return cfg, nil } func PromptDemocraticCSI(cfg config.Config) config.Config { reader := bufio.NewReader(os.Stdin) cfg.DemocraticCSI.TrueNASAPIKey = promptSecret(reader, "TrueNAS API key", cfg.DemocraticCSI.TrueNASAPIKey) cfg.DemocraticCSI.TrueNASHost = prompt(reader, "TrueNAS host", cfg.DemocraticCSI.TrueNASHost) cfg.DemocraticCSI.TargetPortal = prompt(reader, "TrueNAS target portal", fallback(cfg.DemocraticCSI.TargetPortal, cfg.DemocraticCSI.TrueNASHost+":3260")) cfg.DemocraticCSI.ShareHost = prompt(reader, "TrueNAS NFS share host", fallback(cfg.DemocraticCSI.ShareHost, cfg.DemocraticCSI.TrueNASHost)) cfg.DemocraticCSI.DatasetParentNFS = prompt(reader, "TrueNAS NFS dataset parent", cfg.DemocraticCSI.DatasetParentNFS) cfg.DemocraticCSI.DatasetSnapshotsNFS = prompt(reader, "TrueNAS NFS snapshots dataset", cfg.DemocraticCSI.DatasetSnapshotsNFS) cfg.DemocraticCSI.AllowedNetworks = prompt(reader, "TrueNAS allowed NFS network", cfg.DemocraticCSI.AllowedNetworks) cfg.DemocraticCSI.NameSuffix = prompt(reader, "TrueNAS name suffix", fallback(cfg.DemocraticCSI.NameSuffix, "-"+cfg.ClusterID)) cfg.DemocraticCSI.PortalGroup = prompt(reader, "TrueNAS portal group", fallback(cfg.DemocraticCSI.PortalGroup, "1")) cfg.DemocraticCSI.InitiatorGroup = prompt(reader, "TrueNAS initiator group", fallback(cfg.DemocraticCSI.InitiatorGroup, "1")) return cfg } func PromptOperationalSecrets(cfg config.Config) (map[string]map[string]string, error) { if cfg.Git.Username == "" || cfg.Git.Token == "" || cfg.Delivery.ImageRepository == "" { return nil, errors.New("git username, token, and delivery imageRepository are required") } reader := bufio.NewReader(os.Stdin) piholeServer := prompt(reader, "Pi-hole server", "") piholePassword := promptSecret(reader, "Pi-hole password", "") cloudflareAPIToken := promptSecret(reader, "Cloudflare DNS-01 API token", "") cloudflareTunnelToken := promptSecret(reader, "Cloudflare Tunnel token", "") if piholeServer == "" || piholePassword == "" || cloudflareAPIToken == "" || cloudflareTunnelToken == "" { return nil, errors.New("Pi-hole server, password, Cloudflare DNS-01 API token, and Cloudflare Tunnel token are required") } registryHost := strings.Split(cfg.Delivery.ImageRepository, "/")[0] dockerConfig, err := json.Marshal(map[string]map[string]map[string]string{ "auths": { registryHost: {"auth": base64.StdEncoding.EncodeToString([]byte(cfg.Git.Username + ":" + cfg.Git.Token))}, }, }) if err != nil { return nil, err } return map[string]map[string]string{ "cicd/forgejo": {"username": cfg.Git.Username, "token": cfg.Git.Token}, "cicd/forgejo-registry": {"dockerconfigjson": string(dockerConfig)}, "platform/pihole": {"server": piholeServer, "password": piholePassword}, "platform/cloudflare": {"api-token": cloudflareAPIToken}, "platform/cloudflare-tunnel": {"token": cloudflareTunnelToken}, }, nil } func printNodes(nodes []proxmox.NodeInfo) { fmt.Println("Available Proxmox nodes:") for _, node := range nodes { fmt.Printf("- %s: free %dMB RAM, total %dMB RAM, free root %dGB, networks=%s\n", node.Name, node.FreeMemoryMB, node.TotalMemoryMB, node.FreeDiskGB, strings.Join(node.Networks, ",")) } } func chooseArchitecture(reader *bufio.Reader, current string) string { fmt.Println("Architecture options: 1) amd64 2) arm64") defaultChoice := "1" if current == "arm64" { defaultChoice = "2" } choice := prompt(reader, "Architecture", defaultChoice) if choice == "2" { return "arm64" } return "amd64" } func buildNodes(reader *bufio.Reader, cfg config.Config, discovered []proxmox.NodeInfo, preset string) []config.TalosNode { switch preset { case "ha": return buildPresetNodes(reader, cfg, discovered, 3, 0) case "custom": count, _ := strconv.Atoi(prompt(reader, "Number of nodes", "3")) if count < 1 { count = 1 } workers := count - 1 if workers < 0 { workers = 0 } return buildPresetNodes(reader, cfg, discovered, count, workers) default: return buildPresetNodes(reader, cfg, discovered, 1, 0) } } func buildPresetNodes(reader *bufio.Reader, cfg config.Config, discovered []proxmox.NodeInfo, count, workers int) []config.TalosNode { result := make([]config.TalosNode, 0, count) for i := 0; i < count; i++ { role := "controlplane" if i > 0 && i <= workers { role = "worker" } name := fmt.Sprintf("talos-%s-%02d", map[bool]string{role == "controlplane": "cp", role != "controlplane": "wk"}[true], i+1) defaultNode := discovered[min(i, len(discovered)-1)].Name proxmoxNode := prompt(reader, fmt.Sprintf("Node %d Proxmox node", i+1), defaultNode) memoryDefault := "4096" coresDefault := "2" diskDefault := "48G" if role == "worker" { memoryDefault = "2048" coresDefault = "2" diskDefault = "32G" } ip := prompt(reader, fmt.Sprintf("Node %d IP", i+1), "") gateway := prompt(reader, fmt.Sprintf("Node %d gateway", i+1), "192.168.45.1") cidr := prompt(reader, fmt.Sprintf("Node %d CIDR", i+1), "192.168.45.0/24") vlanID, _ := strconv.Atoi(prompt(reader, fmt.Sprintf("Node %d VLAN ID", i+1), "45")) vmid, _ := strconv.Atoi(prompt(reader, fmt.Sprintf("Node %d VMID", i+1), fmt.Sprintf("12%02d", i))) memory, _ := strconv.Atoi(prompt(reader, fmt.Sprintf("Node %d memory MB", i+1), memoryDefault)) cores, _ := strconv.Atoi(prompt(reader, fmt.Sprintf("Node %d cores", i+1), coresDefault)) trafficIP := prompt(reader, fmt.Sprintf("Node %d Cilium traffic IP", i+1), "") trafficCIDR := prompt(reader, fmt.Sprintf("Node %d Cilium traffic CIDR", i+1), "192.168.45.16/28") trafficVLAN, _ := strconv.Atoi(prompt(reader, fmt.Sprintf("Node %d Cilium traffic VLAN ID", i+1), "451")) result = append(result, config.TalosNode{ Name: name, VMID: vmid, Role: role, Cores: cores, Memory: memory, DiskSize: prompt(reader, fmt.Sprintf("Node %d disk size", i+1), diskDefault), Tags: []string{"talos", role}, ProxmoxNode: proxmoxNode, Networks: []config.TalosNetwork{ { MACAddress: prompt(reader, fmt.Sprintf("Node %d primary MAC", i+1), ""), CIDR: cidr, IP: ip, Gateway: gateway, VLANID: vlanID, }, { MACAddress: prompt(reader, fmt.Sprintf("Node %d Cilium traffic MAC", i+1), ""), CIDR: trafficCIDR, IP: trafficIP, VLANID: trafficVLAN, }, }, }) } return result } 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') fmt.Println() 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" } value := prompt(reader, label+" [y/n, default "+defaultValue+"]", "") if value == "" { return fallback } value = strings.ToLower(value) 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 fallback(value, defaultValue string) string { if value == "" { return defaultValue } return value } func fallbackList(value, defaultValue []string) []string { if len(value) == 0 { return defaultValue } return value } func hasInitial(cfg config.Config) bool { return cfg.Talos.Proxmox.APIURL != "" || cfg.Git.Token != "" } func first(values []string, fallback string) string { if len(values) == 0 { return fallback } return values[0] } func firstStorageByCapability(nodes []proxmox.NodeInfo, predicate func(proxmox.StorageInfo) bool, fallback string) string { for _, node := range nodes { for _, storage := range node.Storages { if predicate(storage) { return storage.Name } } } return fallback } func storageSupportsISO(storage proxmox.StorageInfo) bool { return storage.SupportsISO } func storageSupportsDisk(storage proxmox.StorageInfo) bool { return storage.SupportsDisk } func min(a, b int) int { if a < b { return a } return b }