fix: allow platform-only reconcile #14

Merged
eding merged 1 commit from fix/platform-reconcile-delivery-validation into main 2026-08-22 14:31:43 +02:00
7 changed files with 164 additions and 47 deletions
Showing only changes of commit dbd370b20d - Show all commits

View file

@ -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
}

View file

@ -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 })

View file

@ -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
}

View file

@ -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)

View file

@ -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, "/") != "" {

View file

@ -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)
}
}

View file

@ -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()
}