334 lines
12 KiB
Go
334 lines
12 KiB
Go
package config
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/netip"
|
|
"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.Talos.Proxmox.APITokenSecret = ""
|
|
redacted.DemocraticCSI.TrueNASAPIKey = ""
|
|
data, err := yaml.Marshal(redacted)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return os.WriteFile(path, data, 0600)
|
|
}
|
|
|
|
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)
|
|
}
|