diff --git a/README.md b/README.md index 41446a6..3ca2f9f 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,5 @@ ## Commands + go mod init github.com/Pingu-Studio/MaidnCLI go get -u github.com/spf13/cobra@latest go get golang.org/x/term @@ -14,97 +15,14 @@ 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` 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 --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 +## Forgejo setup -```yaml ---- -apiVersion: source.toolkit.fluxcd.io/v1beta2 -kind: HelmRepository -metadata: - name: ghcr-charts - namespace: flux-system -spec: - interval: 5m - type: oci - url: oci://ghcr.io/pingu-studio - secretRef: - name: github-auth ---- -apiVersion: source.toolkit.fluxcd.io/v1 -kind: GitRepository -metadata: - name: cicd-deployment-manifests - namespace: flux-system -spec: - interval: 1m0s - url: https://github.com/Pingu-Studio/cicd-deployment-manifests - ref: - branch: main - secretRef: - name: github-auth ---- -apiVersion: kustomize.toolkit.fluxcd.io/v1 -kind: Kustomization -metadata: - name: namespaces - namespace: flux-system -spec: - interval: 10m - path: ./namespaces - prune: true - sourceRef: - kind: GitRepository - name: cicd-deployment-manifests ---- -apiVersion: kustomize.toolkit.fluxcd.io/v1 -kind: Kustomization -metadata: - name: preview-apps - namespace: flux-system -spec: - dependsOn: - - name: namespaces - interval: 2m0s - path: ./apps/previews - prune: true - sourceRef: - kind: GitRepository - name: cicd-deployment-manifests ---- -apiVersion: kustomize.toolkit.fluxcd.io/v1 -kind: Kustomization -metadata: - name: staging-apps - namespace: flux-system -spec: - dependsOn: - - name: namespaces - interval: 2m0s - path: ./apps/staging - prune: true - sourceRef: - kind: GitRepository - name: cicd-deployment-manifests -``` +For `https://git.pingu.pw` you need: -and in the cicd-deployment-manifests repo create: - -CICD-DEPLOYMENT-MANIFESTS -├── apps -│ ├── previews -│ │ └── .gitkeep -│ ├── production -│ │ └── .gitkeep -│ └── staging -│ └── .gitkeep -├── namespaces -│ ├── kustomization.yaml -│ ├── previews.yaml -│ ├── production.yaml -│ └── staging.yaml -└── .gitkeep \ No newline at end of file +- a user token with repo create/push rights +- 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 diff --git a/internal/bootstrap/bootstrap.go b/internal/bootstrap/bootstrap.go index dd86abe..4f37085 100644 --- a/internal/bootstrap/bootstrap.go +++ b/internal/bootstrap/bootstrap.go @@ -7,6 +7,7 @@ import ( "strings" "github.com/Pingu-Studio/MaidnCLI/internal/config" + "github.com/Pingu-Studio/MaidnCLI/internal/forgejo" ghrepo "github.com/Pingu-Studio/MaidnCLI/internal/github" "github.com/Pingu-Studio/MaidnCLI/internal/utils" ) @@ -21,12 +22,18 @@ func (r Runner) Run() error { return err } - if err := ghrepo.NewRepoManager(r.Config.GitHub.Org, "cicd-deployment-manifests", r.Config.Flux.RepoName).InitializeAll(); err != nil { + 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) + manager := forgejo.NewRepoManager(r.Config.Git.BaseURL, r.Config.Git.Token, r.Config.Git.Owner, r.Config.Git.Username, r.Config.Flux.ManifestsRepo, r.Config.Flux.RepoName) + if err := manager.InitializeAll( + func(dir string) error { return ghrepo.WriteManifestsStructure(dir, r.Config.Flux.ManifestsRepo) }, + func(dir string) error { return ghrepo.WriteFluxStructure(dir, r.Config.Flux.RepoName, r.Config.Flux.ClusterPath, fluxConfig) }, + ); 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 { + repoDir := filepath.Join(r.Config.Git.CloneParent, r.Config.Talos.RepoDirName) + if err := ensureRepo(repoDir, r.Config.Templates.TalosRepoURL, r.Config.Templates.TalosRepoRef); err != nil { return err } @@ -35,7 +42,6 @@ func (r Runner) Run() error { 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 } @@ -48,7 +54,6 @@ func (r Runner) Run() error { 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 @@ -57,13 +62,11 @@ func (r Runner) Run() error { 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 { + if err := utils.RunCommand("flux", "bootstrap", "git", "--url="+forgejo.CloneURL(r.Config.Git.BaseURL, r.Config.Git.Owner, r.Config.Flux.RepoName), "--branch="+r.Config.Flux.Branch, "--path="+r.Config.Flux.ClusterPath); err != nil { return err } } - return nil } @@ -81,29 +84,17 @@ 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_api_token = %q\n", cfg.Talos.Proxmox.APITokenID+"="+cfg.Talos.Proxmox.APITokenSecret)) 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("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_image_update_mode = %q\n", cfg.Talos.Image.UpdateMode)) + builder.WriteString(fmt.Sprintf("talos_image_storage = %q\n", cfg.Talos.Image.Storage)) + builder.WriteString(fmt.Sprintf("talos_image_architecture = %q\n", cfg.Talos.Image.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))) diff --git a/internal/config/config.go b/internal/config/config.go index 9a020fa..1fb75b1 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -16,10 +16,12 @@ func Load(path string) (Config, error) { if err := yaml.Unmarshal(data, &cfg); err != nil { return cfg, err } + applyDefaults(&cfg) return cfg, Validate(cfg) } func Save(path string, cfg Config) error { + applyDefaults(&cfg) if err := Validate(cfg); err != nil { return err } @@ -30,32 +32,80 @@ func Save(path string, cfg Config) error { return os.WriteFile(path, data, 0644) } +func applyDefaults(cfg *Config) { + if cfg.Git.Provider == "" { + cfg.Git.Provider = "forgejo" + } + if cfg.Git.BaseURL == "" { + cfg.Git.BaseURL = "https://git.pingu.pw" + } + if cfg.Git.CloneParent == "" { + cfg.Git.CloneParent = cfg.WorkspaceDir + } + if cfg.Flux.Branch == "" { + cfg.Flux.Branch = "main" + } + if cfg.Flux.ClusterPath == "" { + cfg.Flux.ClusterPath = "./clusters/maidn-cd-0" + } + if cfg.Flux.ManifestsRepo == "" { + cfg.Flux.ManifestsRepo = "cicd-deployment-manifests" + } + if cfg.Templates.TalosRepoURL == "" { + cfg.Templates.TalosRepoURL = "https://git.pingu.pw/Maidn/maidn-talos-proxmox.git" + } + if cfg.Templates.TalosRepoRef == "" { + cfg.Templates.TalosRepoRef = "main" + } + if cfg.Talos.RepoDirName == "" { + cfg.Talos.RepoDirName = "maidn-talos-proxmox" + } + if cfg.Talos.TerraformDir == "" { + cfg.Talos.TerraformDir = "terraform" + } + if cfg.Talos.GeneratedDir == "" { + cfg.Talos.GeneratedDir = "generated" + } + if cfg.Talos.ConfigFileName == "" { + cfg.Talos.ConfigFileName = "terraform.tfvars" + } + if cfg.Talos.Image.UpdateMode == "" { + cfg.Talos.Image.UpdateMode = "download" + } + if cfg.Talos.Image.Storage == "" { + cfg.Talos.Image.Storage = "local" + } + if cfg.Talos.Image.Architecture == "" { + cfg.Talos.Image.Architecture = "amd64" + } +} + 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.Git.BaseURL == "" || cfg.Git.Username == "" || cfg.Git.Owner == "" { + return errors.New("git baseUrl, username, and owner are required") } - if cfg.Flux.RepoName == "" || cfg.Flux.ClusterPath == "" { - return errors.New("flux repoName and clusterPath are required") + if cfg.Flux.RepoName == "" || cfg.Flux.ClusterPath == "" || cfg.Flux.ManifestsRepo == "" { + return errors.New("flux repoName, manifestsRepo, and clusterPath are required") } - if cfg.Talos.RepoURL == "" || cfg.Talos.RepoDirName == "" { - return errors.New("talos repoUrl and repoDirName are required") + if cfg.Templates.TalosRepoURL == "" || cfg.Talos.RepoDirName == "" { + return errors.New("talos template repo and checkout dir 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.Proxmox.APIURL == "" || cfg.Talos.Proxmox.APITokenID == "" || cfg.Talos.Proxmox.APITokenSecret == "" { + return errors.New("talos proxmox apiUrl, apiTokenId, and apiTokenSecret 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 == "" { + if cfg.Talos.Image.FactorySchematicID == "" || cfg.Talos.Image.TalosVersion == "" { return errors.New("talos image pins are required") } - if cfg.Talos.Image.UpdateMode != "" && 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") } if len(cfg.Talos.Nodes) == 0 { diff --git a/internal/config/types.go b/internal/config/types.go index bf891ec..1bf36bd 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -1,14 +1,22 @@ package config type Config struct { - WorkspaceDir string `yaml:"workspaceDir"` - GitHub GitHubConfig `yaml:"github"` - Flux FluxConfig `yaml:"flux"` - Talos TalosConfig `yaml:"talos"` + WorkspaceDir string `yaml:"workspaceDir"` + Git GitConfig `yaml:"git"` + Flux FluxConfig `yaml:"flux"` + Talos TalosConfig `yaml:"talos"` + Templates TemplateConfig `yaml:"templates"` } -type GitHubConfig struct { - Org string `yaml:"org"` +type GitConfig struct { + Provider string `yaml:"provider"` + BaseURL string `yaml:"baseUrl"` + Username string `yaml:"username"` + Token string `yaml:"token"` + Owner string `yaml:"owner"` + UseSSH bool `yaml:"useSsh"` + SSHKeyPath string `yaml:"sshKeyPath,omitempty"` + CloneParent string `yaml:"cloneParent"` } type FluxConfig struct { @@ -16,12 +24,15 @@ type FluxConfig struct { Branch string `yaml:"branch"` ClusterPath string `yaml:"clusterPath"` ClusterDomain string `yaml:"clusterDomain"` - Owner string `yaml:"owner"` + ManifestsRepo string `yaml:"manifestsRepo"` +} + +type TemplateConfig struct { + TalosRepoURL string `yaml:"talosRepoUrl"` + TalosRepoRef string `yaml:"talosRepoRef"` } type TalosConfig struct { - RepoURL string `yaml:"repoUrl"` - RepoRef string `yaml:"repoRef"` RepoDirName string `yaml:"repoDirName"` TerraformDir string `yaml:"terraformDir"` GeneratedDir string `yaml:"generatedDir"` @@ -39,17 +50,18 @@ type TalosConfig struct { type TalosProxmoxConfig struct { APIURL string `yaml:"apiUrl"` - APIToken string `yaml:"apiToken"` + APITokenID string `yaml:"apiTokenId"` + APITokenSecret string `yaml:"apiTokenSecret"` DefaultNode string `yaml:"defaultNode"` Pool string `yaml:"pool"` NodeInterfaces map[string]string `yaml:"nodeInterfaces"` + Insecure bool `yaml:"insecure"` } 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"` @@ -59,7 +71,6 @@ type TalosClusterConfig struct { 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"` diff --git a/internal/forgejo/repo.go b/internal/forgejo/repo.go new file mode 100644 index 0000000..d87620e --- /dev/null +++ b/internal/forgejo/repo.go @@ -0,0 +1,134 @@ +package forgejo + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/Pingu-Studio/MaidnCLI/internal/utils" +) + +type RepoManager struct { + BaseURL string + Token string + Owner string + Username string + ManifestsRepoName string + FluxRepoName string +} + +type createRepoRequest struct { + Name string `json:"name"` + Description string `json:"description"` + Private bool `json:"private"` + AutoInit bool `json:"auto_init"` +} + +func NewRepoManager(baseURL, token, owner, username, manifestsRepo, fluxRepo string) *RepoManager { + return &RepoManager{ + BaseURL: strings.TrimRight(baseURL, "/"), + Token: token, + Owner: owner, + Username: username, + ManifestsRepoName: manifestsRepo, + FluxRepoName: fluxRepo, + } +} + +func (rm *RepoManager) InitializeAll(createRepo func(string) error, createFlux func(string) error) error { + if err := rm.ensureRepo(rm.ManifestsRepoName, "Centralized deployment manifests for Flux CD", createRepo); err != nil { + return err + } + if err := rm.ensureRepo(rm.FluxRepoName, "Flux CD cluster configurations", createFlux); err != nil { + return err + } + return nil +} + +func (rm *RepoManager) ensureRepo(name, description string, createStructure func(string) error) error { + repoURL := fmt.Sprintf("%s/%s/%s.git", rm.BaseURL, rm.Owner, name) + apiURL := fmt.Sprintf("%s/api/v1/repos/%s/%s", rm.BaseURL, rm.Owner, name) + if err := rm.apiRequest(http.MethodGet, apiURL, nil); err != nil { + createURL := fmt.Sprintf("%s/api/v1/orgs/%s/repos", rm.BaseURL, rm.Owner) + 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}) + if apiErr := rm.apiRequest(http.MethodPost, createURL, body); apiErr != nil { + return apiErr + } + } + return rm.setupRepository(repoURL, name, createStructure) +} + +func (rm *RepoManager) setupRepository(repoURL, repoName string, createStructure func(string) error) error { + tempDir, err := os.MkdirTemp("", "repo-setup-*") + if err != nil { + return err + } + defer os.RemoveAll(tempDir) + if err := utils.RunCommand("git", "clone", repoURL, tempDir); err != nil { + return err + } + if err := createStructure(tempDir); err != nil { + return err + } + return commitAndPush(tempDir, repoName) +} + +func commitAndPush(tempDir, repoName string) error { + utils.RunCommandInDir(tempDir, "git", "config", "user.name", "Maidn") + utils.RunCommandInDir(tempDir, "git", "config", "user.email", "maidn@free-maidn.com") + utils.RunCommandInDir(tempDir, "git", "add", ".") + statusCmd := exec.Command("git", "status", "--porcelain") + statusCmd.Dir = tempDir + output, _ := statusCmd.Output() + if len(output) == 0 { + return nil + } + if err := utils.RunCommandInDir(tempDir, "git", "commit", "-m", "feat: initialize repository structure for CI/CD"); err != nil { + return err + } + if err := utils.RunCommandInDir(tempDir, "git", "push", "origin", "main"); err != nil { + return err + } + fmt.Printf("[SUCCESS] Changes pushed to %s successfully.\n", repoName) + return nil +} + +func (rm *RepoManager) apiRequest(method, endpoint string, body []byte) error { + client := http.Client{Timeout: 15 * time.Second} + request, err := http.NewRequest(method, endpoint, bytes.NewReader(body)) + if err != nil { + return err + } + request.Header.Set("Authorization", "token "+rm.Token) + 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("forgejo returned %s for %s", response.Status, endpoint) + } + return nil +} + +func FluxSourceURL(baseURL, owner, repo string) string { + return fmt.Sprintf("%s/%s/%s.git", strings.TrimRight(baseURL, "/"), owner, repo) +} + +func CloneURL(baseURL, owner, repo string) string { + return fmt.Sprintf("%s/%s/%s.git", strings.TrimRight(baseURL, "/"), owner, repo) +} + +func RepoPath(parent, dirName string) string { + return filepath.Join(parent, dirName) +} diff --git a/internal/github/repository.go b/internal/github/repository.go index ccdbe54..e9f20bc 100644 --- a/internal/github/repository.go +++ b/internal/github/repository.go @@ -11,7 +11,8 @@ import ( "github.com/Pingu-Studio/MaidnCLI/internal/utils" ) -const fluxConfigTmpl = `--- +func BuildFluxConfig(chartOwner, gitURL, manifestsRepo string) string { + return fmt.Sprintf(`--- apiVersion: source.toolkit.fluxcd.io/v1beta2 kind: HelmRepository metadata: @@ -31,7 +32,7 @@ metadata: namespace: flux-system spec: interval: 1m0s - url: https://github.com/%s/%s + url: %s ref: branch: main secretRef: @@ -98,159 +99,60 @@ spec: sourceRef: kind: GitRepository name: cicd-deployment-manifests -` +`, chartOwner, gitURL) +} -// RepoManager holds the state for repository operations type RepoManager struct { OrgName string ManifestsRepoName string FluxRepoName string } -// NewRepoManager creates a new instance of RepoManager func NewRepoManager(org, manifestsRepo, fluxRepo string) *RepoManager { - return &RepoManager{ - OrgName: org, - ManifestsRepoName: manifestsRepo, - FluxRepoName: fluxRepo, - } + return &RepoManager{OrgName: org, ManifestsRepoName: manifestsRepo, FluxRepoName: fluxRepo} } -// InitializeManifestsRepo sets up the manifests repository -func (rm *RepoManager) InitializeManifestsRepo() { - fullRepoName := fmt.Sprintf("%s/%s", rm.OrgName, rm.ManifestsRepoName) - fmt.Printf("\n[INFO] Setting up manifests repository '%s'...\n", fullRepoName) +func (rm *RepoManager) InitializeManifestsRepo() {} +func (rm *RepoManager) InitializeFluxRepo() {} +func (rm *RepoManager) InitializeAll() error { return nil } - if err := utils.RunCommandQuiet("gh", "repo", "view", fullRepoName); err != nil { - fmt.Printf("[INFO] Creating repository '%s'...\n", fullRepoName) - if err := utils.RunCommand("gh", "repo", "create", fullRepoName, "--private", "--description", "Centralized deployment manifests for Flux CD"); err != nil { - fmt.Printf("[ERROR] Failed to create repository: %v\n", err) - os.Exit(1) - } - fmt.Println("[SUCCESS] Manifests repository created.") - } else { - fmt.Println("[SUCCESS] Manifests repository already exists.") - } - - rm.setupRepository(fullRepoName, rm.createManifestsStructure) -} - -// InitializeFluxRepo sets up the flux repository -func (rm *RepoManager) InitializeFluxRepo() { - fullRepoName := fmt.Sprintf("%s/%s", rm.OrgName, rm.FluxRepoName) - fmt.Printf("\n[INFO] Setting up flux repository '%s'...\n", fullRepoName) - - if err := utils.RunCommandQuiet("gh", "repo", "view", fullRepoName); err != nil { - fmt.Printf("[INFO] Creating repository '%s'...\n", fullRepoName) - if err := utils.RunCommand("gh", "repo", "create", fullRepoName, "--private", "--description", "Flux CD cluster configurations"); err != nil { - fmt.Printf("[ERROR] Failed to create repository: %v\n", err) - os.Exit(1) - } - fmt.Println("[SUCCESS] Flux repository created.") - } else { - fmt.Println("[SUCCESS] Flux repository already exists.") - } - - 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 { - fmt.Printf("[ERROR] Failed to create temporary directory: %v\n", err) - os.Exit(1) - } - defer os.RemoveAll(tempDir) - - fmt.Printf("[INFO] Cloning repository into temporary directory...\n") - repoURL := fmt.Sprintf("https://github.com/%s.git", fullRepoName) - if err := utils.RunCommand("git", "clone", repoURL, tempDir); err != nil { - fmt.Printf("[ERROR] Failed to clone repository: %v\n", err) - os.Exit(1) - } - - fmt.Println("[INFO] Creating repository structure...") - structureFunc(tempDir) - - fmt.Println("[INFO] Committing and pushing changes...") - commitAndPush(tempDir, fullRepoName) -} - -func (rm *RepoManager) createManifestsStructure(baseDir string) { - manifestsReadme := fmt.Sprintf(assets.ManifestsReadmeTmpl, rm.ManifestsRepoName) +func WriteManifestsStructure(baseDir, repoName string) error { + manifestsReadme := fmt.Sprintf(assets.ManifestsReadmeTmpl, repoName) utils.WriteFile(filepath.Join(baseDir, "README.md"), manifestsReadme) - namespacesDir := filepath.Join(baseDir, "namespaces") os.MkdirAll(namespacesDir, 0755) - - stagingNamespace := `apiVersion: v1 -kind: Namespace -metadata: - name: staging -` - utils.WriteFile(filepath.Join(namespacesDir, "staging.yaml"), stagingNamespace) - - productionNamespace := `apiVersion: v1 -kind: Namespace -metadata: - name: production -` - utils.WriteFile(filepath.Join(namespacesDir, "production.yaml"), productionNamespace) - - namespacesKustomization := `apiVersion: kustomize.config.k8s.io/v1beta1 -kind: Kustomization -resources: - - staging.yaml - - production.yaml -` - utils.WriteFile(filepath.Join(namespacesDir, "kustomization.yaml"), namespacesKustomization) - - environments := []string{"staging", "production", "previews"} - for _, env := range environments { + utils.WriteFile(filepath.Join(namespacesDir, "staging.yaml"), "apiVersion: v1\nkind: Namespace\nmetadata:\n name: staging\n") + utils.WriteFile(filepath.Join(namespacesDir, "production.yaml"), "apiVersion: v1\nkind: Namespace\nmetadata:\n name: production\n") + utils.WriteFile(filepath.Join(namespacesDir, "kustomization.yaml"), "apiVersion: kustomize.config.k8s.io/v1beta1\nkind: Kustomization\nresources:\n - staging.yaml\n - production.yaml\n") + for _, env := range []string{"staging", "production", "previews"} { dirPath := filepath.Join(baseDir, "apps", env) os.MkdirAll(dirPath, 0755) utils.WriteFile(filepath.Join(dirPath, ".gitkeep"), "") } + return nil } -func (rm *RepoManager) createFluxStructure(baseDir string) { - fluxReadme := fmt.Sprintf(assets.FluxReadmeTmpl, rm.FluxRepoName) +func WriteFluxStructure(baseDir, repoName, clusterPath, fluxConfig string) error { + fluxReadme := fmt.Sprintf(assets.FluxReadmeTmpl, repoName) utils.WriteFile(filepath.Join(baseDir, "README.md"), fluxReadme) - - clusterDir := filepath.Join(baseDir, "clusters", "dev-cluster") + clusterDir := filepath.Join(baseDir, strings.TrimPrefix(clusterPath, "./")) os.MkdirAll(clusterDir, 0755) - - fluxConfig := fmt.Sprintf(fluxConfigTmpl, strings.ToLower(rm.OrgName), rm.OrgName, rm.ManifestsRepoName) utils.WriteFile(filepath.Join(clusterDir, "cicd-manifests-repo.yaml"), fluxConfig) + return nil } -func commitAndPush(tempDir, repoName string) { +func CommitAndPush(tempDir string) error { utils.RunCommandInDir(tempDir, "git", "config", "user.name", "Maidn") utils.RunCommandInDir(tempDir, "git", "config", "user.email", "maidn@free-maidn.com") utils.RunCommandInDir(tempDir, "git", "add", ".") - statusCmd := exec.Command("git", "status", "--porcelain") statusCmd.Dir = tempDir output, _ := statusCmd.Output() - if len(output) == 0 { - fmt.Println("[INFO] No changes to commit. Repository is already up to date.") - } else { - commitMsg := "feat: Initialize repository structure for CI/CD" - if err := utils.RunCommandInDir(tempDir, "git", "commit", "-m", commitMsg); err != nil { - fmt.Printf("[ERROR] Failed to commit changes: %v\n", err) - os.Exit(1) - } - if err := utils.RunCommandInDir(tempDir, "git", "push", "origin", "main"); err != nil { - fmt.Printf("[ERROR] Failed to push changes: %v\n", err) - os.Exit(1) - } - fmt.Printf("[SUCCESS] Changes pushed to %s successfully.\n", repoName) + return nil } + if err := utils.RunCommandInDir(tempDir, "git", "commit", "-m", "feat: initialize repository structure for CI/CD"); err != nil { + return err + } + return utils.RunCommandInDir(tempDir, "git", "push", "origin", "main") } diff --git a/internal/proxmox/client.go b/internal/proxmox/client.go new file mode 100644 index 0000000..efa520e --- /dev/null +++ b/internal/proxmox/client.go @@ -0,0 +1,165 @@ +package proxmox + +import ( + "crypto/tls" + "encoding/json" + "fmt" + "net/http" + "net/url" + "sort" + "strings" + "time" +) + +type Client struct { + baseURL string + tokenID string + tokenSecret string + httpClient *http.Client +} + +type clusterNodesResponse struct { + Data []struct { + Node string `json:"node"` + Status string `json:"status"` + } `json:"data"` +} + +type nodeStatusResponse struct { + Data struct { + CPU float64 `json:"cpu"` + Memory struct { + Used uint64 `json:"used"` + Total uint64 `json:"total"` + } `json:"memory"` + RootFS struct { + Avail uint64 `json:"avail"` + Total uint64 `json:"total"` + } `json:"rootfs"` + } `json:"data"` +} + +type networkResponse struct { + Data []struct { + Iface string `json:"iface"` + Type string `json:"type"` + Active int `json:"active"` + } `json:"data"` +} + +type storageResponse struct { + Data []struct { + Storage string `json:"storage"` + Type string `json:"type"` + Avail uint64 `json:"avail"` + Total uint64 `json:"total"` + } `json:"data"` +} + +type NodeInfo struct { + Name string + Networks []string + Storages []StorageInfo + FreeMemoryMB int + TotalMemoryMB int + FreeDiskGB int +} + +type StorageInfo struct { + Name string + Type string + FreeGB int + SupportsISO bool +} + +func New(baseURL, tokenID, tokenSecret string, insecure bool) *Client { + transport := &http.Transport{} + if insecure { + transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} + } + return &Client{ + baseURL: strings.TrimRight(baseURL, "/"), + tokenID: tokenID, + tokenSecret: tokenSecret, + httpClient: &http.Client{Timeout: 15 * time.Second, Transport: transport}, + } +} + +func (c *Client) Discover() ([]NodeInfo, error) { + var nodesResp clusterNodesResponse + if err := c.getJSON("/cluster/resources?type=node", &nodesResp); err != nil { + return nil, err + } + result := make([]NodeInfo, 0, len(nodesResp.Data)) + for _, node := range nodesResp.Data { + if node.Node == "" || node.Status != "online" { + continue + } + info, err := c.describeNode(node.Node) + if err != nil { + return nil, err + } + result = append(result, info) + } + sort.Slice(result, func(i, j int) bool { return result[i].Name < result[j].Name }) + return result, nil +} + +func (c *Client) describeNode(node string) (NodeInfo, error) { + var status nodeStatusResponse + if err := c.getJSON("/nodes/"+node+"/status", &status); err != nil { + return NodeInfo{}, err + } + var networks networkResponse + if err := c.getJSON("/nodes/"+node+"/network", &networks); err != nil { + return NodeInfo{}, err + } + var storages storageResponse + if err := c.getJSON("/nodes/"+node+"/storage", &storages); err != nil { + return NodeInfo{}, err + } + info := NodeInfo{ + Name: node, + FreeMemoryMB: int((status.Data.Memory.Total - status.Data.Memory.Used) / 1024 / 1024), + TotalMemoryMB: int(status.Data.Memory.Total / 1024 / 1024), + FreeDiskGB: int(status.Data.RootFS.Avail / 1024 / 1024 / 1024), + } + for _, net := range networks.Data { + if net.Active == 1 && (net.Type == "bridge" || net.Type == "eth") { + info.Networks = append(info.Networks, net.Iface) + } + } + 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", + }) + } + return info, nil +} + +func (c *Client) getJSON(path string, target any) error { + request, err := http.NewRequest(http.MethodGet, c.baseURL+"/api2/json"+path, nil) + if err != nil { + return err + } + request.Header.Set("Authorization", fmt.Sprintf("PVEAPIToken=%s=%s", c.tokenID, c.tokenSecret)) + response, err := c.httpClient.Do(request) + if err != nil { + return err + } + defer response.Body.Close() + if response.StatusCode >= 300 { + return fmt.Errorf("proxmox returned %s", response.Status) + } + return json.NewDecoder(response.Body).Decode(target) +} + +func BuildAPIURL(host string) string { + if strings.HasPrefix(host, "http") { + return strings.TrimRight(host, "/") + } + return (&url.URL{Scheme: "https", Host: host, Path: "/api2/json"}).String() +} diff --git a/internal/talos/releases.go b/internal/talos/releases.go new file mode 100644 index 0000000..e3d80e2 --- /dev/null +++ b/internal/talos/releases.go @@ -0,0 +1,38 @@ +package talos + +import ( + "encoding/json" + "fmt" + "net/http" + "time" +) + +type releaseResponse struct { + TagName string `json:"tag_name"` +} + +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) + if err != nil { + return "v1.13.6" + } + request.Header.Set("Accept", "application/vnd.github+json") + response, err := client.Do(request) + if err != nil { + return "v1.13.6" + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return "v1.13.6" + } + var release releaseResponse + if err := json.NewDecoder(response.Body).Decode(&release); err != nil || release.TagName == "" { + return "v1.13.6" + } + return release.TagName +} + +func FactoryHint(version string) string { + return fmt.Sprintf("https://factory.talos.dev/?version=%s", version) +} diff --git a/internal/ui/wizard.go b/internal/ui/wizard.go index 5b38381..e20190e 100644 --- a/internal/ui/wizard.go +++ b/internal/ui/wizard.go @@ -8,61 +8,157 @@ import ( "strings" "github.com/Pingu-Studio/MaidnCLI/internal/config" + "github.com/Pingu-Studio/MaidnCLI/internal/proxmox" + "github.com/Pingu-Studio/MaidnCLI/internal/talos" ) 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.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.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.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.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.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.Insecure = true 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) + 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 cfg, err + } + printNodes(discovered) + cfg.Talos.Proxmox.DefaultNode = prompt(reader, "Default Proxmox node", 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) } + 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.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) + + 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.UpdateMode = "download" + cfg.Talos.Image.Storage = prompt(reader, "ISO storage", firstStorage(discovered, false, "local")) + cfg.Talos.Image.Architecture = chooseArchitecture(reader) + + nodePreset := prompt(reader, "Cluster size preset (single/ha/custom)", "single") + cfg.Talos.Nodes = buildNodes(reader, cfg, discovered, nodePreset) + cfg.Talos.BootstrapNode = cfg.Talos.Nodes[0].Networks[0].IP + cfg.Talos.KubeconfigNode = cfg.Talos.Nodes[0].Networks[0].IP return cfg, 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) string { + fmt.Println("Architecture options: 1) amd64 2) arm64") + choice := prompt(reader, "Architecture", "1") + 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, 2) + 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)) + 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 MAC", i+1), ""), + CIDR: cidr, + IP: ip, + Gateway: gateway, + VLANID: vlanID, + }}, + }) + } + return result +} + func prompt(reader *bufio.Reader, label, fallback string) string { if fallback != "" { fmt.Printf("%s [%s]: ", label, fallback) @@ -70,6 +166,7 @@ func prompt(reader *bufio.Reader, label, fallback string) string { fmt.Printf("%s: ", label) } value, _ := reader.ReadString('\n') + fmt.Println() value = strings.TrimSpace(value) if value == "" { return fallback @@ -82,12 +179,11 @@ func promptBool(reader *bufio.Reader, label string, fallback bool) bool { if !fallback { defaultValue = "n" } - fmt.Printf("%s [y/n, default %s]: ", label, defaultValue) - value, _ := reader.ReadString('\n') - value = strings.TrimSpace(strings.ToLower(value)) + value := prompt(reader, label+" [y/n, default "+defaultValue+"]", "") if value == "" { return fallback } + value = strings.ToLower(value) return value == "y" || value == "yes" } @@ -104,61 +200,30 @@ func promptList(reader *bufio.Reader, label string, fallback []string) []string 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) +func first(values []string, fallback string) string { + if len(values) == 0 { + return fallback } - 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 values[0] +} + +func firstStorage(nodes []proxmox.NodeInfo, iso bool, fallback string) string { + for _, node := range nodes { + for _, storage := range node.Storages { + if iso && storage.SupportsISO { + return storage.Name + } + if !iso { + return storage.Name + } } } - return result + return fallback } -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 +func min(a, b int) int { + if a < b { + return a } - 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 + return b } diff --git a/internal/utils/helpers.go b/internal/utils/helpers.go index eedbd35..1c2875e 100644 --- a/internal/utils/helpers.go +++ b/internal/utils/helpers.go @@ -2,6 +2,8 @@ package utils import ( "bufio" + "bytes" + "encoding/json" "fmt" "os" "os/exec" @@ -9,7 +11,6 @@ import ( "strings" ) -// WriteFile writes content to a file, exiting on error. func WriteFile(path, content string) { if err := os.WriteFile(path, []byte(content), 0644); err != nil { fmt.Printf("[ERROR] Failed to write file %s: %v\n", path, err) @@ -17,13 +18,11 @@ func WriteFile(path, content string) { } } -// CommandExists checks if a command exists in the system's PATH. func CommandExists(cmd string) bool { _, err := exec.LookPath(cmd) return err == nil } -// RunCommand runs a command with its output visible to the user. func RunCommand(name string, args ...string) error { cmd := exec.Command(name, args...) cmd.Stdout = os.Stdout @@ -31,14 +30,12 @@ func RunCommand(name string, args ...string) error { return cmd.Run() } -// RunCommandQuiet runs a command without printing its stdout. func RunCommandQuiet(name string, args ...string) error { cmd := exec.Command(name, args...) cmd.Stderr = os.Stderr return cmd.Run() } -// RunCommandInDir runs a command in a specific directory. func RunCommandInDir(dir string, name string, args ...string) error { cmd := exec.Command(name, args...) cmd.Dir = dir @@ -47,7 +44,25 @@ func RunCommandInDir(dir string, name string, args ...string) error { return cmd.Run() } -// HandleKubectlCacheError detects a specific kubectl cache error and offers an interactive fix. +func RunCommandOutput(name string, args ...string) ([]byte, error) { + cmd := exec.Command(name, args...) + var stdout bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return nil, err + } + return stdout.Bytes(), nil +} + +func RunCommandJSON(target any, name string, args ...string) error { + output, err := RunCommandOutput(name, args...) + if err != nil { + return err + } + return json.Unmarshal(output, target) +} + func HandleKubectlCacheError(err error) bool { if err == nil || !strings.Contains(err.Error(), "no matches for kind") { return false @@ -73,9 +88,9 @@ func HandleKubectlCacheError(err error) bool { cmd = exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", "Remove-Item -Path $env:USERPROFILE\\.kube\\cache -Recurse -Force") } else { shell = "shell" - homeDir, err := os.UserHomeDir() - if err != nil { - fmt.Printf("[ERROR] Could not determine home directory to clear cache: %v\n", err) + homeDir, homeErr := os.UserHomeDir() + if homeErr != nil { + fmt.Printf("[ERROR] Could not determine home directory to clear cache: %v\n", homeErr) return false } cachePath := fmt.Sprintf("%s/.kube/cache", homeDir)