From dbd370b20d72974ff48f6547cab4fdae1b6913b6 Mon Sep 17 00:00:00 2001 From: eding Date: Sat, 22 Aug 2026 14:29:47 +0200 Subject: [PATCH] fix: allow platform-only reconcile --- cmd/bootstrap.go | 21 +++++++- cmd/bootstrap_test.go | 25 ++++++++++ internal/bootstrap/bootstrap.go | 43 +++++++++++++--- internal/bootstrap/bootstrap_test.go | 19 +++++-- internal/config/config.go | 74 ++++++++++++++++------------ internal/config/config_test.go | 24 +++++++-- internal/config/types.go | 5 ++ 7 files changed, 164 insertions(+), 47 deletions(-) diff --git a/cmd/bootstrap.go b/cmd/bootstrap.go index c375efc..2fd28bf 100644 --- a/cmd/bootstrap.go +++ b/cmd/bootstrap.go @@ -27,6 +27,8 @@ var bootstrapMergeBootstrapPR bool var bootstrapManageNetworkBridges bool var upsertOperationalSecret = bootstrap.UpsertOperationalSecret +var loadPublishAppConfig = config.Load +var ensurePublishAppCheckoutClean = forgejo.EnsureCleanCheckout var bootstrapCmd = &cobra.Command{ Use: "bootstrap", @@ -63,6 +65,9 @@ func runBootstrap(cmd *cobra.Command, args []string) error { if err != nil { return err } + if err := config.ValidateDelivery(cfg); err != nil { + return err + } if err := bootstrap.EnsureTemplateRevisions(cfg); err != nil { return err } @@ -76,6 +81,9 @@ func runBootstrap(cmd *cobra.Command, args []string) error { if err != nil { return err } + if err := config.ValidateDelivery(cfg); err != nil { + return err + } authorization, err := bootstrap.NewWebhookAuthorization() if err != nil { return fmt.Errorf("generate Forgejo webhook authorization: %w", err) @@ -118,11 +126,14 @@ func runBootstrap(cmd *cobra.Command, args []string) error { if bootstrapConfigPath == "" { return fmt.Errorf("--publish-app-from requires --config") } - cfg, err = config.Load(bootstrapConfigPath) + cfg, err = loadPublishAppConfig(bootstrapConfigPath) if err != nil { return err } - if err := forgejo.EnsureCleanCheckout(bootstrapPublishAppFrom); err != nil { + if err := config.ValidateDelivery(cfg); err != nil { + return err + } + if err := ensurePublishAppCheckoutClean(bootstrapPublishAppFrom); err != nil { return err } origin, err := forgejo.CheckoutOrigin(bootstrapPublishAppFrom) @@ -180,6 +191,9 @@ func runBootstrap(cmd *cobra.Command, args []string) error { } cfg, err = config.Resolve(cfg) } + if err == nil && bootstrapPromptOperationalSecrets { + err = config.ValidateDelivery(cfg) + } if err == nil && bootstrapPromptOperationalSecrets { var secrets map[string]map[string]string secrets, err = ui.PromptOperationalSecrets(cfg) @@ -221,6 +235,9 @@ func runBootstrap(cmd *cobra.Command, args []string) error { return err } if bootstrapRegisterWebhook { + if err := config.ValidateDelivery(cfg); err != nil { + return err + } if err := seedForgejoOperationalCredentials(cfg); err != nil { return err } diff --git a/cmd/bootstrap_test.go b/cmd/bootstrap_test.go index 3342ea5..7596e90 100644 --- a/cmd/bootstrap_test.go +++ b/cmd/bootstrap_test.go @@ -21,6 +21,31 @@ func TestCreateForgejoRegistryTokenRequiresConfig(t *testing.T) { } } +func TestPublishAppRequiresDeliveryConfigBeforeCheckout(t *testing.T) { + originalConfigPath, originalPublish := bootstrapConfigPath, bootstrapPublishAppFrom + originalLoad, originalClean := loadPublishAppConfig, ensurePublishAppCheckoutClean + t.Cleanup(func() { + bootstrapConfigPath = originalConfigPath + bootstrapPublishAppFrom = originalPublish + loadPublishAppConfig = originalLoad + ensurePublishAppCheckoutClean = originalClean + }) + bootstrapConfigPath = "test-config.yaml" + bootstrapPublishAppFrom = "app-checkout" + loadPublishAppConfig = func(string) (config.Config, error) { + return config.Config{Git: config.GitConfig{BaseURL: "https://git.example.test"}}, nil + } + ensurePublishAppCheckoutClean = func(string) error { + t.Fatal("publish inspected the checkout before validating delivery config") + return nil + } + + err := runBootstrap(nil, nil) + if err == nil || !strings.Contains(err.Error(), "delivery appName") { + t.Fatalf("runBootstrap() error = %v, want incomplete delivery error", err) + } +} + func TestSeedForgejoOperationalCredentialsUsesEncryptedUpsertBoundary(t *testing.T) { original := upsertOperationalSecret t.Cleanup(func() { upsertOperationalSecret = original }) diff --git a/internal/bootstrap/bootstrap.go b/internal/bootstrap/bootstrap.go index 1cac01d..c96a08e 100644 --- a/internal/bootstrap/bootstrap.go +++ b/internal/bootstrap/bootstrap.go @@ -107,6 +107,8 @@ var webhookTargetPollInterval = 2 * time.Second var templateBaseComponents = []string{"snapshot-crds", "democratic-csi", "cert-manager", "cluster-issuers", "gateway-api", "gateway", "monitoring", "openbao", "external-secrets", "cnpg", "cloudflare-tunnel", "external-dns", "tekton", "tekton-triggers"} +var deliveryTemplateBaseComponents = map[string]bool{"gateway": true, "tekton": true, "tekton-triggers": true} + var generatedTemplateFiles = map[string]map[string]bool{ "democratic-csi": {"secret.sops.yaml": true}, "openbao": {"unseal.sops.yaml": true}, @@ -120,6 +122,12 @@ func (r Runner) Run() error { return err } r.Config = resolved + deliveryConfigured := r.Config.Delivery.Configured() + if r.RegisterWebhook || r.Config.Talos.AutoBootstrapFlux { + if err := config.ValidateDelivery(r.Config); err != nil { + return err + } + } if err := preflight(r.Config); err != nil { return fmt.Errorf("preflight: %w", err) } @@ -180,16 +188,23 @@ func (r Runner) Run() error { }, func(dir string) error { clusterDir := filepath.Join(dir, strings.TrimPrefix(r.Config.Flux.ClusterPath, "./")) - if err := copyDir(filepath.Join(cicdTemplateDir, "base"), filepath.Join(dir, "base"), false); err != nil { + if deliveryConfigured { + if err := copyDir(filepath.Join(cicdTemplateDir, "base"), filepath.Join(dir, "base"), false); err != nil { + return err + } + } else if err := copyDirExcept(filepath.Join(cicdTemplateDir, "base"), filepath.Join(dir, "base"), false, deliveryTemplateBaseComponents); err != nil { return err } - if err := copyTemplateBaseComponents(cicdTemplateDir, dir); err != nil { + if err := copyTemplateBaseComponents(cicdTemplateDir, dir, deliveryConfigured); err != nil { return err } if err := copyClusterTemplate(filepath.Join(cicdTemplateDir, "clusters", "template"), clusterDir); err != nil { return err } for _, name := range []string{"external-secrets", "cnpg", "cloudflare-tunnel", "external-dns", "monitoring", "tekton"} { + if !deliveryConfigured && name == "tekton" { + continue + } content, err := os.ReadFile(filepath.Join(cicdTemplateDir, "clusters", "template", name+"-kustomization.yaml")) if err != nil { return err @@ -208,8 +223,10 @@ func (r Runner) Run() error { if err := copyAndRenderCiliumBases(cicdTemplateDir, dir, r.Config); err != nil { return err } - if err := copyAndRenderDeliveryBases(cicdTemplateDir, dir, r.Config); err != nil { - return err + if deliveryConfigured { + if err := copyAndRenderDeliveryBases(cicdTemplateDir, dir, r.Config); err != nil { + return err + } } if err := writeDemocraticCSISecret(filepath.Join(dir, "base", "democratic-csi", "secret.sops.yaml"), r.Config.DemocraticCSI, r.Config.SOPS.AgeKeyPath); err != nil { return err @@ -221,7 +238,7 @@ func (r Runner) Run() error { if err := ensureOpenBaoUnsealKustomization(filepath.Join(openbaoDir, "kustomization.yaml")); err != nil { return err } - if err := ensureClusterKustomizations(clusterDir); err != nil { + if err := ensureClusterKustomizations(clusterDir, deliveryConfigured); err != nil { return err } return ghrepo.WriteFluxStructure(dir, r.Config.Flux.RepoName, r.Config.Flux.ClusterPath, fluxConfig) @@ -381,6 +398,9 @@ func renderDeliveryConfig(dir string, cfg config.Config) error { } func copyAndRenderDeliveryBases(templateDir, repoDir string, cfg config.Config) error { + if err := config.ValidateDelivery(cfg); err != nil { + return err + } bases := []string{"gateway", "tekton", "tekton-triggers"} for _, base := range bases { baseDir := filepath.Join(repoDir, "base", base) @@ -464,6 +484,9 @@ var deliveryAppName = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`) // GenerateAppDelivery writes the source-owned Tekton delivery contract for an app checkout. func GenerateAppDelivery(dir string, cfg config.Config) error { + if err := config.ValidateDelivery(cfg); err != nil { + return err + } content, err := renderAppDelivery(cfg) if err != nil { return err @@ -964,7 +987,7 @@ func waitForWebhookAuthorization(dir, authorization string) error { } } -func ensureClusterKustomizations(clusterDir string) error { +func ensureClusterKustomizations(clusterDir string, includeDelivery bool) error { path := filepath.Join(clusterDir, "kustomization.yaml") content, err := os.ReadFile(path) if os.IsNotExist(err) { @@ -978,6 +1001,9 @@ func ensureClusterKustomizations(clusterDir string) error { updated := string(content) updated = strings.ReplaceAll(updated, " - bootstrap-secrets.sops.yaml\n", "") for _, resource := range requiredClusterKustomizations { + if !includeDelivery && (resource == "gateway-kustomization.yaml" || resource == "tekton-kustomization.yaml" || resource == "tekton-triggers-kustomization.yaml") { + continue + } if !strings.Contains(updated, resource) { updated += " - " + resource + "\n" } @@ -988,8 +1014,11 @@ func ensureClusterKustomizations(clusterDir string) error { return os.WriteFile(path, []byte(updated), 0644) } -func copyTemplateBaseComponents(templateDir, repoDir string) error { +func copyTemplateBaseComponents(templateDir, repoDir string, includeDelivery bool) error { for _, component := range templateBaseComponents { + if !includeDelivery && deliveryTemplateBaseComponents[component] { + continue + } if err := copyDirExcept(filepath.Join(templateDir, "base", component), filepath.Join(repoDir, "base", component), true, generatedTemplateFiles[component]); err != nil { return err } diff --git a/internal/bootstrap/bootstrap_test.go b/internal/bootstrap/bootstrap_test.go index 348c591..4957bdc 100644 --- a/internal/bootstrap/bootstrap_test.go +++ b/internal/bootstrap/bootstrap_test.go @@ -68,7 +68,7 @@ func TestGeneratedDeliveryIsGenericAndUsesSafePreviewCleanupContract(t *testing. if err != nil { t.Fatal(err) } - for _, expected := range []string{"maidn-node-static-image", "maidn-preview-orphan-reconciler", "valid_pr_number()", "valid_commit()", "values: [promotion]", "values: [\"production\"]", "environment\n value: production", "cmp -s \"$expected_marker\" \"$marker\"", "values: [closed]"} { + for _, expected := range []string{"maidn-node-static-image", "maidn-preview-orphan-reconciler", "valid_pr_number()", "valid_commit()", "values: [promotion]", "values: [\"production\"]", "cmp -s \"$expected_marker\" \"$marker\"", "values: [closed]"} { if !strings.Contains(string(content), expected) { t.Fatalf("generated delivery does not contain %q", expected) } @@ -78,6 +78,17 @@ func TestGeneratedDeliveryIsGenericAndUsesSafePreviewCleanupContract(t *testing. } } +func TestGenerateAppDeliveryRequiresCompleteConfig(t *testing.T) { + dir := t.TempDir() + err := GenerateAppDelivery(dir, config.Config{Git: config.GitConfig{BaseURL: "https://git.example.test"}}) + if err == nil || !strings.Contains(err.Error(), "delivery appName") { + t.Fatalf("GenerateAppDelivery() error = %v, want incomplete delivery error", err) + } + if _, statErr := os.Stat(filepath.Join(dir, ".tekton")); !os.IsNotExist(statErr) { + t.Fatal("GenerateAppDelivery() wrote delivery files before rejecting incomplete config") + } +} + func TestWritePreviewDeliveryConfigIsTrustedAndNonSecret(t *testing.T) { dir := t.TempDir() if err := os.WriteFile(filepath.Join(dir, "kustomization.yaml"), []byte("resources:\n"), 0644); err != nil { @@ -141,7 +152,7 @@ func TestCopyAndRenderDeliveryBasesOverwritesExistingMigrationOutput(t *testing. cfg := config.Config{ Git: config.GitConfig{BaseURL: "https://git.example.test", Owner: "user-org"}, Flux: config.FluxConfig{TektonCatalogRepo: "my-tekton-catalog", ManifestsRepo: "manifests", Branch: "main"}, - Delivery: config.DeliveryConfig{AppName: "demo", AppRepoURL: "https://git.example.test/demo.git", WebhookHostname: "tekton.example.test", WebhookPath: "/hooks/forgejo"}, + Delivery: config.DeliveryConfig{AppName: "demo", AppRepoURL: "https://git.example.test/demo.git", AppRepoRef: "main", ProductionBranch: "production", ImageRepository: "registry.example.test/demo", BuildOutputDirectory: "dist", BuildConfiguration: "production", WebhookHostname: "tekton.example.test", WebhookPath: "/hooks/forgejo"}, Templates: config.TemplateConfig{TektonCatalogRepoRef: "release"}, } if err := copyAndRenderDeliveryBases(templateDir, repoDir, cfg); err != nil { @@ -209,7 +220,7 @@ func TestCopyTemplateBaseComponentsRefreshesCNPGAndPreservesGeneratedSecrets(t * } } - if err := copyTemplateBaseComponents(templateDir, repoDir); err != nil { + if err := copyTemplateBaseComponents(templateDir, repoDir, true); err != nil { t.Fatal(err) } content, err := os.ReadFile(cnpgDestination) @@ -625,7 +636,7 @@ func TestEnsureClusterKustomizationsUsesTemplateExternalSecretsResource(t *testi if err := os.WriteFile(path, []byte("resources:\n"), 0644); err != nil { t.Fatal(err) } - if err := ensureClusterKustomizations(dir); err != nil { + if err := ensureClusterKustomizations(dir, true); err != nil { t.Fatal(err) } content, err := os.ReadFile(path) diff --git a/internal/config/config.go b/internal/config/config.go index d8bc515..7555a2c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -158,20 +158,22 @@ func applyDefaults(cfg *Config) { if cfg.DemocraticCSI.InitiatorGroup == "" { cfg.DemocraticCSI.InitiatorGroup = "1" } - 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.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 == "" { cfg.Talos.RepoDirName = "maidn-talos-proxmox" @@ -245,23 +247,10 @@ func Validate(cfg Config) error { 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.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") + if cfg.Delivery.Configured() { + if err := ValidateDelivery(cfg); 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") @@ -384,6 +373,29 @@ func Validate(cfg Config) error { 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, "/") != "" { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index c817d07..4aaf816 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -28,13 +28,31 @@ func validConfig(t *testing.T) Config { } } -func TestResolveDoesNotInventAnApplication(t *testing.T) { +func TestResolveAcceptsPlatformOnlyConfig(t *testing.T) { cfg := validConfig(t) cfg.Delivery.AppName = "" cfg.Delivery.AppRepoURL = "" + cfg.Delivery.AppRepoRef = "" + cfg.Delivery.ProductionBranch = "" cfg.Delivery.ImageRepository = "" - if _, err := Resolve(cfg); err == nil { - t.Fatal("Resolve() accepted a configuration without an explicit application") + cfg.Delivery.BuildOutputDirectory = "" + cfg.Delivery.BuildConfiguration = "" + cfg.Delivery.WebhookHostname = "" + cfg.Delivery.WebhookPath = "" + resolved, err := Resolve(cfg) + if err != nil { + t.Fatalf("Resolve() rejected platform-only config: %v", err) + } + if resolved.Delivery.Configured() { + t.Fatal("Resolve() invented delivery settings for a platform-only config") + } +} + +func TestValidateDeliveryRequiresCompleteConfig(t *testing.T) { + cfg := validConfig(t) + cfg.Delivery.ImageRepository = "" + if err := ValidateDelivery(cfg); err == nil || !strings.Contains(err.Error(), "imageRepository") { + t.Fatalf("ValidateDelivery() error = %v, want incomplete delivery error", err) } } diff --git a/internal/config/types.go b/internal/config/types.go index 428e821..63e1e33 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -40,6 +40,11 @@ type DeliveryConfig struct { WebhookPath string `yaml:"webhookPath"` } +// Configured reports whether the configuration contains any app-delivery setting. +func (c DeliveryConfig) Configured() bool { + return c.AppName != "" || c.AppRepoURL != "" || c.AppRepoRef != "" || c.ProductionBranch != "" || c.ImageRepository != "" || c.BuildOutputDirectory != "" || c.BuildConfiguration != "" || c.WebhookHostname != "" || c.WebhookPath != "" +} + func (c DeliveryConfig) WebhookURL() string { return (&url.URL{Scheme: "https", Host: c.WebhookHostname, Path: c.WebhookPath}).String() }