feat: target forgejo templates

This commit is contained in:
eding 2026-07-18 21:45:17 +02:00
parent 85e87f1334
commit b6dce951ed
10 changed files with 652 additions and 363 deletions

View file

@ -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 <org> --flux-repo <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
- 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

View file

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

View file

@ -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 {

View file

@ -2,13 +2,21 @@ package config
type Config struct {
WorkspaceDir string `yaml:"workspaceDir"`
GitHub GitHubConfig `yaml:"github"`
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"`

134
internal/forgejo/repo.go Normal file
View file

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

View file

@ -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)
return nil
}
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)
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")
}

165
internal/proxmox/client.go Normal file
View file

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

View file

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

View file

@ -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 result
return values[0]
}
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 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
}
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,
}},
})
if !iso {
return storage.Name
}
return result
}
}
return fallback
}
func min(a, b int) int {
if a < b {
return a
}
return b
}

View file

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