601 lines
24 KiB
Go
601 lines
24 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)
|
|
redacted.Delivery.TektonDashboardURL = RedactURL(redacted.Delivery.TektonDashboardURL)
|
|
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)
|
|
}
|
|
|
|
// ResolveDelivery applies delivery defaults for an explicitly requested app operation.
|
|
func ResolveDelivery(cfg Config) (Config, error) {
|
|
applyDeliveryDefaults(&cfg)
|
|
return cfg, ValidateDelivery(cfg)
|
|
}
|
|
|
|
// ResolveFreshBootstrap validates the scoped contract needed before a new
|
|
// Forgejo organization can be scaffolded. It deliberately does not validate
|
|
// recovery, SOPS, Proxmox, or Talos state.
|
|
func ResolveFreshBootstrap(cfg Config, organization string, enableDelivery bool) (Config, error) {
|
|
if !validRepositoryPart(organization) {
|
|
return cfg, errors.New("organization must be a Forgejo owner name")
|
|
}
|
|
if cfg.Git.Owner != "" && cfg.Git.Owner != organization {
|
|
return cfg, errors.New("git owner must match --organization")
|
|
}
|
|
cfg.Git.Owner = organization
|
|
if cfg.WorkspaceDir == "" || !filepath.IsAbs(cfg.WorkspaceDir) {
|
|
return cfg, errors.New("workspaceDir must be an absolute isolated workspace path")
|
|
}
|
|
if cfg.Git.CloneParent == "" || !filepath.IsAbs(cfg.Git.CloneParent) || !isChildPath(cfg.WorkspaceDir, cfg.Git.CloneParent) {
|
|
return cfg, errors.New("git cloneParent must be an absolute child of workspaceDir")
|
|
}
|
|
if cfg.Git.Provider != "forgejo" || cfg.Git.BaseURL == "" || cfg.Git.Username == "" || cfg.Git.Token == "" {
|
|
return cfg, errors.New("git provider, baseUrl, username, and token are required for fresh bootstrap")
|
|
}
|
|
if err := validateForgejoOrigin(cfg.Git.BaseURL); err != nil {
|
|
return cfg, err
|
|
}
|
|
if !validRepositoryPart(cfg.Flux.RepoName) || !validRepositoryPart(cfg.Flux.ManifestsRepo) || !validRepositoryPart(cfg.Flux.TektonCatalogRepo) || cfg.Flux.Branch == "" || cfg.Flux.ClusterPath == "" || cfg.Flux.ClusterDomain == "" {
|
|
return cfg, errors.New("flux repoName, manifestsRepo, tektonCatalogRepo, branch, clusterPath, and clusterDomain are required")
|
|
}
|
|
if cfg.Talos.RepoDirName == "" || filepath.Base(cfg.Talos.RepoDirName) != cfg.Talos.RepoDirName || cfg.Talos.GeneratedDir == "" {
|
|
return cfg, errors.New("talos repoDirName and generatedDir are required for template locking")
|
|
}
|
|
for _, source := range []struct{ URL, Ref string }{
|
|
{cfg.Templates.TalosRepoURL, cfg.Templates.TalosRepoRef},
|
|
{cfg.Templates.CICDRepoURL, cfg.Templates.CICDRepoRef},
|
|
{cfg.Templates.ManifestsRepoURL, cfg.Templates.ManifestsRepoRef},
|
|
{cfg.Templates.TektonCatalogRepoURL, cfg.Templates.TektonCatalogRepoRef},
|
|
} {
|
|
if source.URL == "" || source.Ref == "" || RedactURL(source.URL) != source.URL {
|
|
return cfg, errors.New("template repository URLs and refs must be explicit and credential-free")
|
|
}
|
|
if err := validateRepositoryURL(source.URL); err != nil {
|
|
return cfg, err
|
|
}
|
|
}
|
|
return cfg, nil
|
|
}
|
|
|
|
// ResolveAppOnboarding validates only the source-owned delivery contract.
|
|
func ResolveAppOnboarding(cfg Config) (Config, error) {
|
|
if cfg.Git.Owner == "" || cfg.Git.Username == "" || cfg.Git.Token == "" || cfg.Flux.RepoName == "" || cfg.Flux.ManifestsRepo == "" || cfg.Flux.Branch == "" || cfg.Flux.ClusterDomain == "" {
|
|
return cfg, errors.New("git owner, username, token, flux repoName, manifestsRepo, branch, and clusterDomain are required for app onboarding")
|
|
}
|
|
if err := validateForgejoOrigin(cfg.Git.BaseURL); err != nil {
|
|
return cfg, err
|
|
}
|
|
resolved, err := ResolveDelivery(cfg)
|
|
if err != nil {
|
|
return cfg, err
|
|
}
|
|
owner, repository, err := deliveryRepositoryOwner(resolved.Delivery.AppRepoURL)
|
|
if err != nil {
|
|
return cfg, err
|
|
}
|
|
if owner != resolved.Git.Owner && (owner != "Maidn" || !strings.HasPrefix(repository, "maidn-e2e-")) {
|
|
return cfg, errors.New("delivery appRepoUrl owner must match git owner or identify a canonical Maidn E2E fixture")
|
|
}
|
|
if resolved.Delivery.ProductionBranch != "production" {
|
|
return cfg, errors.New("delivery productionBranch must be literal production for app onboarding")
|
|
}
|
|
return resolved, nil
|
|
}
|
|
|
|
func deliveryRepositoryOwner(value string) (string, string, error) {
|
|
parsed, err := url.Parse(value)
|
|
if err != nil {
|
|
return "", "", errors.New("delivery appRepoUrl must identify one Forgejo owner/repository.git")
|
|
}
|
|
parts := strings.Split(strings.Trim(parsed.Path, "/"), "/")
|
|
if len(parts) != 2 || !strings.HasSuffix(parts[1], ".git") || !validRepositoryPart(parts[0]) || !validRepositoryPart(strings.TrimSuffix(parts[1], ".git")) {
|
|
return "", "", errors.New("delivery appRepoUrl must identify one Forgejo owner/repository.git")
|
|
}
|
|
return parts[0], strings.TrimSuffix(parts[1], ".git"), nil
|
|
}
|
|
|
|
func validRepositoryPart(value string) bool {
|
|
return value != "" && !strings.Contains(value, "..") && regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`).MatchString(value)
|
|
}
|
|
|
|
func isChildPath(parent, child string) bool {
|
|
relative, err := filepath.Rel(filepath.Clean(parent), filepath.Clean(child))
|
|
return err == nil && relative != "." && relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)) && !filepath.IsAbs(relative)
|
|
}
|
|
|
|
func validateForgejoOrigin(value string) error {
|
|
parsed, err := url.Parse(value)
|
|
if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil || parsed.RawPath != "" || parsed.RawQuery != "" || parsed.Fragment != "" || strings.Trim(parsed.Path, "/") != "" {
|
|
return errors.New("git baseUrl must be a credential-free HTTPS origin")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateRepositoryURL(value string) error {
|
|
parsed, err := url.Parse(value)
|
|
if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" || strings.Trim(parsed.Path, "/") == "" {
|
|
return errors.New("template repository URL must be a credential-free HTTPS repository URL")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func applyDefaults(cfg *Config) {
|
|
if cfg.Delivery.BuildStrategy == "" {
|
|
cfg.Delivery.BuildStrategy = "static"
|
|
}
|
|
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.Flux.TektonCatalogRepo == "" {
|
|
cfg.Flux.TektonCatalogRepo = "tekton-pipelines"
|
|
}
|
|
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.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 applyDeliveryDefaults(cfg *Config) {
|
|
if cfg.Delivery.BuildStrategy == "" {
|
|
cfg.Delivery.BuildStrategy = "static"
|
|
}
|
|
if cfg.Delivery.AppRepoRef == "" {
|
|
cfg.Delivery.AppRepoRef = cfg.Flux.Branch
|
|
}
|
|
if cfg.Delivery.BuildOutputDirectory == "" {
|
|
cfg.Delivery.BuildOutputDirectory = "dist"
|
|
}
|
|
if cfg.Delivery.BuildConfiguration == "" {
|
|
cfg.Delivery.BuildConfiguration = "production"
|
|
}
|
|
if cfg.Delivery.WebhookHostname == "" && cfg.Flux.ClusterDomain != "" {
|
|
cfg.Delivery.WebhookHostname = "tekton." + cfg.Flux.ClusterDomain
|
|
}
|
|
if cfg.Delivery.WebhookPath == "" {
|
|
cfg.Delivery.WebhookPath = "/"
|
|
}
|
|
}
|
|
|
|
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 RedactURL(cfg.Git.BaseURL) != cfg.Git.BaseURL {
|
|
return errors.New("git baseUrl must not contain credentials, a query, or a fragment")
|
|
}
|
|
if cfg.Flux.RepoName == "" || cfg.Flux.ClusterPath == "" || cfg.Flux.ManifestsRepo == "" || cfg.Flux.TektonCatalogRepo == "" || cfg.Flux.ClusterDomain == "" {
|
|
return errors.New("flux repoName, clusterDomain, manifestsRepo, tektonCatalogRepo, and clusterPath are required")
|
|
}
|
|
if !regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`).MatchString(cfg.Flux.TektonCatalogRepo) || strings.Contains(cfg.Flux.TektonCatalogRepo, "..") {
|
|
return errors.New("flux tektonCatalogRepo must be a repository name")
|
|
}
|
|
if len(cfg.Flux.ClusterDomain) > 253 || !regexp.MustCompile(`^[a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?(\.[a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?)*$`).MatchString(cfg.Flux.ClusterDomain) {
|
|
return errors.New("flux clusterDomain must be a lowercase DNS subdomain")
|
|
}
|
|
if cfg.Delivery.Configured() {
|
|
if err := ValidateDelivery(cfg); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if err := ValidateSecretGrants(cfg.SecretGrants); err != nil {
|
|
return err
|
|
}
|
|
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")
|
|
}
|
|
generatedDir := filepath.Clean(cfg.Talos.GeneratedDir)
|
|
if filepath.IsAbs(generatedDir) || generatedDir == "." || generatedDir == ".." || strings.HasPrefix(generatedDir, ".."+string(filepath.Separator)) {
|
|
return errors.New("talos generatedDir must be a child directory")
|
|
}
|
|
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
|
|
}
|
|
|
|
// ValidateSecretGrants prevents a configuration change from widening an
|
|
// application's OpenBao policy outside its own path or named shared grants.
|
|
func ValidateSecretGrants(grants []SecretGrant) error {
|
|
name := regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`)
|
|
seen := map[string]bool{}
|
|
for _, grant := range grants {
|
|
if !name.MatchString(grant.Application) {
|
|
return errors.New("secret grant application must be a lowercase DNS label")
|
|
}
|
|
if grant.Consumer != "build" && grant.Consumer != "publish" && grant.Consumer != "runtime" {
|
|
return errors.New("secret grant consumer must be build, publish, or runtime")
|
|
}
|
|
if grant.Consumer == "runtime" {
|
|
if grant.Environment != "staging" && grant.Environment != "production" {
|
|
return errors.New("runtime secret grant environment must be staging or production")
|
|
}
|
|
} else if grant.Environment != "" {
|
|
return errors.New("build and publish secret grants must not set environment")
|
|
}
|
|
key := grant.Application + "/" + grant.Consumer + "/" + grant.Environment
|
|
if seen[key] {
|
|
return errors.New("duplicate secret grant consumer")
|
|
}
|
|
seen[key] = true
|
|
if len(grant.Secrets) == 0 {
|
|
return errors.New("secret grant requires at least one application secret")
|
|
}
|
|
secretNames := map[string]bool{}
|
|
for _, secret := range grant.Secrets {
|
|
if !name.MatchString(secret) || secretNames[secret] {
|
|
return errors.New("secret grant application secret names must be unique lowercase DNS labels")
|
|
}
|
|
secretNames[secret] = true
|
|
}
|
|
for _, shared := range grant.Shared {
|
|
if !name.MatchString(shared) {
|
|
return errors.New("shared secret grant name must be a lowercase DNS label")
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ValidateDelivery requires the complete app-delivery contract before rendering or publishing it.
|
|
func ValidateDelivery(cfg Config) error {
|
|
if cfg.Delivery.AppName == "" || cfg.Delivery.AppRepoURL == "" || cfg.Delivery.AppRepoRef == "" || cfg.Delivery.ProductionBranch == "" || cfg.Delivery.ImageRepository == "" || cfg.Delivery.BuildOutputDirectory == "" || cfg.Delivery.BuildConfiguration == "" || cfg.Delivery.WebhookHostname == "" || cfg.Delivery.WebhookPath == "" {
|
|
return errors.New("delivery appName, appRepoUrl, appRepoRef, productionBranch, imageRepository, buildOutputDirectory, buildConfiguration, webhookHostname, and webhookPath are required")
|
|
}
|
|
if !regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,45}[a-z0-9])?$`).MatchString(cfg.Delivery.AppName) {
|
|
return errors.New("delivery appName must be a lowercase DNS label of at most 47 characters")
|
|
}
|
|
if cfg.Delivery.BuildStrategy != "static" && cfg.Delivery.BuildStrategy != "runtime" {
|
|
return errors.New("delivery buildStrategy must be static or runtime")
|
|
}
|
|
if cfg.Delivery.ProductionBranch == cfg.Delivery.AppRepoRef || !validDeliveryBranch(cfg.Delivery.ProductionBranch) {
|
|
return errors.New("delivery productionBranch must be a valid branch distinct from appRepoRef")
|
|
}
|
|
if RedactURL(cfg.Delivery.AppRepoURL) != cfg.Delivery.AppRepoURL {
|
|
return errors.New("delivery appRepoUrl must not contain credentials, a query, or a fragment")
|
|
}
|
|
if err := validateDeliveryRepositoryOrigin(cfg.Git.BaseURL, cfg.Delivery.AppRepoURL); err != nil {
|
|
return err
|
|
}
|
|
if cfg.Delivery.TektonDashboardURL != "" {
|
|
if err := validateForgejoOrigin(cfg.Delivery.TektonDashboardURL); err != nil {
|
|
return errors.New("delivery tektonDashboardUrl must be a credential-free HTTPS origin")
|
|
}
|
|
}
|
|
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")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateDeliveryRepositoryOrigin(baseURL, repositoryURL string) error {
|
|
base, err := url.Parse(baseURL)
|
|
if err != nil || base.Scheme != "https" || base.Host == "" || base.User != nil || base.RawPath != "" || base.RawQuery != "" || base.Fragment != "" || strings.Trim(base.Path, "/") != "" {
|
|
return errors.New("git baseUrl must be a credential-free HTTPS origin")
|
|
}
|
|
repository, err := url.Parse(repositoryURL)
|
|
if err != nil || repository.Scheme != "https" || repository.Host == "" || repository.User != nil || repository.RawPath != "" || repository.RawQuery != "" || repository.Fragment != "" || !strings.EqualFold(repository.Scheme+"://"+repository.Host, base.Scheme+"://"+base.Host) {
|
|
return errors.New("delivery appRepoUrl must be a credential-free HTTPS repository on the configured Forgejo origin")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validDeliveryBranch(value string) bool {
|
|
if !regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._/-]*$`).MatchString(value) || strings.Contains(value, "..") || strings.Contains(value, "//") || strings.HasSuffix(value, ".") || strings.HasSuffix(value, ".lock") {
|
|
return false
|
|
}
|
|
for _, part := range strings.Split(value, "/") {
|
|
if strings.HasPrefix(part, ".") {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func Preflight(cfg Config) error {
|
|
return talos.RequireCLICompatibility(cfg.Talos.Image.TalosVersion)
|
|
}
|