maidn-cli/internal/config/config.go

586 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)
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, _, err := deliveryRepositoryOwner(resolved.Delivery.AppRepoURL)
if err != nil {
return cfg, err
}
if owner != resolved.Git.Owner {
return cfg, errors.New("delivery appRepoUrl owner must match git owner for app onboarding")
}
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.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.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,61}[a-z0-9])?$`).MatchString(cfg.Delivery.AppName) {
return errors.New("delivery appName must be a lowercase DNS label")
}
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 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)
}