diff --git a/cmd/bootstrap.go b/cmd/bootstrap.go index 16c4189..4179a9d 100644 --- a/cmd/bootstrap.go +++ b/cmd/bootstrap.go @@ -13,6 +13,7 @@ import ( var bootstrapConfigPath string var bootstrapOutputPath string +var bootstrapWorkspaceDir string var bootstrapMode string var bootstrapYes bool var bootstrapPromptDemocraticCSI bool @@ -25,6 +26,7 @@ var bootstrapRotateWebhookAuthorization bool var bootstrapPublishAppFrom string var bootstrapMergeBootstrapPR bool var bootstrapManageNetworkBridges bool +var bootstrapEnableDelivery bool var upsertOperationalSecret = bootstrap.UpsertOperationalSecret var loadPublishAppConfig = config.Load @@ -40,6 +42,7 @@ func init() { rootCmd.AddCommand(bootstrapCmd) bootstrapCmd.Flags().StringVar(&bootstrapConfigPath, "config", "", "Path to bootstrap config YAML") bootstrapCmd.Flags().StringVar(&bootstrapOutputPath, "out", "maidn-bootstrap.yaml", "Path to save generated config") + bootstrapCmd.Flags().StringVar(&bootstrapWorkspaceDir, "workspace-dir", "", "Override workspace directory for this bootstrap run") bootstrapCmd.Flags().StringVar(&bootstrapMode, "mode", string(bootstrap.Reconcile), "Lifecycle mode: reconcile or rebuild") bootstrapCmd.Flags().BoolVar(&bootstrapYes, "yes", false, "Confirm destructive rebuild") bootstrapCmd.Flags().BoolVar(&bootstrapPromptDemocraticCSI, "prompt-democratic-csi", false, "Prompt for and save Democratic CSI settings in --config") @@ -52,6 +55,7 @@ func init() { bootstrapCmd.Flags().StringVar(&bootstrapPublishAppFrom, "publish-app-from", "", "Push this app checkout's current branch and create a Forgejo delivery PR") bootstrapCmd.Flags().BoolVar(&bootstrapMergeBootstrapPR, "merge-bootstrap-pr", false, "Merge the generated Flux repository migration PR before bootstrapping") bootstrapCmd.Flags().BoolVar(&bootstrapManageNetworkBridges, "manage-network-bridges", false, "Persist Terraform management for existing Talos network bridges") + bootstrapCmd.Flags().BoolVar(&bootstrapEnableDelivery, "enable-delivery", false, "Resolve delivery defaults and reconcile the configured app delivery source") } func runBootstrap(cmd *cobra.Command, args []string) error { @@ -237,6 +241,9 @@ func runBootstrap(cmd *cobra.Command, args []string) error { if err != nil { return err } + if bootstrapWorkspaceDir != "" { + cfg.WorkspaceDir = bootstrapWorkspaceDir + } if bootstrapRegisterWebhook { cfg, err = config.ResolveDelivery(cfg) if err != nil { @@ -247,7 +254,7 @@ func runBootstrap(cmd *cobra.Command, args []string) error { } } - runner := bootstrap.Runner{Config: cfg, Mode: bootstrap.Mode(bootstrapMode), ConfirmRebuild: bootstrapYes, RegisterWebhook: bootstrapRegisterWebhook} + runner := bootstrap.Runner{Config: cfg, Mode: bootstrap.Mode(bootstrapMode), ConfirmRebuild: bootstrapYes, RegisterWebhook: bootstrapRegisterWebhook, EnableDelivery: bootstrapEnableDelivery} return runner.Run() } diff --git a/internal/bootstrap/bootstrap.go b/internal/bootstrap/bootstrap.go index d55421f..5622db5 100644 --- a/internal/bootstrap/bootstrap.go +++ b/internal/bootstrap/bootstrap.go @@ -44,6 +44,7 @@ type Runner struct { Mode Mode ConfirmRebuild bool RegisterWebhook bool + EnableDelivery bool } type operationalSecrets struct { @@ -143,7 +144,7 @@ func (r Runner) Run() error { return err } r.Config = resolved - if r.RegisterWebhook { + if r.RegisterWebhook || r.EnableDelivery { resolvedDelivery, err := config.ResolveDelivery(r.Config) if err != nil { return err @@ -441,7 +442,7 @@ func copyAndRenderDeliveryBases(templateDir, repoDir string, cfg config.Config) if err := writePreviewDeliveryConfig(dir, cfg); err != nil { return err } - return writeAppDeliverySource(dir, cfg) + return removeDuplicateAppDeliverySource(dir, cfg.Delivery.AppName) } func writePreviewDeliveryConfig(dir string, cfg config.Config) error { @@ -476,50 +477,23 @@ func writePreviewDeliveryConfig(dir string, cfg config.Config) error { if !strings.Contains("\n"+string(data), "\nresources:") { return errors.New("Tekton Kustomization must define resources before adding preview delivery configuration") } - if err := os.WriteFile(filepath.Join(dir, "maidn-preview-delivery-config.yaml"), content, 0644); err != nil { + if err := os.WriteFile(filepath.Join(dir, "preview-delivery-config.yaml"), content, 0644); err != nil { return err } - if strings.Contains(string(data), "maidn-preview-delivery-config.yaml") { - return nil + legacyPath := filepath.Join(dir, "maidn-preview-delivery-config.yaml") + if err := os.Remove(legacyPath); err != nil && !os.IsNotExist(err) { + return err } - return os.WriteFile(path, append(data, []byte(" - maidn-preview-delivery-config.yaml\n")...), 0644) + updated := strings.ReplaceAll(string(data), " - maidn-preview-delivery-config.yaml\n", "") + if !strings.Contains(updated, "preview-delivery-config.yaml") { + updated += " - preview-delivery-config.yaml\n" + } + return os.WriteFile(path, []byte(updated), 0644) } -func writeAppDeliverySource(dir string, cfg config.Config) error { - if err := config.ValidateDelivery(cfg); err != nil { - return err - } - name := cfg.Delivery.AppName - filename := name + "-source.yaml" - content := fmt.Sprintf(`apiVersion: source.toolkit.fluxcd.io/v1 -kind: GitRepository -metadata: - name: %s - namespace: flux-system -spec: - interval: 5m - url: %s - ref: - branch: %s - secretRef: - name: forgejo-flux-credentials ---- -apiVersion: kustomize.toolkit.fluxcd.io/v1 -kind: Kustomization -metadata: - name: %s-delivery - namespace: flux-system -spec: - dependsOn: - - name: tekton-catalog - interval: 5m - path: ./.tekton - prune: true - sourceRef: - kind: GitRepository - name: %s -`, name, cfg.Delivery.AppRepoURL, cfg.Delivery.AppRepoRef, name, name) - if err := os.WriteFile(filepath.Join(dir, filename), []byte(content), 0644); err != nil { +func removeDuplicateAppDeliverySource(dir, appName string) error { + filename := appName + "-source.yaml" + if err := os.Remove(filepath.Join(dir, filename)); err != nil && !os.IsNotExist(err) { return err } path := filepath.Join(dir, "kustomization.yaml") @@ -527,13 +501,11 @@ spec: if err != nil { return err } - if !strings.Contains("\n"+string(data), "\nresources:") { - return errors.New("Tekton Kustomization must define resources before adding app delivery source") - } - if strings.Contains(string(data), filename) { + updated := strings.ReplaceAll(string(data), " - "+filename+"\n", "") + if updated == string(data) { return nil } - return os.WriteFile(path, append(data, []byte(" - "+filename+"\n")...), 0644) + return os.WriteFile(path, []byte(updated), 0644) } func canonicalForgejoOrigin(value string) (string, error) { diff --git a/internal/bootstrap/bootstrap_test.go b/internal/bootstrap/bootstrap_test.go index b0e315d..7c6fdd5 100644 --- a/internal/bootstrap/bootstrap_test.go +++ b/internal/bootstrap/bootstrap_test.go @@ -69,25 +69,22 @@ func TestRenderDeliveryConfig(t *testing.T) { } } -func TestWriteAppDeliverySource(t *testing.T) { +func TestRemoveDuplicateAppDeliverySource(t *testing.T) { dir := t.TempDir() - if err := os.WriteFile(filepath.Join(dir, "kustomization.yaml"), []byte("resources:\n"), 0644); err != nil { + if err := os.WriteFile(filepath.Join(dir, "kustomization.yaml"), []byte("resources:\n - easycsr-frontend-source.yaml\n - delivery-source.yaml\n"), 0644); err != nil { t.Fatal(err) } - cfg := config.Config{ - Git: config.GitConfig{BaseURL: "https://git.example.test", Owner: "apps"}, - Flux: config.FluxConfig{Branch: "main", ManifestsRepo: "manifests"}, - Delivery: config.DeliveryConfig{AppName: "web-ui", AppRepoURL: "https://git.example.test/apps/web-ui.git", AppRepoRef: "main", ProductionBranch: "production", ImageRepository: "registry.example.test/apps/web-ui", BuildOutputDirectory: "dist", BuildConfiguration: "production", WebhookHostname: "tekton.example.test", WebhookPath: "/"}, - } - if err := writeAppDeliverySource(dir, cfg); err != nil { + if err := os.WriteFile(filepath.Join(dir, "easycsr-frontend-source.yaml"), []byte("stale: source\n"), 0644); err != nil { t.Fatal(err) } - content, err := os.ReadFile(filepath.Join(dir, "web-ui-source.yaml")) - if err != nil || !strings.Contains(string(content), "name: web-ui-delivery") || strings.Contains(string(content), "token") { - t.Fatalf("app delivery source = %q, %v", content, err) + if err := removeDuplicateAppDeliverySource(dir, "easycsr-frontend"); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(dir, "easycsr-frontend-source.yaml")); !os.IsNotExist(err) { + t.Fatalf("duplicate app source remains: %v", err) } kustomization, err := os.ReadFile(filepath.Join(dir, "kustomization.yaml")) - if err != nil || !strings.Contains(string(kustomization), "web-ui-source.yaml") { + if err != nil || strings.Contains(string(kustomization), "easycsr-frontend-source.yaml") || !strings.Contains(string(kustomization), "delivery-source.yaml") { t.Fatalf("Tekton Kustomization = %q, %v", kustomization, err) } } @@ -132,10 +129,17 @@ func TestWritePreviewDeliveryConfigIsTrustedAndNonSecret(t *testing.T) { if err := writePreviewDeliveryConfig(dir, cfg); err != nil { t.Fatal(err) } - content, err := os.ReadFile(filepath.Join(dir, "maidn-preview-delivery-config.yaml")) + content, err := os.ReadFile(filepath.Join(dir, "preview-delivery-config.yaml")) if err != nil || !strings.Contains(string(content), "forgejo-origin: https://git.example.test") || strings.Contains(string(content), "forgejo-base-url") || !strings.Contains(string(content), "manifests-url: https://git.example.test/platform/manifests.git") || strings.Contains(string(content), "secret") || strings.Contains(string(content), "dynamic-app") { t.Fatalf("preview delivery config is not trusted and non-secret: %q, %v", content, err) } + if _, err := os.Stat(filepath.Join(dir, "maidn-preview-delivery-config.yaml")); !os.IsNotExist(err) { + t.Fatalf("obsolete preview config remains: %v", err) + } + kustomization, err := os.ReadFile(filepath.Join(dir, "kustomization.yaml")) + if err != nil || strings.Contains(string(kustomization), "maidn-preview-delivery-config.yaml") || !strings.Contains(string(kustomization), "preview-delivery-config.yaml") { + t.Fatalf("preview config resource was not replaced: %q, %v", kustomization, err) + } } func TestWritePreviewDeliveryConfigRejectsAmbiguousKustomization(t *testing.T) { @@ -744,6 +748,28 @@ func TestRunnerAutoBootstrapFluxIgnoresPartialLegacyDelivery(t *testing.T) { } } +func TestRunnerEnableDeliveryAppliesDefaults(t *testing.T) { + originalPreflight := preflight + t.Cleanup(func() { preflight = originalPreflight }) + preflight = func(cfg config.Config) error { + if !cfg.Delivery.Configured() { + t.Fatal("delivery defaults were not applied") + } + return errors.New("reached preflight") + } + + cfg := runnerTestConfig(t.TempDir(), "") + cfg.Delivery.AppRepoRef = "" + cfg.Delivery.BuildOutputDirectory = "" + cfg.Delivery.BuildConfiguration = "" + cfg.Delivery.WebhookHostname = "" + cfg.Delivery.WebhookPath = "" + err := (Runner{Config: cfg, EnableDelivery: true}).Run() + if err == nil || !strings.Contains(err.Error(), "reached preflight") { + t.Fatalf("enable delivery did not resolve defaults before preflight: %v", err) + } +} + func TestRunnerRegisterWebhookSkipsTemplateRevisions(t *testing.T) { originalPreflight := preflight originalGit := runGit diff --git a/internal/openbao/bootstrap.go b/internal/openbao/bootstrap.go index ee97efe..7cdf029 100644 --- a/internal/openbao/bootstrap.go +++ b/internal/openbao/bootstrap.go @@ -325,7 +325,12 @@ func refreshExternalSecrets(kubeconfig string) error { if err != nil || strings.TrimSpace(string(available)) != "True" { return nil } - _, err = kubectlOutput(kubeconfig, externalSecretRefreshArgs(time.Now().UnixNano())...) + timestamp := time.Now().UnixNano() + _, err = kubectlOutput(kubeconfig, "annotate", "clustersecretstore", "openbao", fmt.Sprintf("force-sync=%d", timestamp), "--overwrite") + if err != nil { + return fmt.Errorf("refresh OpenBao secret store after seed: %w", err) + } + _, err = kubectlOutput(kubeconfig, externalSecretRefreshArgs(timestamp)...) if err != nil { return fmt.Errorf("refresh ExternalSecrets after OpenBao seed: %w", err) } diff --git a/internal/openbao/bootstrap_test.go b/internal/openbao/bootstrap_test.go index 9bc5224..40e78ee 100644 --- a/internal/openbao/bootstrap_test.go +++ b/internal/openbao/bootstrap_test.go @@ -83,7 +83,7 @@ func TestRefreshExternalSecretsIsReadyGatedAndScoped(t *testing.T) { } return nil, nil } - if err := refreshExternalSecrets("kubeconfig"); err != nil || len(calls) != 2 || !strings.Contains(calls[0], "get deployment/external-secrets") || !strings.Contains(calls[1], "annotate externalsecret forgejo-webhook") || strings.Contains(calls[1], "--all") { + if err := refreshExternalSecrets("kubeconfig"); err != nil || len(calls) != 3 || !strings.Contains(calls[0], "get deployment/external-secrets") || !strings.Contains(calls[1], "annotate clustersecretstore openbao") || !strings.Contains(calls[2], "annotate externalsecret forgejo-webhook") || strings.Contains(calls[1], "--all") || strings.Contains(calls[2], "--all") { t.Fatalf("ExternalSecret refresh was not readiness-gated and scoped: %q, %v", calls, err) } }