From ef654073d60a7011d3cdaa07b96fe5f86742858a Mon Sep 17 00:00:00 2001 From: eding Date: Sun, 19 Jul 2026 08:43:37 +0200 Subject: [PATCH] fix: improve talos bootstrap flow --- README.md | 5 +- cmd/bootstrap.go | 2 +- internal/bootstrap/bootstrap.go | 10 ++- internal/config/config.go | 4 +- internal/config/types.go | 10 +-- internal/forgejo/repo.go | 6 +- internal/proxmox/client.go | 70 ++++++++++++++----- internal/talos/releases.go | 78 ++++++++++++++++++++- internal/ui/prompts.go | 27 ++++--- internal/ui/wizard.go | 120 +++++++++++++++++++++----------- internal/ui/wizard_proxmox.go | 26 +++++++ 11 files changed, 279 insertions(+), 79 deletions(-) create mode 100644 internal/ui/wizard_proxmox.go diff --git a/README.md b/README.md index 3ca2f9f..c300564 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ dlv version ## Commands - `cicd-tool repo init --org --flux-repo ` creates the manifests and Flux repos -- `cicd-tool bootstrap` runs a shorter Forgejo-first wizard, discovers Proxmox nodes/storage/networks, shows the latest Talos version, writes `terraform.tfvars`, stages Talos images on Proxmox, and can execute Terraform, Talos bootstrap, and Flux bootstrap +- `cicd-tool bootstrap` runs a shorter Forgejo-first wizard, asks for a Forgejo PAT, asks where local repos should be cloned, discovers Proxmox nodes/storage/networks, retries without losing entered answers when discovery fails, shows the latest Talos version, derives the standardized Talos factory URL, schematic, and required extensions automatically from the chosen version, writes `terraform.tfvars`, stages Talos images on Proxmox, and can execute Terraform, Talos bootstrap, and Flux bootstrap - `cicd-tool bootstrap --config maidn-bootstrap.yaml` skips the wizard and uses the saved config ## Forgejo setup @@ -26,3 +26,6 @@ For `https://git.pingu.pw` you need: - an owner target (`Maidn` org by default, or your own user/org) - git/ssh access from the machine running the CLI if you want SSH later - Flux bootstrap credentials for the repo URL that gets created + +root@pam!maidn-test-key +2ce7bff1-ac98-4a45-8db4-186d49ae4159 \ No newline at end of file diff --git a/cmd/bootstrap.go b/cmd/bootstrap.go index 103c260..dcc7fdb 100644 --- a/cmd/bootstrap.go +++ b/cmd/bootstrap.go @@ -32,7 +32,7 @@ func runBootstrap(cmd *cobra.Command, args []string) { if bootstrapConfigPath != "" { cfg, err = config.Load(bootstrapConfigPath) } else { - cfg, err = ui.RunBootstrapWizard() + cfg, err = ui.RunBootstrapWizard(config.Config{}) if err == nil { err = config.Save(bootstrapOutputPath, cfg) if err == nil { diff --git a/internal/bootstrap/bootstrap.go b/internal/bootstrap/bootstrap.go index 4f37085..49ad23c 100644 --- a/internal/bootstrap/bootstrap.go +++ b/internal/bootstrap/bootstrap.go @@ -21,6 +21,14 @@ func (r Runner) Run() error { if err := os.MkdirAll(workspace, 0755); err != nil { return err } + if err := config.Save(filepath.Join(workspace, "maidn-bootstrap.resolved.yaml"), r.Config); err != nil { + return err + } + return err + } + if err := os.MkdirAll(r.Config.Git.CloneParent, 0755); err != nil { + return err + } manifestsURL := forgejo.CloneURL(r.Config.Git.BaseURL, r.Config.Git.Owner, r.Config.Flux.ManifestsRepo) fluxConfig := ghrepo.BuildFluxConfig(strings.ToLower(r.Config.Git.Owner), manifestsURL, r.Config.Flux.ManifestsRepo) @@ -88,7 +96,7 @@ func renderTerraformTFVars(cfg config.Config) string { 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_factory_schematic_id = %q\n", cfg.Talos.Image.SchematicID)) builder.WriteString(fmt.Sprintf("talos_version = %q\n", cfg.Talos.Image.TalosVersion)) builder.WriteString("cni_name = \"flannel\"\n") builder.WriteString(fmt.Sprintf("talos_iso_file = %q\n", fmt.Sprintf("%s:iso/talos-%s.iso", cfg.Talos.Image.Storage, strings.TrimPrefix(cfg.Talos.Image.TalosVersion, "v")))) diff --git a/internal/config/config.go b/internal/config/config.go index 1fb75b1..260d791 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -102,8 +102,8 @@ func Validate(cfg Config) error { 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 == "" { - return errors.New("talos image pins are required") + if cfg.Talos.Image.TalosVersion == "" || cfg.Talos.Image.SchematicID == "" { + return errors.New("talos version and schematicId are required") } if cfg.Talos.Image.UpdateMode != "manual" && cfg.Talos.Image.UpdateMode != "download" { return errors.New("talos image updateMode must be manual or download") diff --git a/internal/config/types.go b/internal/config/types.go index 1bf36bd..e85383b 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -69,11 +69,11 @@ type TalosClusterConfig struct { } type TalosImageConfig struct { - FactorySchematicID string `yaml:"factorySchematicId"` - TalosVersion string `yaml:"talosVersion"` - UpdateMode string `yaml:"updateMode"` - Storage string `yaml:"storage"` - Architecture string `yaml:"architecture"` + TalosVersion string `yaml:"talosVersion"` + SchematicID string `yaml:"schematicId"` + UpdateMode string `yaml:"updateMode"` + Storage string `yaml:"storage"` + Architecture string `yaml:"architecture"` } type TalosNode struct { diff --git a/internal/forgejo/repo.go b/internal/forgejo/repo.go index d87620e..d060273 100644 --- a/internal/forgejo/repo.go +++ b/internal/forgejo/repo.go @@ -28,6 +28,7 @@ type createRepoRequest struct { Description string `json:"description"` Private bool `json:"private"` AutoInit bool `json:"auto_init"` + DefaultBranch string `json:"default_branch"` } func NewRepoManager(baseURL, token, owner, username, manifestsRepo, fluxRepo string) *RepoManager { @@ -59,7 +60,7 @@ func (rm *RepoManager) ensureRepo(name, description string, createStructure func if rm.Owner == rm.Username { createURL = fmt.Sprintf("%s/api/v1/user/repos", rm.BaseURL) } - body, _ := json.Marshal(createRepoRequest{Name: name, Description: description, Private: true, AutoInit: true}) + body, _ := json.Marshal(createRepoRequest{Name: name, Description: description, Private: true, AutoInit: false, DefaultBranch: "main"}) if apiErr := rm.apiRequest(http.MethodPost, createURL, body); apiErr != nil { return apiErr } @@ -76,6 +77,9 @@ func (rm *RepoManager) setupRepository(repoURL, repoName string, createStructure if err := utils.RunCommand("git", "clone", repoURL, tempDir); err != nil { return err } + if err := utils.RunCommandInDir(tempDir, "git", "checkout", "-B", "main"); err != nil { + return err + } if err := createStructure(tempDir); err != nil { return err } diff --git a/internal/proxmox/client.go b/internal/proxmox/client.go index efa520e..12b6f50 100644 --- a/internal/proxmox/client.go +++ b/internal/proxmox/client.go @@ -4,6 +4,7 @@ import ( "crypto/tls" "encoding/json" "fmt" + "io" "net/http" "net/url" "sort" @@ -12,10 +13,27 @@ import ( ) type Client struct { - baseURL string - tokenID string + baseURL string + tokenID string tokenSecret string - httpClient *http.Client + httpClient *http.Client +} + +type APIError struct { + StatusCode int + Message string + Path string +} + +func (e APIError) Error() string { + if e.Message == "" { + return fmt.Sprintf("proxmox returned %d for %s", e.StatusCode, e.Path) + } + return fmt.Sprintf("proxmox returned %d for %s: %s", e.StatusCode, e.Path, e.Message) +} + +func (e APIError) IsPermissionDenied() bool { + return e.StatusCode == http.StatusForbidden } type clusterNodesResponse struct { @@ -57,19 +75,20 @@ type storageResponse struct { } type NodeInfo struct { - Name string - Networks []string - Storages []StorageInfo - FreeMemoryMB int + Name string + Networks []string + Storages []StorageInfo + FreeMemoryMB int TotalMemoryMB int - FreeDiskGB int + FreeDiskGB int } type StorageInfo struct { - Name string - Type string - FreeGB int - SupportsISO bool + Name string + Type string + FreeGB int + SupportsISO bool + SupportsDisk bool } func New(baseURL, tokenID, tokenSecret string, insecure bool) *Client { @@ -97,10 +116,17 @@ func (c *Client) Discover() ([]NodeInfo, error) { } info, err := c.describeNode(node.Node) if err != nil { + var apiErr APIError + if ok := AsAPIError(err, &apiErr); ok && apiErr.IsPermissionDenied() { + continue + } return nil, err } result = append(result, info) } + if len(result) == 0 { + return nil, fmt.Errorf("no Proxmox nodes could be inspected; token likely needs Sys.Audit on target nodes") + } sort.Slice(result, func(i, j int) bool { return result[i].Name < result[j].Name }) return result, nil } @@ -131,10 +157,11 @@ func (c *Client) describeNode(node string) (NodeInfo, error) { } for _, storage := range storages.Data { info.Storages = append(info.Storages, StorageInfo{ - Name: storage.Storage, - Type: storage.Type, - FreeGB: int(storage.Avail / 1024 / 1024 / 1024), - SupportsISO: storage.Type == "dir" || storage.Type == "nfs", + Name: storage.Storage, + Type: storage.Type, + FreeGB: int(storage.Avail / 1024 / 1024 / 1024), + SupportsISO: storage.Type == "dir" || storage.Type == "nfs", + SupportsDisk: storage.Type == "dir" || storage.Type == "nfs" || storage.Type == "lvm" || storage.Type == "lvmthin" || storage.Type == "zfspool" || storage.Type == "btrfs", }) } return info, nil @@ -152,11 +179,20 @@ func (c *Client) getJSON(path string, target any) error { } defer response.Body.Close() if response.StatusCode >= 300 { - return fmt.Errorf("proxmox returned %s", response.Status) + body, _ := io.ReadAll(response.Body) + return APIError{StatusCode: response.StatusCode, Message: strings.TrimSpace(string(body)), Path: path} } return json.NewDecoder(response.Body).Decode(target) } +func AsAPIError(err error, target *APIError) bool { + apiErr, ok := err.(APIError) + if ok { + *target = apiErr + } + return ok +} + func BuildAPIURL(host string) string { if strings.HasPrefix(host, "http") { return strings.TrimRight(host, "/") diff --git a/internal/talos/releases.go b/internal/talos/releases.go index e3d80e2..44e306b 100644 --- a/internal/talos/releases.go +++ b/internal/talos/releases.go @@ -1,16 +1,42 @@ package talos import ( + "bytes" "encoding/json" "fmt" "net/http" + "strings" "time" ) +const ( + defaultPlatform = "nocloud" + defaultTarget = "cloud" +) + +var defaultExtensions = []string{ + "siderolabs/iscsi-tools", + "siderolabs/nfs-utils", + "siderolabs/qemu-guest-agent", + "siderolabs/util-linux-tools", +} + type releaseResponse struct { TagName string `json:"tag_name"` } +type schematicRequest struct { + Customization struct { + SystemExtensions struct { + OfficialExtensions []string `json:"officialExtensions"` + } `json:"systemExtensions"` + } `json:"customization"` +} + +type schematicResponse struct { + ID string `json:"id"` +} + func LatestVersion() string { client := http.Client{Timeout: 10 * time.Second} request, err := http.NewRequest(http.MethodGet, "https://api.github.com/repos/siderolabs/talos/releases/latest", nil) @@ -33,6 +59,54 @@ func LatestVersion() string { return release.TagName } -func FactoryHint(version string) string { - return fmt.Sprintf("https://factory.talos.dev/?version=%s", version) +func NormalizeVersion(version string) string { + if strings.HasPrefix(version, "v") { + return version + } + return "v" + version +} + +func FactoryURL(version, arch string) string { + version = NormalizeVersion(version) + return fmt.Sprintf("https://factory.talos.dev/?arch=%s&extensions=-&extensions=%s&extensions=%s&extensions=%s&extensions=%s&platform=%s&target=%s&version=%s", + arch, + "siderolabs%2Fiscsi-tools", + "siderolabs%2Fnfs-utils", + "siderolabs%2Fqemu-guest-agent", + "siderolabs%2Futil-linux-tools", + defaultPlatform, + defaultTarget, + strings.TrimPrefix(version, "v"), + ) +} + +func ResolveSchematicID() (string, error) { + requestBody := schematicRequest{} + requestBody.Customization.SystemExtensions.OfficialExtensions = defaultExtensions + payload, err := json.Marshal(requestBody) + if err != nil { + return "", err + } + client := http.Client{Timeout: 15 * time.Second} + request, err := http.NewRequest(http.MethodPost, "https://factory.talos.dev/schematics", bytes.NewReader(payload)) + if err != nil { + return "", err + } + request.Header.Set("Content-Type", "application/json") + response, err := client.Do(request) + if err != nil { + return "", err + } + defer response.Body.Close() + if response.StatusCode >= 300 { + return "", fmt.Errorf("talos factory returned %s", response.Status) + } + var schematic schematicResponse + if err := json.NewDecoder(response.Body).Decode(&schematic); err != nil { + return "", err + } + if schematic.ID == "" { + return "", fmt.Errorf("talos factory returned empty schematic id") + } + return schematic.ID, nil } diff --git a/internal/ui/prompts.go b/internal/ui/prompts.go index 8e27e3b..18f995d 100644 --- a/internal/ui/prompts.go +++ b/internal/ui/prompts.go @@ -86,14 +86,7 @@ func GetUserInputForGithubAuth() (username, token string, err error) { } func promptForToken(reader *bufio.Reader, prompt string) (string, error) { - fmt.Printf("%s: ", prompt) - tokenBytes, err := term.ReadPassword(int(syscall.Stdin)) - fmt.Println() - if err != nil { - return "", err - } - - token := string(tokenBytes) + token := promptSecret(reader, prompt, "") if token == "" { return "", fmt.Errorf("token cannot be empty") } @@ -118,3 +111,21 @@ func promptForToken(reader *bufio.Reader, prompt string) (string, error) { return token, nil } + +func promptSecret(reader *bufio.Reader, prompt, fallback string) string { + if fallback != "" { + fmt.Printf("%s [saved]: ", prompt) + } else { + fmt.Printf("%s: ", prompt) + } + secretBytes, err := term.ReadPassword(int(syscall.Stdin)) + fmt.Println() + if err != nil { + return fallback + } + secret := strings.TrimSpace(string(secretBytes)) + if secret == "" { + return fallback + } + return secret +} diff --git a/internal/ui/wizard.go b/internal/ui/wizard.go index e20190e..20ff3a5 100644 --- a/internal/ui/wizard.go +++ b/internal/ui/wizard.go @@ -12,64 +12,75 @@ import ( "github.com/Pingu-Studio/MaidnCLI/internal/talos" ) -func RunBootstrapWizard() (config.Config, error) { +func RunBootstrapWizard(initial config.Config) (config.Config, error) { reader := bufio.NewReader(os.Stdin) - cfg := config.Config{} - cfg.WorkspaceDir = prompt(reader, "Workspace directory", ".\\maidn-workspace") + cfg := initial + cwd, _ := os.Getwd() + if cfg.WorkspaceDir == "" { + cfg.WorkspaceDir = cwd + } cfg.Git.Provider = "forgejo" - cfg.Git.BaseURL = prompt(reader, "Git server URL", "https://git.pingu.pw") - cfg.Git.Username = prompt(reader, "Git username", "eding") - cfg.Git.Owner = prompt(reader, "Repo owner/user", "Maidn") - cfg.Git.Token = prompt(reader, "Forgejo access token", "") - cfg.Git.CloneParent = prompt(reader, "Clone directory", cfg.WorkspaceDir) - cfg.Flux.RepoName = prompt(reader, "Flux repo name", "test-maidn-cd-cluster") - cfg.Flux.ManifestsRepo = prompt(reader, "Manifests repo name", "cicd-deployment-manifests") + 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", "https://git.pingu.pw/Maidn/maidn-talos-proxmox.git") + cfg.Templates.TalosRepoURL = prompt(reader, "Talos template repo URL", fallback(cfg.Templates.TalosRepoURL, "https://git.pingu.pw/Maidn/maidn-talos-proxmox.git")) + fmt.Printf("Template repos are cloned under: %s\n", cfg.Git.CloneParent) cfg.Templates.TalosRepoRef = "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", true) - cfg.Talos.AutoBootstrap = promptBool(reader, "Run talos bootstrap automatically", true) - cfg.Talos.AutoBootstrapFlux = promptBool(reader, "Run flux bootstrap automatically", true) + 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", "https://192.168.0.15:8006") - cfg.Talos.Proxmox.APITokenID = prompt(reader, "Proxmox API token ID", "root@pam!iac") - cfg.Talos.Proxmox.APITokenSecret = prompt(reader, "Proxmox API token secret", "") + 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 = prompt(reader, "Proxmox pool", cfg.Talos.Proxmox.Pool) - discovered, err := proxmox.New(cfg.Talos.Proxmox.APIURL, cfg.Talos.Proxmox.APITokenID, cfg.Talos.Proxmox.APITokenSecret, cfg.Talos.Proxmox.Insecure).Discover() + discovered, err := discoverProxmox(reader, cfg) if err != nil { return cfg, err } + printNodes(discovered) - cfg.Talos.Proxmox.DefaultNode = prompt(reader, "Default Proxmox node", discovered[0].Name) + cfg.Talos.Proxmox.DefaultNode = prompt(reader, "Default Proxmox node", fallback(cfg.Talos.Proxmox.DefaultNode, discovered[0].Name)) cfg.Talos.Proxmox.NodeInterfaces = map[string]string{} for _, node := range discovered { - fallback := first(node.Networks, "vmbr0") - cfg.Talos.Proxmox.NodeInterfaces[node.Name] = prompt(reader, fmt.Sprintf("Primary interface for %s", node.Name), fallback) + fallbackIface := first(node.Networks, "vmbr0") + cfg.Talos.Proxmox.NodeInterfaces[node.Name] = prompt(reader, fmt.Sprintf("Primary interface for %s", node.Name), fallbackIface) } - cfg.Talos.Cluster.Name = prompt(reader, "Talos cluster name", "proxmox-talos-dev02-cluster") - cfg.Talos.Cluster.Domain = prompt(reader, "Talos cluster domain", "dev02.nid3.com") + cfg.Talos.Cluster.Name = prompt(reader, "Talos cluster name", fallback(cfg.Talos.Cluster.Name, "proxmox-talos-dev02-cluster")) + 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", "192.168.45.2") - cfg.Talos.Cluster.DNSServers = promptList(reader, "DNS servers", []string{"192.168.0.222", "1.1.1.1"}) - cfg.Talos.Cluster.DiskStorage = prompt(reader, "Primary disk storage", firstStorage(discovered, true, "local-lvm")) - cfg.Talos.Cluster.AdditionalStorage = prompt(reader, "Additional disk storage", cfg.Talos.Cluster.DiskStorage) - cfg.Talos.Cluster.CreateVLANInterface = promptBool(reader, "Create VLAN interfaces", true) + 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", cfg.Talos.Cluster.CreateVLANInterface || !hasInitial(initial)) latestTalos := talos.LatestVersion() - cfg.Talos.Image.TalosVersion = prompt(reader, "Talos version", latestTalos) - fmt.Printf("Talos factory: %s\n", talos.FactoryHint(cfg.Talos.Image.TalosVersion)) - cfg.Talos.Image.FactorySchematicID = prompt(reader, "Talos factory schematic ID", "") + cfg.Talos.Image.TalosVersion = talos.NormalizeVersion(prompt(reader, "Talos version", fallback(cfg.Talos.Image.TalosVersion, latestTalos))) + cfg.Talos.Image.Architecture = chooseArchitecture(reader, cfg.Talos.Image.Architecture) + 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", firstStorage(discovered, false, "local")) - cfg.Talos.Image.Architecture = chooseArchitecture(reader) + 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) @@ -85,9 +96,13 @@ func printNodes(nodes []proxmox.NodeInfo) { } } -func chooseArchitecture(reader *bufio.Reader) string { +func chooseArchitecture(reader *bufio.Reader, current string) string { fmt.Println("Architecture options: 1) amd64 2) arm64") - choice := prompt(reader, "Architecture", "1") + defaultChoice := "1" + if current == "arm64" { + defaultChoice = "2" + } + choice := prompt(reader, "Architecture", defaultChoice) if choice == "2" { return "arm64" } @@ -200,6 +215,24 @@ func promptList(reader *bufio.Reader, label string, fallback []string) []string 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 @@ -207,13 +240,10 @@ func first(values []string, fallback string) string { return values[0] } -func firstStorage(nodes []proxmox.NodeInfo, iso bool, fallback string) string { +func firstStorageByCapability(nodes []proxmox.NodeInfo, predicate func(proxmox.StorageInfo) bool, fallback string) string { for _, node := range nodes { for _, storage := range node.Storages { - if iso && storage.SupportsISO { - return storage.Name - } - if !iso { + if predicate(storage) { return storage.Name } } @@ -221,6 +251,14 @@ func firstStorage(nodes []proxmox.NodeInfo, iso bool, fallback string) string { 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 diff --git a/internal/ui/wizard_proxmox.go b/internal/ui/wizard_proxmox.go new file mode 100644 index 0000000..502ef79 --- /dev/null +++ b/internal/ui/wizard_proxmox.go @@ -0,0 +1,26 @@ +package ui + +import ( + "bufio" + "fmt" + + "github.com/Pingu-Studio/MaidnCLI/internal/config" + "github.com/Pingu-Studio/MaidnCLI/internal/proxmox" +) + +func discoverProxmox(reader *bufio.Reader, cfg config.Config) ([]proxmox.NodeInfo, error) { + for { + discovered, err := proxmox.New(cfg.Talos.Proxmox.APIURL, cfg.Talos.Proxmox.APITokenID, cfg.Talos.Proxmox.APITokenSecret, cfg.Talos.Proxmox.Insecure).Discover() + if err == nil { + return discovered, nil + } + fmt.Printf("[WARN] Proxmox discovery failed: %v\n", err) + fmt.Println("[WARN] Required token perms for discovery: Sys.Audit on nodes/storage/network you want listed.") + if !promptBool(reader, "Retry Proxmox discovery", true) { + return nil, err + } + cfg.Talos.Proxmox.APIURL = prompt(reader, "Proxmox API URL", cfg.Talos.Proxmox.APIURL) + cfg.Talos.Proxmox.APITokenID = prompt(reader, "Proxmox API token ID", cfg.Talos.Proxmox.APITokenID) + cfg.Talos.Proxmox.APITokenSecret = prompt(reader, "Proxmox API token secret", cfg.Talos.Proxmox.APITokenSecret) + } +}