fix: require complete delivery for reconcile #16

Merged
eding merged 1 commit from fix/delivery-opt-in-reconcile into main 2026-08-23 19:23:30 +02:00
6 changed files with 135 additions and 48 deletions
Showing only changes of commit 433305e0aa - Show all commits

View file

@ -65,7 +65,8 @@ func runBootstrap(cmd *cobra.Command, args []string) error {
if err != nil { if err != nil {
return err return err
} }
if err := config.ValidateDelivery(cfg); err != nil { cfg, err = config.ResolveDelivery(cfg)
if err != nil {
return err return err
} }
if err := bootstrap.EnsureTemplateRevisions(cfg); err != nil { if err := bootstrap.EnsureTemplateRevisions(cfg); err != nil {
@ -81,7 +82,8 @@ func runBootstrap(cmd *cobra.Command, args []string) error {
if err != nil { if err != nil {
return err return err
} }
if err := config.ValidateDelivery(cfg); err != nil { cfg, err = config.ResolveDelivery(cfg)
if err != nil {
return err return err
} }
authorization, err := bootstrap.NewWebhookAuthorization() authorization, err := bootstrap.NewWebhookAuthorization()
@ -130,7 +132,8 @@ func runBootstrap(cmd *cobra.Command, args []string) error {
if err != nil { if err != nil {
return err return err
} }
if err := config.ValidateDelivery(cfg); err != nil { cfg, err = config.ResolveDelivery(cfg)
if err != nil {
return err return err
} }
if err := ensurePublishAppCheckoutClean(bootstrapPublishAppFrom); err != nil { if err := ensurePublishAppCheckoutClean(bootstrapPublishAppFrom); err != nil {
@ -192,7 +195,7 @@ func runBootstrap(cmd *cobra.Command, args []string) error {
cfg, err = config.Resolve(cfg) cfg, err = config.Resolve(cfg)
} }
if err == nil && bootstrapPromptOperationalSecrets { if err == nil && bootstrapPromptOperationalSecrets {
err = config.ValidateDelivery(cfg) cfg, err = config.ResolveDelivery(cfg)
} }
if err == nil && bootstrapPromptOperationalSecrets { if err == nil && bootstrapPromptOperationalSecrets {
var secrets map[string]map[string]string var secrets map[string]map[string]string
@ -235,7 +238,8 @@ func runBootstrap(cmd *cobra.Command, args []string) error {
return err return err
} }
if bootstrapRegisterWebhook { if bootstrapRegisterWebhook {
if err := config.ValidateDelivery(cfg); err != nil { cfg, err = config.ResolveDelivery(cfg)
if err != nil {
return err return err
} }
if err := seedForgejoOperationalCredentials(cfg); err != nil { if err := seedForgejoOperationalCredentials(cfg); err != nil {

View file

@ -122,12 +122,14 @@ func (r Runner) Run() error {
return err return err
} }
r.Config = resolved r.Config = resolved
deliveryConfigured := r.Config.Delivery.Configured() if r.RegisterWebhook {
if r.RegisterWebhook || r.Config.Talos.AutoBootstrapFlux { resolvedDelivery, err := config.ResolveDelivery(r.Config)
if err := config.ValidateDelivery(r.Config); err != nil { if err != nil {
return err return err
} }
r.Config = resolvedDelivery
} }
deliveryConfigured := r.Config.Delivery.Configured()
if err := preflight(r.Config); err != nil { if err := preflight(r.Config); err != nil {
return fmt.Errorf("preflight: %w", err) return fmt.Errorf("preflight: %w", err)
} }

View file

@ -243,6 +243,30 @@ func TestCopyTemplateBaseComponentsRefreshesCNPGAndPreservesGeneratedSecrets(t *
} }
} }
func TestPlatformCopyPreservesExistingDeliveryBases(t *testing.T) {
templateDir := t.TempDir()
repoDir := t.TempDir()
for _, component := range templateBaseComponents {
if err := os.MkdirAll(filepath.Join(templateDir, "base", component), 0755); err != nil {
t.Fatal(err)
}
}
legacy := filepath.Join(repoDir, "base", "tekton", "legacy-delivery.yaml")
if err := os.MkdirAll(filepath.Dir(legacy), 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(legacy, []byte("generated: delivery\n"), 0644); err != nil {
t.Fatal(err)
}
if err := copyTemplateBaseComponents(templateDir, repoDir, false); err != nil {
t.Fatal(err)
}
content, err := os.ReadFile(legacy)
if err != nil || string(content) != "generated: delivery\n" {
t.Fatalf("platform reconciliation removed existing delivery output: %q, %v", content, err)
}
}
func TestCopyAndRenderCiliumBasesRefreshesTemplateWithoutLeavingPlaceholders(t *testing.T) { func TestCopyAndRenderCiliumBasesRefreshesTemplateWithoutLeavingPlaceholders(t *testing.T) {
templateDir := t.TempDir() templateDir := t.TempDir()
repoDir := t.TempDir() repoDir := t.TempDir()
@ -651,6 +675,41 @@ func TestWebhookTargetTimeoutExceedsExternalSecretRefreshInterval(t *testing.T)
} }
} }
func runnerTestConfig(workspace, ageKeyPath string) config.Config {
return config.Config{
ClusterID: "test-cluster",
WorkspaceDir: workspace,
Git: config.GitConfig{Provider: "forgejo", BaseURL: "https://git.example.test", Username: "bot", Token: "token", Owner: "test-org", CloneParent: filepath.Join(workspace, "checkouts")},
Flux: config.FluxConfig{RepoName: "cluster", Branch: "main", ClusterPath: "./clusters/test", ClusterDomain: "example.test", ManifestsRepo: "manifests"},
Templates: config.TemplateConfig{TalosRepoURL: "https://git.example.test/talos.git", TalosRepoRef: "main", CICDRepoURL: "https://git.example.test/template.git", CICDRepoRef: "main", ManifestsRepoURL: "https://git.example.test/manifests.git", ManifestsRepoRef: "main"},
Cilium: config.CiliumConfig{TrafficInterface: "eth1", LoadBalancerStart: "192.168.45.19", LoadBalancerEnd: "192.168.45.30"},
DemocraticCSI: config.DemocraticCSIConfig{TrueNASAPIKey: "key", TrueNASHost: "truenas.example.test", TargetPortal: "truenas.example.test:3260", ShareHost: "truenas.example.test", DatasetParentNFS: "pool/kubernetes/nfs/v", DatasetSnapshotsNFS: "pool/kubernetes/nfs/s", AllowedNetworks: "192.168.45.0/24", NameSuffix: "-test", PortalGroup: "1", InitiatorGroup: "1"},
Delivery: config.DeliveryConfig{AppName: "app", AppRepoURL: "https://git.example.test/app.git", AppRepoRef: "main", ProductionBranch: "production", ImageRepository: "registry.example.test/test/app", BuildOutputDirectory: "dist", BuildConfiguration: "production", WebhookHostname: "tekton.example.test", WebhookPath: "/"},
SOPS: config.SOPSConfig{AgeKeyPath: ageKeyPath},
Talos: config.TalosConfig{
RepoDirName: "talos", TerraformDir: "terraform", GeneratedDir: "generated", ConfigFileName: "terraform.tfvars",
Proxmox: config.TalosProxmoxConfig{APIURL: "https://proxmox.example.test:8006", APITokenID: "id", APITokenSecret: "secret"},
Cluster: config.TalosClusterConfig{Name: "test-cluster", Domain: "example.test"},
Image: config.TalosImageConfig{TalosVersion: "v1.13.6", KubernetesVersion: "v1.33.4", SchematicID: "abcdefghijkl"},
Nodes: []config.TalosNode{{Name: "cp-01", VMID: 100, Role: "controlplane", Networks: []config.TalosNetwork{{IP: "192.168.45.3", CIDR: "192.168.45.0/28", Gateway: "192.168.45.1", VLANID: 45}, {IP: "192.168.45.18", CIDR: "192.168.45.16/28", VLANID: 451}}}},
},
}
}
func TestRunnerAutoBootstrapFluxIgnoresPartialLegacyDelivery(t *testing.T) {
originalPreflight := preflight
t.Cleanup(func() { preflight = originalPreflight })
preflight = func(config.Config) error { return errors.New("reached preflight") }
cfg := runnerTestConfig(t.TempDir(), "")
cfg.Delivery = config.DeliveryConfig{AppName: "legacy-app", AppRepoURL: "https://git.example.test/test-org/legacy-app.git", ProductionBranch: "production", ImageRepository: "registry.example.test/test-org/legacy-app"}
cfg.Talos.AutoBootstrapFlux = true
err := (Runner{Config: cfg}).Run()
if err == nil || !strings.Contains(err.Error(), "reached preflight") {
t.Fatalf("platform reconcile validated partial delivery before preflight: %v", err)
}
}
func TestRunnerRegisterWebhookSkipsTemplateRevisions(t *testing.T) { func TestRunnerRegisterWebhookSkipsTemplateRevisions(t *testing.T) {
originalPreflight := preflight originalPreflight := preflight
originalGit := runGit originalGit := runGit
@ -692,24 +751,7 @@ func TestRunnerRegisterWebhookSkipsTemplateRevisions(t *testing.T) {
return nil return nil
} }
cfg := config.Config{ cfg := runnerTestConfig(workspace, ageKeyPath)
ClusterID: "test-cluster",
WorkspaceDir: workspace,
Git: config.GitConfig{Provider: "forgejo", BaseURL: "https://git.example.test", Username: "bot", Token: "token", Owner: "test-org", CloneParent: filepath.Join(workspace, "checkouts")},
Flux: config.FluxConfig{RepoName: "cluster", Branch: "main", ClusterPath: "./clusters/test", ClusterDomain: "example.test", ManifestsRepo: "manifests"},
Templates: config.TemplateConfig{TalosRepoURL: "https://git.example.test/talos.git", TalosRepoRef: "main", CICDRepoURL: "https://git.example.test/template.git", CICDRepoRef: "main", ManifestsRepoURL: "https://git.example.test/manifests.git", ManifestsRepoRef: "main"},
Cilium: config.CiliumConfig{TrafficInterface: "eth1", LoadBalancerStart: "192.168.45.19", LoadBalancerEnd: "192.168.45.30"},
DemocraticCSI: config.DemocraticCSIConfig{TrueNASAPIKey: "key", TrueNASHost: "truenas.example.test", TargetPortal: "truenas.example.test:3260", ShareHost: "truenas.example.test", DatasetParentNFS: "pool/kubernetes/nfs/v", DatasetSnapshotsNFS: "pool/kubernetes/nfs/s", AllowedNetworks: "192.168.45.0/24", NameSuffix: "-test", PortalGroup: "1", InitiatorGroup: "1"},
Delivery: config.DeliveryConfig{AppName: "app", AppRepoURL: "https://git.example.test/app.git", AppRepoRef: "main", ProductionBranch: "production", ImageRepository: "registry.example.test/test/app", WebhookHostname: "tekton.example.test", WebhookPath: "/"},
SOPS: config.SOPSConfig{AgeKeyPath: ageKeyPath},
Talos: config.TalosConfig{
RepoDirName: "talos", TerraformDir: "terraform", GeneratedDir: "generated", ConfigFileName: "terraform.tfvars",
Proxmox: config.TalosProxmoxConfig{APIURL: "https://proxmox.example.test:8006", APITokenID: "id", APITokenSecret: "secret"},
Cluster: config.TalosClusterConfig{Name: "test-cluster", Domain: "example.test"},
Image: config.TalosImageConfig{TalosVersion: "v1.13.6", KubernetesVersion: "v1.33.4", SchematicID: "abcdefghijkl"},
Nodes: []config.TalosNode{{Name: "cp-01", VMID: 100, Role: "controlplane", Networks: []config.TalosNetwork{{IP: "192.168.45.3", CIDR: "192.168.45.0/28", Gateway: "192.168.45.1", VLANID: 45}, {IP: "192.168.45.18", CIDR: "192.168.45.16/28", VLANID: 451}}}},
},
}
partial := cfg partial := cfg
partial.Delivery.ImageRepository = "" partial.Delivery.ImageRepository = ""
preflight = func(config.Config) error { preflight = func(config.Config) error {

View file

@ -100,6 +100,12 @@ func Resolve(cfg Config) (Config, error) {
return cfg, Validate(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)
}
func applyDefaults(cfg *Config) { func applyDefaults(cfg *Config) {
if cfg.ClusterID == "" { if cfg.ClusterID == "" {
cfg.ClusterID = cfg.Talos.Cluster.Name cfg.ClusterID = cfg.Talos.Cluster.Name
@ -158,23 +164,6 @@ func applyDefaults(cfg *Config) {
if cfg.DemocraticCSI.InitiatorGroup == "" { if cfg.DemocraticCSI.InitiatorGroup == "" {
cfg.DemocraticCSI.InitiatorGroup = "1" cfg.DemocraticCSI.InitiatorGroup = "1"
} }
if cfg.Delivery.Configured() {
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 = "/"
}
}
if cfg.Talos.RepoDirName == "" { if cfg.Talos.RepoDirName == "" {
cfg.Talos.RepoDirName = "maidn-talos-proxmox" cfg.Talos.RepoDirName = "maidn-talos-proxmox"
} }
@ -219,6 +208,24 @@ func applyDefaults(cfg *Config) {
} }
} }
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 { func Validate(cfg Config) error {
if !regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`).MatchString(cfg.ClusterID) { 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") return errors.New("clusterId must be a lowercase DNS label")

View file

@ -17,7 +17,7 @@ func validConfig(t *testing.T) Config {
Templates: TemplateConfig{TalosRepoURL: "https://git.example.test/talos.git", TalosRepoRef: "main", CICDRepoURL: "https://git.example.test/template.git", CICDRepoRef: "main", ManifestsRepoURL: "https://git.example.test/manifests.git", ManifestsRepoRef: "main"}, Templates: TemplateConfig{TalosRepoURL: "https://git.example.test/talos.git", TalosRepoRef: "main", CICDRepoURL: "https://git.example.test/template.git", CICDRepoRef: "main", ManifestsRepoURL: "https://git.example.test/manifests.git", ManifestsRepoRef: "main"},
Cilium: CiliumConfig{TrafficInterface: "eth1", LoadBalancerStart: "192.168.45.19", LoadBalancerEnd: "192.168.45.30"}, Cilium: CiliumConfig{TrafficInterface: "eth1", LoadBalancerStart: "192.168.45.19", LoadBalancerEnd: "192.168.45.30"},
DemocraticCSI: DemocraticCSIConfig{TrueNASAPIKey: "api-key", TrueNASHost: "truenas.example.test", TargetPortal: "truenas.example.test:3260", ShareHost: "truenas.example.test", DatasetParentNFS: "pool/kubernetes/nfs/v", DatasetSnapshotsNFS: "pool/kubernetes/nfs/s", AllowedNetworks: "192.168.45.0/24", NameSuffix: "-test", PortalGroup: "1", InitiatorGroup: "1"}, DemocraticCSI: DemocraticCSIConfig{TrueNASAPIKey: "api-key", TrueNASHost: "truenas.example.test", TargetPortal: "truenas.example.test:3260", ShareHost: "truenas.example.test", DatasetParentNFS: "pool/kubernetes/nfs/v", DatasetSnapshotsNFS: "pool/kubernetes/nfs/s", AllowedNetworks: "192.168.45.0/24", NameSuffix: "-test", PortalGroup: "1", InitiatorGroup: "1"},
Delivery: DeliveryConfig{AppName: "web-ui", AppRepoURL: "https://git.example.test/test-org/web-ui.git", AppRepoRef: "main", ProductionBranch: "production", ImageRepository: "registry.example.test/test-org/web-ui", WebhookHostname: "tekton.example.test", WebhookPath: "/"}, Delivery: DeliveryConfig{AppName: "web-ui", AppRepoURL: "https://git.example.test/test-org/web-ui.git", AppRepoRef: "main", ProductionBranch: "production", ImageRepository: "registry.example.test/test-org/web-ui", BuildOutputDirectory: "dist", BuildConfiguration: "production", WebhookHostname: "tekton.example.test", WebhookPath: "/"},
Talos: TalosConfig{ Talos: TalosConfig{
RepoDirName: "talos", TerraformDir: "terraform", GeneratedDir: "generated", ConfigFileName: "terraform.tfvars", RepoDirName: "talos", TerraformDir: "terraform", GeneratedDir: "generated", ConfigFileName: "terraform.tfvars",
Proxmox: TalosProxmoxConfig{APIURL: "https://proxmox.example.test:8006", APITokenID: "id", APITokenSecret: "secret"}, Proxmox: TalosProxmoxConfig{APIURL: "https://proxmox.example.test:8006", APITokenID: "id", APITokenSecret: "secret"},
@ -32,7 +32,8 @@ func TestResolveDeliveryStates(t *testing.T) {
platformOnly := validConfig(t) platformOnly := validConfig(t)
platformOnly.Delivery = DeliveryConfig{} platformOnly.Delivery = DeliveryConfig{}
partialLegacy := platformOnly partialLegacy := platformOnly
partialLegacy.Delivery.AppName = "legacy-app" partialLegacy.Delivery = DeliveryConfig{AppName: "legacy-app", AppRepoURL: "https://git.example.test/test-org/legacy-app.git", ProductionBranch: "production", ImageRepository: "registry.example.test/test-org/legacy-app"}
partialLegacy.Talos.AutoBootstrapFlux = true
for _, test := range []struct { for _, test := range []struct {
name string name string
@ -58,6 +59,30 @@ func TestResolveDeliveryStates(t *testing.T) {
} }
} }
func TestResolveDeliveryAppliesDefaultsOnlyForExplicitAppOperations(t *testing.T) {
cfg := validConfig(t)
cfg.Delivery.AppRepoRef = ""
cfg.Delivery.BuildOutputDirectory = ""
cfg.Delivery.BuildConfiguration = ""
cfg.Delivery.WebhookHostname = ""
cfg.Delivery.WebhookPath = ""
platform, err := Resolve(cfg)
if err != nil {
t.Fatal(err)
}
if platform.Delivery.Configured() {
t.Fatal("platform resolution treated incomplete delivery as configured")
}
delivery, err := ResolveDelivery(platform)
if err != nil {
t.Fatal(err)
}
if !delivery.Delivery.Configured() || delivery.Delivery.AppRepoRef != "main" || delivery.Delivery.BuildOutputDirectory != "dist" || delivery.Delivery.BuildConfiguration != "production" || delivery.Delivery.WebhookURL() != "https://tekton.example.test/" {
t.Fatalf("ResolveDelivery() did not apply the complete delivery contract: %#v", delivery.Delivery)
}
}
func TestValidateDeliveryRequiresCompleteConfig(t *testing.T) { func TestValidateDeliveryRequiresCompleteConfig(t *testing.T) {
cfg := validConfig(t) cfg := validConfig(t)
cfg.Delivery.ImageRepository = "" cfg.Delivery.ImageRepository = ""
@ -99,7 +124,14 @@ func TestValidateRequiresDistinctValidProductionBranch(t *testing.T) {
} }
func TestResolveDefaultsWebhookEndpoint(t *testing.T) { func TestResolveDefaultsWebhookEndpoint(t *testing.T) {
cfg, err := Resolve(validConfig(t)) input := validConfig(t)
input.Delivery.WebhookHostname = ""
input.Delivery.WebhookPath = ""
platform, err := Resolve(input)
if err != nil {
t.Fatal(err)
}
cfg, err := ResolveDelivery(platform)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }

View file

@ -40,9 +40,9 @@ type DeliveryConfig struct {
WebhookPath string `yaml:"webhookPath"` WebhookPath string `yaml:"webhookPath"`
} }
// Configured reports whether the non-default app-delivery settings are complete. // Configured reports whether the app-delivery rendering contract is complete.
func (c DeliveryConfig) Configured() bool { func (c DeliveryConfig) Configured() bool {
return c.AppName != "" && c.AppRepoURL != "" && c.ProductionBranch != "" && c.ImageRepository != "" return c.AppName != "" && c.AppRepoURL != "" && c.AppRepoRef != "" && c.ProductionBranch != "" && c.ImageRepository != "" && c.BuildOutputDirectory != "" && c.BuildConfiguration != "" && c.WebhookHostname != "" && c.WebhookPath != ""
} }
func (c DeliveryConfig) WebhookURL() string { func (c DeliveryConfig) WebhookURL() string {