maidn-cli/internal/config/config.go

365 lines
13 KiB
Go

package config
import (
"bytes"
"errors"
"fmt"
"io"
"net/netip"
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
"github.com/Pingu-Studio/MaidnCLI/internal/talos"
"gopkg.in/yaml.v3"
)
func Load(path string) (Config, error) {
cfg, err := LoadRaw(path)
if err != nil {
return cfg, err
}
return Resolve(cfg)
}
func LoadRaw(path string) (Config, error) {
var cfg Config
data, err := os.ReadFile(path)
if err != nil {
return cfg, err
}
decoder := yaml.NewDecoder(bytes.NewReader(data))
decoder.KnownFields(true)
if err := decoder.Decode(&cfg); err != nil {
return cfg, err
}
if err := decoder.Decode(&Config{}); !errors.Is(err, io.EOF) {
return cfg, errors.New("bootstrap config must contain one YAML document")
}
return cfg, nil
}
func Save(path string, cfg Config) error {
resolved, err := Resolve(cfg)
if err != nil {
return err
}
data, err := yaml.Marshal(resolved)
if err != nil {
return err
}
return os.WriteFile(path, data, 0600)
}
func WriteRedacted(path string, cfg Config) error {
redacted := cfg
redacted.Git.Token = ""
redacted.Git.BaseURL = RedactURL(redacted.Git.BaseURL)
redacted.Talos.Proxmox.APITokenSecret = ""
redacted.Talos.Proxmox.APIURL = RedactURL(redacted.Talos.Proxmox.APIURL)
redacted.DemocraticCSI.TrueNASAPIKey = ""
redacted.Templates.TalosRepoURL = RedactURL(redacted.Templates.TalosRepoURL)
redacted.Templates.CICDRepoURL = RedactURL(redacted.Templates.CICDRepoURL)
redacted.Templates.ManifestsRepoURL = RedactURL(redacted.Templates.ManifestsRepoURL)
redacted.Templates.TektonCatalogRepoURL = RedactURL(redacted.Templates.TektonCatalogRepoURL)
redacted.Delivery.AppRepoURL = RedactURL(redacted.Delivery.AppRepoURL)
redacted.Delivery.ImageRepository = RedactURL(redacted.Delivery.ImageRepository)
data, err := yaml.Marshal(redacted)
if err != nil {
return err
}
return os.WriteFile(path, data, 0600)
}
// RedactURL removes credential-bearing URL components before persistent output.
func RedactURL(value string) string {
if strings.Contains(value, "://") {
parsed, err := url.Parse(value)
if err != nil {
return "<redacted>"
}
parsed.User = nil
parsed.RawQuery = ""
parsed.ForceQuery = false
parsed.Fragment = ""
return parsed.String()
}
if at := strings.Index(value, "@"); at > 0 && !strings.Contains(value[:at], "/") && strings.Contains(value[at+1:], ":") {
return value[at+1:]
}
if strings.ContainsAny(value, "@?#") {
return "<redacted>"
}
return value
}
func Resolve(cfg Config) (Config, error) {
applyDefaults(&cfg)
return cfg, Validate(cfg)
}
func applyDefaults(cfg *Config) {
if cfg.ClusterID == "" {
cfg.ClusterID = cfg.Talos.Cluster.Name
}
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.Templates.CICDRepoURL == "" {
cfg.Templates.CICDRepoURL = "https://git.pingu.pw/Maidn/maidn-cicd-cluster-template.git"
}
if cfg.Templates.CICDRepoRef == "" {
cfg.Templates.CICDRepoRef = "main"
}
if cfg.Templates.ManifestsRepoURL == "" {
cfg.Templates.ManifestsRepoURL = "https://git.pingu.pw/Maidn/cicd-deployment-manifests-template.git"
}
if cfg.Templates.ManifestsRepoRef == "" {
cfg.Templates.ManifestsRepoRef = "main"
}
if cfg.Templates.TektonCatalogRepoURL == "" {
cfg.Templates.TektonCatalogRepoURL = "https://git.pingu.pw/Maidn/tekton-pipelines.git"
}
if cfg.Templates.TektonCatalogRepoRef == "" {
cfg.Templates.TektonCatalogRepoRef = "main"
}
if cfg.Cilium.TrafficInterface == "" {
cfg.Cilium.TrafficInterface = "eth1"
}
if cfg.DemocraticCSI.PortalGroup == "" {
cfg.DemocraticCSI.PortalGroup = "1"
}
if cfg.DemocraticCSI.InitiatorGroup == "" {
cfg.DemocraticCSI.InitiatorGroup = "1"
}
if cfg.Delivery.AppName == "" {
cfg.Delivery.AppName = "easycsr-frontend"
}
if cfg.Delivery.AppRepoURL == "" {
cfg.Delivery.AppRepoURL = strings.TrimRight(cfg.Git.BaseURL, "/") + "/" + cfg.Git.Owner + "/" + cfg.Delivery.AppName + ".git"
}
if cfg.Delivery.AppRepoRef == "" {
cfg.Delivery.AppRepoRef = cfg.Flux.Branch
}
if cfg.Delivery.ImageRepository == "" {
cfg.Delivery.ImageRepository = strings.TrimPrefix(strings.TrimPrefix(cfg.Git.BaseURL, "https://"), "http://") + "/" + strings.ToLower(cfg.Git.Owner) + "/" + cfg.Delivery.AppName
}
if cfg.Delivery.WebhookHostname == "" && cfg.Flux.ClusterDomain != "" {
cfg.Delivery.WebhookHostname = "tekton." + cfg.Flux.ClusterDomain
}
if cfg.Delivery.WebhookPath == "" {
cfg.Delivery.WebhookPath = "/"
}
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"
}
if cfg.Talos.Image.KubernetesVersion == "" && strings.HasPrefix(cfg.Talos.Image.TalosVersion, "v1.13.") {
cfg.Talos.Image.KubernetesVersion = "v1.33.4"
}
if cfg.SOPS.AgeKeyPath == "" && cfg.WorkspaceDir != "" {
cfg.SOPS.AgeKeyPath = filepath.Join(cfg.WorkspaceDir, ".age", "key.txt")
}
if cfg.SOPS.OperationalSecretsPath == "" && cfg.WorkspaceDir != "" {
cfg.SOPS.OperationalSecretsPath = filepath.Join(cfg.WorkspaceDir, "operational-secrets.sops.yaml")
}
if cfg.SOPS.RecoveryIdentityPath == "" && cfg.WorkspaceDir != "" {
cfg.SOPS.RecoveryIdentityPath = filepath.Join(cfg.WorkspaceDir, ".age", "recovery-key.txt")
}
if cfg.SOPS.RecoveryBundlePath == "" && cfg.WorkspaceDir != "" {
cfg.SOPS.RecoveryBundlePath = filepath.Join(cfg.WorkspaceDir, ".recovery", "openbao-recovery.age")
}
if cfg.Talos.BootstrapEndpoint == "" {
cfg.Talos.BootstrapEndpoint = cfg.Talos.BootstrapNode
}
if cfg.Talos.KubeconfigEndpoint == "" {
cfg.Talos.KubeconfigEndpoint = cfg.Talos.KubeconfigNode
}
}
func Validate(cfg Config) error {
if !regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`).MatchString(cfg.ClusterID) {
return errors.New("clusterId must be a lowercase DNS label")
}
if cfg.WorkspaceDir == "" {
return errors.New("workspaceDir is required")
}
if cfg.Git.Provider != "forgejo" {
return errors.New("git provider must be forgejo")
}
if cfg.Git.BaseURL == "" || cfg.Git.Username == "" || cfg.Git.Owner == "" {
return errors.New("git baseUrl, username, and owner are required")
}
if cfg.Git.Token == "" {
return errors.New("git token is required; SSH bootstrap is not implemented")
}
if cfg.Flux.RepoName == "" || cfg.Flux.ClusterPath == "" || cfg.Flux.ManifestsRepo == "" || cfg.Flux.ClusterDomain == "" {
return errors.New("flux repoName, clusterDomain, manifestsRepo, and clusterPath are required")
}
if cfg.Delivery.AppName == "" || cfg.Delivery.AppRepoURL == "" || cfg.Delivery.AppRepoRef == "" || cfg.Delivery.ImageRepository == "" || cfg.Delivery.WebhookHostname == "" || cfg.Delivery.WebhookPath == "" {
return errors.New("delivery appName, appRepoUrl, appRepoRef, imageRepository, webhookHostname, and webhookPath are required")
}
if strings.ContainsAny(cfg.Delivery.WebhookHostname, "/:@?#") || !strings.HasPrefix(cfg.Delivery.WebhookPath, "/") || strings.ContainsAny(cfg.Delivery.WebhookPath, "?#") {
return errors.New("delivery webhookHostname must be a hostname and webhookPath must be an absolute path")
}
if cfg.Templates.TalosRepoURL == "" || cfg.Templates.TalosRepoRef == "" || cfg.Templates.CICDRepoURL == "" || cfg.Templates.CICDRepoRef == "" || cfg.Templates.ManifestsRepoURL == "" || cfg.Templates.ManifestsRepoRef == "" || cfg.Templates.TektonCatalogRepoURL == "" || cfg.Templates.TektonCatalogRepoRef == "" {
return errors.New("all template repository URLs and refs are required")
}
if cfg.Talos.RepoDirName == "" {
return errors.New("talos checkout dir is 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.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.TalosVersion == "" || cfg.Talos.Image.KubernetesVersion == "" || cfg.Talos.Image.SchematicID == "" {
return errors.New("talos version, kubernetesVersion, and schematicId are required")
}
if len(cfg.Talos.Image.SchematicID) < 12 {
return errors.New("talos schematicId must contain at least 12 characters")
}
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 {
return errors.New("at least one talos node is required")
}
if cfg.Cilium.LoadBalancerStart == "" || cfg.Cilium.LoadBalancerEnd == "" {
return errors.New("cilium loadBalancerStart and loadBalancerEnd are required")
}
start, err := netip.ParseAddr(cfg.Cilium.LoadBalancerStart)
if err != nil {
return errors.New("cilium loadBalancerStart must be an IP address")
}
end, err := netip.ParseAddr(cfg.Cilium.LoadBalancerEnd)
if err != nil || start.BitLen() != end.BitLen() || start.Compare(end) > 0 {
return errors.New("cilium loadBalancerEnd must be an IP address after loadBalancerStart")
}
if cfg.DemocraticCSI.TrueNASAPIKey == "" || cfg.DemocraticCSI.TrueNASHost == "" || cfg.DemocraticCSI.TargetPortal == "" || cfg.DemocraticCSI.ShareHost == "" || cfg.DemocraticCSI.DatasetParentNFS == "" || cfg.DemocraticCSI.DatasetSnapshotsNFS == "" || cfg.DemocraticCSI.AllowedNetworks == "" || cfg.DemocraticCSI.NameSuffix == "" || cfg.DemocraticCSI.PortalGroup == "" || cfg.DemocraticCSI.InitiatorGroup == "" {
return errors.New("all democraticCsi settings are required")
}
if _, err := netip.ParsePrefix(cfg.DemocraticCSI.AllowedNetworks); err != nil {
return errors.New("democraticCsi allowedNetworks must be a CIDR")
}
seenNames := map[string]bool{}
seenVMIDs := map[int]bool{}
seenIPs := map[netip.Addr]bool{}
seenMACs := map[string]bool{}
controlPlanes := 0
trafficVLAN := 0
for index, node := range cfg.Talos.Nodes {
if node.Name == "" || seenNames[node.Name] || node.VMID <= 0 || seenVMIDs[node.VMID] {
return errors.New("talos node names and VMIDs must be unique and non-zero")
}
seenNames[node.Name] = true
seenVMIDs[node.VMID] = true
if node.Role != "controlplane" && node.Role != "worker" {
return fmt.Errorf("talos node %q role must be controlplane or worker", node.Name)
}
if node.Role == "controlplane" {
controlPlanes++
}
if index == 0 && node.Role != "controlplane" {
return errors.New("the first talos node must be a controlplane")
}
if len(node.Networks) < 2 {
return errors.New("cilium requires a second static traffic network on every talos node")
}
primary := node.Networks[0]
primaryIP, err := netip.ParseAddr(primary.IP)
if err != nil || primary.Gateway == "" {
return errors.New("talos primary networks require a static IP and gateway")
}
if seenIPs[primaryIP] {
return errors.New("talos node IPs must be unique")
}
seenIPs[primaryIP] = true
traffic := node.Networks[1]
prefix, err := netip.ParsePrefix(traffic.CIDR)
if err != nil || traffic.IP == "" || traffic.Gateway != "" {
return errors.New("cilium traffic networks require a static IP, valid CIDR, and no gateway")
}
nodeIP, err := netip.ParseAddr(traffic.IP)
if err != nil || !prefix.Contains(nodeIP) || (nodeIP.Compare(start) >= 0 && nodeIP.Compare(end) <= 0) {
return errors.New("cilium traffic node IP must belong to its CIDR and not use the LoadBalancer range")
}
if seenIPs[nodeIP] {
return errors.New("talos node IPs must be unique")
}
seenIPs[nodeIP] = true
if trafficVLAN == 0 {
trafficVLAN = traffic.VLANID
} else if trafficVLAN != traffic.VLANID {
return errors.New("cilium traffic networks must use one VLAN")
}
if !prefix.Contains(start) || !prefix.Contains(end) {
return errors.New("cilium LoadBalancer range must belong to every node traffic network")
}
for _, network := range node.Networks {
if network.VLANID < 1 || network.VLANID > 4094 {
return errors.New("talos VLAN IDs must be between 1 and 4094")
}
if network.MACAddress != "" {
mac := strings.ToLower(network.MACAddress)
if seenMACs[mac] {
return errors.New("talos network MAC addresses must be unique")
}
seenMACs[mac] = true
}
}
}
if controlPlanes == 0 {
return errors.New("at least one controlplane node is required")
}
return nil
}
func Preflight(cfg Config) error {
return talos.RequireCLICompatibility(cfg.Talos.Image.TalosVersion)
}