fix: improve talos bootstrap flow

This commit is contained in:
eding 2026-07-19 08:43:37 +02:00
parent b6dce951ed
commit ef654073d6
11 changed files with 279 additions and 79 deletions

View file

@ -15,7 +15,7 @@ dlv version
## Commands ## Commands
- `cicd-tool repo init --org <org> --flux-repo <repo>` creates the manifests and Flux repos - `cicd-tool repo init --org <org> --flux-repo <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 - `cicd-tool bootstrap --config maidn-bootstrap.yaml` skips the wizard and uses the saved config
## Forgejo setup ## 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) - 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 - git/ssh access from the machine running the CLI if you want SSH later
- Flux bootstrap credentials for the repo URL that gets created - Flux bootstrap credentials for the repo URL that gets created
root@pam!maidn-test-key
2ce7bff1-ac98-4a45-8db4-186d49ae4159

View file

@ -32,7 +32,7 @@ func runBootstrap(cmd *cobra.Command, args []string) {
if bootstrapConfigPath != "" { if bootstrapConfigPath != "" {
cfg, err = config.Load(bootstrapConfigPath) cfg, err = config.Load(bootstrapConfigPath)
} else { } else {
cfg, err = ui.RunBootstrapWizard() cfg, err = ui.RunBootstrapWizard(config.Config{})
if err == nil { if err == nil {
err = config.Save(bootstrapOutputPath, cfg) err = config.Save(bootstrapOutputPath, cfg)
if err == nil { if err == nil {

View file

@ -21,6 +21,14 @@ func (r Runner) Run() error {
if err := os.MkdirAll(workspace, 0755); err != nil { if err := os.MkdirAll(workspace, 0755); err != nil {
return err 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) 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) 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("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_name = %q\n", cfg.Talos.Cluster.Name))
builder.WriteString(fmt.Sprintf("cluster_domain = %q\n", cfg.Talos.Cluster.Domain)) 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(fmt.Sprintf("talos_version = %q\n", cfg.Talos.Image.TalosVersion))
builder.WriteString("cni_name = \"flannel\"\n") 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")))) 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"))))

View file

@ -102,8 +102,8 @@ func Validate(cfg Config) error {
if cfg.Talos.Cluster.Name == "" || cfg.Talos.Cluster.Domain == "" { if cfg.Talos.Cluster.Name == "" || cfg.Talos.Cluster.Domain == "" {
return errors.New("talos cluster name and domain are required") return errors.New("talos cluster name and domain are required")
} }
if cfg.Talos.Image.FactorySchematicID == "" || cfg.Talos.Image.TalosVersion == "" { if cfg.Talos.Image.TalosVersion == "" || cfg.Talos.Image.SchematicID == "" {
return errors.New("talos image pins are required") return errors.New("talos version and schematicId are required")
} }
if cfg.Talos.Image.UpdateMode != "manual" && cfg.Talos.Image.UpdateMode != "download" { if cfg.Talos.Image.UpdateMode != "manual" && cfg.Talos.Image.UpdateMode != "download" {
return errors.New("talos image updateMode must be manual or download") return errors.New("talos image updateMode must be manual or download")

View file

@ -69,8 +69,8 @@ type TalosClusterConfig struct {
} }
type TalosImageConfig struct { type TalosImageConfig struct {
FactorySchematicID string `yaml:"factorySchematicId"`
TalosVersion string `yaml:"talosVersion"` TalosVersion string `yaml:"talosVersion"`
SchematicID string `yaml:"schematicId"`
UpdateMode string `yaml:"updateMode"` UpdateMode string `yaml:"updateMode"`
Storage string `yaml:"storage"` Storage string `yaml:"storage"`
Architecture string `yaml:"architecture"` Architecture string `yaml:"architecture"`

View file

@ -28,6 +28,7 @@ type createRepoRequest struct {
Description string `json:"description"` Description string `json:"description"`
Private bool `json:"private"` Private bool `json:"private"`
AutoInit bool `json:"auto_init"` AutoInit bool `json:"auto_init"`
DefaultBranch string `json:"default_branch"`
} }
func NewRepoManager(baseURL, token, owner, username, manifestsRepo, fluxRepo string) *RepoManager { 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 { if rm.Owner == rm.Username {
createURL = fmt.Sprintf("%s/api/v1/user/repos", rm.BaseURL) 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 { if apiErr := rm.apiRequest(http.MethodPost, createURL, body); apiErr != nil {
return apiErr return apiErr
} }
@ -76,6 +77,9 @@ func (rm *RepoManager) setupRepository(repoURL, repoName string, createStructure
if err := utils.RunCommand("git", "clone", repoURL, tempDir); err != nil { if err := utils.RunCommand("git", "clone", repoURL, tempDir); err != nil {
return err return err
} }
if err := utils.RunCommandInDir(tempDir, "git", "checkout", "-B", "main"); err != nil {
return err
}
if err := createStructure(tempDir); err != nil { if err := createStructure(tempDir); err != nil {
return err return err
} }

View file

@ -4,6 +4,7 @@ import (
"crypto/tls" "crypto/tls"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"net/http" "net/http"
"net/url" "net/url"
"sort" "sort"
@ -18,6 +19,23 @@ type Client struct {
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 { type clusterNodesResponse struct {
Data []struct { Data []struct {
Node string `json:"node"` Node string `json:"node"`
@ -70,6 +88,7 @@ type StorageInfo struct {
Type string Type string
FreeGB int FreeGB int
SupportsISO bool SupportsISO bool
SupportsDisk bool
} }
func New(baseURL, tokenID, tokenSecret string, insecure bool) *Client { 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) info, err := c.describeNode(node.Node)
if err != nil { if err != nil {
var apiErr APIError
if ok := AsAPIError(err, &apiErr); ok && apiErr.IsPermissionDenied() {
continue
}
return nil, err return nil, err
} }
result = append(result, info) 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 }) sort.Slice(result, func(i, j int) bool { return result[i].Name < result[j].Name })
return result, nil return result, nil
} }
@ -135,6 +161,7 @@ func (c *Client) describeNode(node string) (NodeInfo, error) {
Type: storage.Type, Type: storage.Type,
FreeGB: int(storage.Avail / 1024 / 1024 / 1024), FreeGB: int(storage.Avail / 1024 / 1024 / 1024),
SupportsISO: storage.Type == "dir" || storage.Type == "nfs", 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 return info, nil
@ -152,11 +179,20 @@ func (c *Client) getJSON(path string, target any) error {
} }
defer response.Body.Close() defer response.Body.Close()
if response.StatusCode >= 300 { 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) 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 { func BuildAPIURL(host string) string {
if strings.HasPrefix(host, "http") { if strings.HasPrefix(host, "http") {
return strings.TrimRight(host, "/") return strings.TrimRight(host, "/")

View file

@ -1,16 +1,42 @@
package talos package talos
import ( import (
"bytes"
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"strings"
"time" "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 { type releaseResponse struct {
TagName string `json:"tag_name"` 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 { func LatestVersion() string {
client := http.Client{Timeout: 10 * time.Second} client := http.Client{Timeout: 10 * time.Second}
request, err := http.NewRequest(http.MethodGet, "https://api.github.com/repos/siderolabs/talos/releases/latest", nil) 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 return release.TagName
} }
func FactoryHint(version string) string { func NormalizeVersion(version string) string {
return fmt.Sprintf("https://factory.talos.dev/?version=%s", version) 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
} }

View file

@ -86,14 +86,7 @@ func GetUserInputForGithubAuth() (username, token string, err error) {
} }
func promptForToken(reader *bufio.Reader, prompt string) (string, error) { func promptForToken(reader *bufio.Reader, prompt string) (string, error) {
fmt.Printf("%s: ", prompt) token := promptSecret(reader, prompt, "")
tokenBytes, err := term.ReadPassword(int(syscall.Stdin))
fmt.Println()
if err != nil {
return "", err
}
token := string(tokenBytes)
if token == "" { if token == "" {
return "", fmt.Errorf("token cannot be empty") return "", fmt.Errorf("token cannot be empty")
} }
@ -118,3 +111,21 @@ func promptForToken(reader *bufio.Reader, prompt string) (string, error) {
return token, nil 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
}

View file

@ -12,64 +12,75 @@ import (
"github.com/Pingu-Studio/MaidnCLI/internal/talos" "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) reader := bufio.NewReader(os.Stdin)
cfg := config.Config{} cfg := initial
cfg.WorkspaceDir = prompt(reader, "Workspace directory", ".\\maidn-workspace") cwd, _ := os.Getwd()
if cfg.WorkspaceDir == "" {
cfg.WorkspaceDir = cwd
}
cfg.Git.Provider = "forgejo" cfg.Git.Provider = "forgejo"
cfg.Git.BaseURL = prompt(reader, "Git server URL", "https://git.pingu.pw") cfg.Git.BaseURL = prompt(reader, "Git server URL", fallback(cfg.Git.BaseURL, "https://git.pingu.pw"))
cfg.Git.Username = prompt(reader, "Git username", "eding") cfg.Git.Username = prompt(reader, "Git username", fallback(cfg.Git.Username, "eding"))
cfg.Git.Owner = prompt(reader, "Repo owner/user", "Maidn") cfg.Git.Owner = prompt(reader, "Repo owner/user", fallback(cfg.Git.Owner, "Maidn"))
cfg.Git.Token = prompt(reader, "Forgejo access token", "") cfg.Git.Token = promptSecret(reader, "Forgejo PAT", cfg.Git.Token)
cfg.Git.CloneParent = prompt(reader, "Clone directory", cfg.WorkspaceDir) cfg.WorkspaceDir = prompt(reader, "Working directory", cfg.WorkspaceDir)
cfg.Flux.RepoName = prompt(reader, "Flux repo name", "test-maidn-cd-cluster") cfg.Git.CloneParent = prompt(reader, "Where should repos be cloned locally", fallback(cfg.Git.CloneParent, cfg.WorkspaceDir))
cfg.Flux.ManifestsRepo = prompt(reader, "Manifests repo name", "cicd-deployment-manifests") 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.Branch = "main"
cfg.Flux.ClusterPath = "./clusters/maidn-cd-0" 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.Templates.TalosRepoRef = "main"
cfg.Talos.RepoDirName = "maidn-talos-proxmox" cfg.Talos.RepoDirName = "maidn-talos-proxmox"
cfg.Talos.TerraformDir = "terraform" cfg.Talos.TerraformDir = "terraform"
cfg.Talos.GeneratedDir = "generated" cfg.Talos.GeneratedDir = "generated"
cfg.Talos.ConfigFileName = "terraform.tfvars" cfg.Talos.ConfigFileName = "terraform.tfvars"
cfg.Talos.AutoRunTerraform = promptBool(reader, "Run terraform automatically", true) cfg.Talos.AutoRunTerraform = promptBool(reader, "Run terraform automatically", cfg.Talos.AutoRunTerraform || !hasInitial(initial))
cfg.Talos.AutoBootstrap = promptBool(reader, "Run talos bootstrap automatically", true) cfg.Talos.AutoBootstrap = promptBool(reader, "Run talos bootstrap automatically", cfg.Talos.AutoBootstrap || !hasInitial(initial))
cfg.Talos.AutoBootstrapFlux = promptBool(reader, "Run flux bootstrap automatically", true) 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.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", "root@pam!iac") cfg.Talos.Proxmox.APITokenID = prompt(reader, "Proxmox API token ID", fallback(cfg.Talos.Proxmox.APITokenID, "root@pam!iac"))
cfg.Talos.Proxmox.APITokenSecret = prompt(reader, "Proxmox API token secret", "") cfg.Talos.Proxmox.APITokenSecret = promptSecret(reader, "Proxmox API token secret", cfg.Talos.Proxmox.APITokenSecret)
cfg.Talos.Proxmox.Insecure = true 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 { if err != nil {
return cfg, err return cfg, err
} }
printNodes(discovered) 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{} cfg.Talos.Proxmox.NodeInterfaces = map[string]string{}
for _, node := range discovered { for _, node := range discovered {
fallback := first(node.Networks, "vmbr0") fallbackIface := first(node.Networks, "vmbr0")
cfg.Talos.Proxmox.NodeInterfaces[node.Name] = prompt(reader, fmt.Sprintf("Primary interface for %s", node.Name), fallback) 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.Name = prompt(reader, "Talos cluster name", fallback(cfg.Talos.Cluster.Name, "proxmox-talos-dev02-cluster"))
cfg.Talos.Cluster.Domain = prompt(reader, "Talos cluster domain", "dev02.nid3.com") 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.Flux.ClusterDomain = cfg.Talos.Cluster.Domain
cfg.Talos.Cluster.ControlPlaneVIP = prompt(reader, "Control plane VIP", "192.168.45.2") 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", []string{"192.168.0.222", "1.1.1.1"}) 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", firstStorage(discovered, true, "local-lvm")) 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", cfg.Talos.Cluster.DiskStorage) 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.CreateVLANInterface = promptBool(reader, "Create VLAN interfaces", cfg.Talos.Cluster.CreateVLANInterface || !hasInitial(initial))
latestTalos := talos.LatestVersion() latestTalos := talos.LatestVersion()
cfg.Talos.Image.TalosVersion = prompt(reader, "Talos version", latestTalos) cfg.Talos.Image.TalosVersion = talos.NormalizeVersion(prompt(reader, "Talos version", fallback(cfg.Talos.Image.TalosVersion, latestTalos)))
fmt.Printf("Talos factory: %s\n", talos.FactoryHint(cfg.Talos.Image.TalosVersion)) cfg.Talos.Image.Architecture = chooseArchitecture(reader, cfg.Talos.Image.Architecture)
cfg.Talos.Image.FactorySchematicID = prompt(reader, "Talos factory schematic ID", "") 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.UpdateMode = "download"
cfg.Talos.Image.Storage = prompt(reader, "ISO storage", firstStorage(discovered, false, "local")) cfg.Talos.Image.Storage = prompt(reader, "ISO storage", fallback(cfg.Talos.Image.Storage, firstStorageByCapability(discovered, storageSupportsISO, "local")))
cfg.Talos.Image.Architecture = chooseArchitecture(reader)
nodePreset := prompt(reader, "Cluster size preset (single/ha/custom)", "single") nodePreset := prompt(reader, "Cluster size preset (single/ha/custom)", "single")
cfg.Talos.Nodes = buildNodes(reader, cfg, discovered, nodePreset) 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") 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" { if choice == "2" {
return "arm64" return "arm64"
} }
@ -200,6 +215,24 @@ func promptList(reader *bufio.Reader, label string, fallback []string) []string
return out 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 { func first(values []string, fallback string) string {
if len(values) == 0 { if len(values) == 0 {
return fallback return fallback
@ -207,13 +240,10 @@ func first(values []string, fallback string) string {
return values[0] 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 _, node := range nodes {
for _, storage := range node.Storages { for _, storage := range node.Storages {
if iso && storage.SupportsISO { if predicate(storage) {
return storage.Name
}
if !iso {
return storage.Name return storage.Name
} }
} }
@ -221,6 +251,14 @@ func firstStorage(nodes []proxmox.NodeInfo, iso bool, fallback string) string {
return fallback 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 { func min(a, b int) int {
if a < b { if a < b {
return a return a

View file

@ -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)
}
}