From 112fc5376b3eabc3f07af50b4b07033fe5ae13f2 Mon Sep 17 00:00:00 2001 From: eding Date: Sun, 6 Sep 2026 20:22:20 +0200 Subject: [PATCH] feat: add platform app onboarding --- cmd/bootstrap.go | 56 ------ cmd/bootstrap_test.go | 25 --- cmd/fresh.go | 27 +-- cmd/fresh_test.go | 42 ++--- docs/operations.md | 31 ++++ internal/bootstrap/bootstrap.go | 90 ++++----- internal/bootstrap/bootstrap_test.go | 23 ++- internal/bootstrap/fresh.go | 13 +- internal/bootstrap/fresh_test.go | 35 +++- internal/bootstrap/onboard.go | 264 +++++++++++++++++++++++++++ internal/bootstrap/onboard_test.go | 78 ++++++++ internal/config/config.go | 39 +++- internal/config/config_test.go | 20 ++ internal/forgejo/repo.go | 169 +++++++++++++++-- internal/forgejo/repo_test.go | 50 +++++ 15 files changed, 726 insertions(+), 236 deletions(-) create mode 100644 internal/bootstrap/onboard.go create mode 100644 internal/bootstrap/onboard_test.go diff --git a/cmd/bootstrap.go b/cmd/bootstrap.go index 3992831..478ba6f 100644 --- a/cmd/bootstrap.go +++ b/cmd/bootstrap.go @@ -23,15 +23,11 @@ var bootstrapInitializeOpenBao bool var bootstrapCreateForgejoRegistryToken bool var bootstrapRegisterWebhook bool var bootstrapRotateWebhookAuthorization bool -var bootstrapPublishAppFrom string var bootstrapMergeBootstrapPR bool var bootstrapManageNetworkBridges bool var bootstrapEnableDelivery bool var upsertOperationalSecret = bootstrap.UpsertOperationalSecret -var loadPublishAppConfig = config.Load -var ensurePublishAppCheckoutClean = forgejo.EnsureCleanCheckout - var bootstrapCmd = &cobra.Command{ Use: "bootstrap", Short: "Bootstrap Talos and Flux from config or an interactive wizard.", @@ -52,7 +48,6 @@ func init() { bootstrapCmd.Flags().BoolVar(&bootstrapCreateForgejoRegistryToken, "create-forgejo-registry-token", false, "Create a least-privilege Forgejo package registry token and seed it through OpenBao") bootstrapCmd.Flags().BoolVar(&bootstrapRegisterWebhook, "register-webhook", false, "Seed OpenBao secrets and register the Forgejo webhook") bootstrapCmd.Flags().BoolVar(&bootstrapRotateWebhookAuthorization, "rotate-webhook-authorization", false, "Replace the Forgejo webhook authorization and reconcile it through OpenBao") - 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") @@ -128,57 +123,6 @@ func runBootstrap(cmd *cobra.Command, args []string) error { return err } } - if bootstrapPublishAppFrom != "" { - if bootstrapConfigPath == "" { - return fmt.Errorf("--publish-app-from requires --config") - } - cfg, err = loadPublishAppConfig(bootstrapConfigPath) - if err != nil { - return err - } - cfg, err = config.ResolveDelivery(cfg) - if err != nil { - return err - } - if err := ensurePublishAppCheckoutClean(bootstrapPublishAppFrom); err != nil { - return err - } - branch, err := forgejo.CurrentBranch(bootstrapPublishAppFrom) - if err != nil { - return err - } - deliveryBranch, err := forgejo.DeliveryBranch(cfg.Delivery.AppName, cfg.Delivery.AppRepoRef) - if err != nil { - return err - } - if err := bootstrap.EnsureTemplateRevisions(cfg); err != nil { - return err - } - owner, repo, err := forgejo.RepositoryFromURL(cfg.Delivery.AppRepoURL) - if err != nil { - return err - } - manager := forgejo.NewRepoManager(cfg.Git.BaseURL, cfg.Git.Token, owner, cfg.Git.Username, "", "", cfg.Delivery.AppRepoRef, "") - created, err := manager.EnsureRepository(repo, "Application source for Maidn CI/CD delivery") - if err != nil { - return err - } - if created && branch != cfg.Delivery.AppRepoRef { - return fmt.Errorf("new application repository requires the checkout branch to match delivery appRepoRef") - } - if err := manager.EnsureProtectedBranch(repo, cfg.Delivery.ProductionBranch); err != nil { - return fmt.Errorf("protect Forgejo production branch: %w", err) - } - if err := manager.PushRef(bootstrapPublishAppFrom, cfg.Delivery.AppRepoURL, branch, branch); err != nil { - return err - } - if err := manager.PublishDeliveryBranch(bootstrapPublishAppFrom, branch, cfg.Delivery.AppRepoURL, deliveryBranch, func(dir string) error { - return bootstrap.GenerateAppDelivery(dir, cfg) - }); err != nil { - return err - } - return manager.CreatePullRequest(repo, "feat: migrate delivery to Tekton", deliveryBranch, cfg.Delivery.AppRepoRef) - } if bootstrapConfigPath != "" { if bootstrapPromptDemocraticCSI || bootstrapPromptOperationalSecrets || bootstrapInitializeOpenBaoRecovery || bootstrapManageNetworkBridges { cfg, err = config.LoadRaw(bootstrapConfigPath) diff --git a/cmd/bootstrap_test.go b/cmd/bootstrap_test.go index a4a9309..3342ea5 100644 --- a/cmd/bootstrap_test.go +++ b/cmd/bootstrap_test.go @@ -21,31 +21,6 @@ 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"}, Delivery: config.DeliveryConfig{AppName: "legacy-app"}}, 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/cmd/fresh.go b/cmd/fresh.go index 0c04b84..b363dcd 100644 --- a/cmd/fresh.go +++ b/cmd/fresh.go @@ -5,7 +5,6 @@ import ( "github.com/Pingu-Studio/MaidnCLI/internal/bootstrap" "github.com/Pingu-Studio/MaidnCLI/internal/config" - "github.com/Pingu-Studio/MaidnCLI/internal/forgejo" "github.com/spf13/cobra" ) @@ -17,10 +16,7 @@ var loadFreshConfig = config.Load var loadAppOnboardConfig = config.LoadRaw var runFreshOrganization = bootstrap.RunFreshOrganization var resolveAppOnboarding = config.ResolveAppOnboarding -var ensureOnboardCheckoutClean = forgejo.EnsureCleanCheckout -var onboardCheckoutOrigin = forgejo.CheckoutOrigin -var onboardCheckoutBranch = forgejo.CurrentBranch -var generateAppDelivery = bootstrap.GenerateAppDelivery +var onboardApp = bootstrap.OnboardApp var bootstrapInitCmd = &cobra.Command{ Use: "init", @@ -44,7 +40,7 @@ func init() { bootstrapInitCmd.Flags().StringVar(&freshConfigPath, "config", "", "Path to private bootstrap config YAML") bootstrapInitCmd.Flags().StringVar(&freshOrganization, "organization", "", "New Forgejo organization name") bootstrapInitCmd.Flags().BoolVar(&freshCreateOrganization, "create-organization", false, "Create the Forgejo organization when absent") - bootstrapInitCmd.Flags().BoolVar(&freshEnableDelivery, "enable-delivery", false, "Initialize delivery repository prerequisites") + bootstrapInitCmd.Flags().BoolVar(&freshEnableDelivery, "enable-delivery", false, "Deprecated: init always initializes the shared delivery platform") bootstrapInitCmd.Flags().StringVar(&freshMode, "mode", string(bootstrap.Reconcile), "Lifecycle mode: reconcile or rebuild") bootstrapInitCmd.Flags().BoolVar(&freshYes, "yes", false, "Confirm destructive rebuild") _ = bootstrapInitCmd.MarkFlagRequired("config") @@ -82,22 +78,5 @@ func runAppOnboard(_ *cobra.Command, _ []string) error { if err != nil { return err } - if err := ensureOnboardCheckoutClean(onboardFrom); err != nil { - return err - } - origin, err := onboardCheckoutOrigin(onboardFrom) - if err != nil { - return err - } - if config.RedactURL(origin) != cfg.Delivery.AppRepoURL { - return fmt.Errorf("--from origin does not match delivery appRepoUrl") - } - branch, err := onboardCheckoutBranch(onboardFrom) - if err != nil { - return err - } - if branch != cfg.Delivery.AppRepoRef { - return fmt.Errorf("--from branch must match delivery appRepoRef") - } - return generateAppDelivery(onboardFrom, cfg) + return onboardApp(cfg, onboardFrom) } diff --git a/cmd/fresh_test.go b/cmd/fresh_test.go index 2c94707..efa979b 100644 --- a/cmd/fresh_test.go +++ b/cmd/fresh_test.go @@ -62,19 +62,17 @@ func TestBootstrapInitAppliesFluxDefaultsBeforeFreshValidation(t *testing.T) { } } -func TestAppOnboardValidatesConfigBeforeInspectingCheckout(t *testing.T) { - originalConfig, originalResolve, originalClean := loadAppOnboardConfig, resolveAppOnboarding, ensureOnboardCheckoutClean +func TestAppOnboardValidatesConfigBeforeExternalWork(t *testing.T) { + originalConfig, originalResolve, originalOnboard := loadAppOnboardConfig, resolveAppOnboarding, onboardApp originalConfigPath, originalFrom := onboardConfigPath, onboardFrom t.Cleanup(func() { - loadAppOnboardConfig = originalConfig - resolveAppOnboarding = originalResolve - ensureOnboardCheckoutClean = originalClean + loadAppOnboardConfig, resolveAppOnboarding, onboardApp = originalConfig, originalResolve, originalOnboard onboardConfigPath, onboardFrom = originalConfigPath, originalFrom }) loadAppOnboardConfig = func(string) (config.Config, error) { return config.Config{}, nil } resolveAppOnboarding = func(config.Config) (config.Config, error) { return config.Config{}, errors.New("incomplete delivery") } - ensureOnboardCheckoutClean = func(string) error { - t.Fatal("onboarding inspected checkout before validating config") + onboardApp = func(config.Config, string) error { + t.Fatal("onboarding reached external work before validating config") return nil } onboardConfigPath, onboardFrom = "private.yaml", "app-checkout" @@ -83,36 +81,26 @@ func TestAppOnboardValidatesConfigBeforeInspectingCheckout(t *testing.T) { } } -func TestAppOnboardScaffoldsOnlyTheValidatedCheckout(t *testing.T) { - originalConfig, originalResolve, originalClean := loadAppOnboardConfig, resolveAppOnboarding, ensureOnboardCheckoutClean - originalOrigin, originalBranch, originalGenerate := onboardCheckoutOrigin, onboardCheckoutBranch, generateAppDelivery +func TestAppOnboardPassesOnlyValidatedConfigAndCheckout(t *testing.T) { + originalConfig, originalResolve, originalOnboard := loadAppOnboardConfig, resolveAppOnboarding, onboardApp originalConfigPath, originalFrom := onboardConfigPath, onboardFrom t.Cleanup(func() { - loadAppOnboardConfig, resolveAppOnboarding, ensureOnboardCheckoutClean = originalConfig, originalResolve, originalClean - onboardCheckoutOrigin, onboardCheckoutBranch, generateAppDelivery = originalOrigin, originalBranch, originalGenerate + loadAppOnboardConfig, resolveAppOnboarding, onboardApp = originalConfig, originalResolve, originalOnboard onboardConfigPath, onboardFrom = originalConfigPath, originalFrom }) cfg := config.Config{Delivery: config.DeliveryConfig{AppRepoURL: "https://git.example.test/new-org/app.git", AppRepoRef: "main"}} loadAppOnboardConfig = func(string) (config.Config, error) { return cfg, nil } resolveAppOnboarding = func(config.Config) (config.Config, error) { return cfg, nil } - ensureOnboardCheckoutClean = func(path string) error { - if path != "app-checkout" { - t.Fatal("onboarding checked the wrong checkout") + calls := 0 + onboardApp = func(got config.Config, checkout string) error { + if checkout != "app-checkout" || got.Delivery.AppRepoURL != cfg.Delivery.AppRepoURL { + t.Fatal("onboarding used the wrong checkout or config") } - return nil - } - onboardCheckoutOrigin = func(string) (string, error) { return cfg.Delivery.AppRepoURL, nil } - onboardCheckoutBranch = func(string) (string, error) { return cfg.Delivery.AppRepoRef, nil } - generations := 0 - generateAppDelivery = func(path string, got config.Config) error { - if path != "app-checkout" || got.Delivery.AppRepoURL != cfg.Delivery.AppRepoURL { - t.Fatal("onboarding generated delivery for the wrong checkout or config") - } - generations++ + calls++ return nil } onboardConfigPath, onboardFrom = "private.yaml", "app-checkout" - if err := runAppOnboard(nil, nil); err != nil || generations != 1 { - t.Fatalf("runAppOnboard() = %v, generations = %d", err, generations) + if err := runAppOnboard(nil, nil); err != nil || calls != 1 { + t.Fatalf("runAppOnboard() = %v, calls = %d", err, calls) } } diff --git a/docs/operations.md b/docs/operations.md index 986fe20..9cb31c0 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -23,6 +23,37 @@ new template revisions intentionally, use a new empty `workspaceDir` (and a fresh `cloneParent` when it is configured separately) and keep the prior secret-bearing workspace intact for recovery. +## Platform Initialization And App Onboarding + +Initialize the shared delivery platform before onboarding any application. This +creates the Gateway, Tekton, and Tekton Triggers platform resources using +`tekton.` and the configured Forgejo owner; it does not render +an application Pipeline or register an application webhook: + +```powershell +bootstrap init --config --organization --create-organization +``` + +Import a clean checkout into the configured owner with a separate command. The +checkout may originate in another Forgejo organization, but `delivery.appRepoUrl` +must target `/.git` and its current branch must be +`delivery.appRepoRef`: + +```powershell +app onboard --config --from +``` + +Onboarding copies the current branch to the target repository, replaces only +the two Maidn-generated `.tekton` files, protects the production branch, and +auto-merges the app delivery and cluster registration PRs. The registration is +stored in `base/tekton/apps/.yaml` in the configured cluster repository; +the generic EventListener dispatches by the Forgejo repository name. + +`delivery.productionBranch` is the application production branch. A production +delivery PR targets the configured generic +`/` repository semantics (for example, +`test-org-2/`), never through a source checkout's former owner. + ## Rebuild Use only when an authorized recovery requires recreating the Talos VM: diff --git a/internal/bootstrap/bootstrap.go b/internal/bootstrap/bootstrap.go index b493f7c..3136648 100644 --- a/internal/bootstrap/bootstrap.go +++ b/internal/bootstrap/bootstrap.go @@ -156,7 +156,6 @@ func (r Runner) Run() error { } r.Config = resolvedDelivery } - deliveryConfigured := r.Config.Delivery.Configured() if err := preflight(r.Config); err != nil { return fmt.Errorf("preflight: %w", err) } @@ -190,11 +189,9 @@ func (r Runner) Run() error { if err := EnsureTemplateRevisions(r.Config); err != nil { return err } - if !r.SkipDeliveryScaffolding { - catalogManager := forgejo.NewRepoManager(r.Config.Git.BaseURL, r.Config.Git.Token, r.Config.Git.Owner, r.Config.Git.Username, "", "", r.Config.Templates.TektonCatalogRepoRef, "") - if _, err := catalogManager.EnsureRepositoryCopy(r.Config.Flux.TektonCatalogRepo, "User-managed Tekton pipeline catalog", r.Config.Templates.TektonCatalogRepoURL); err != nil { - return fmt.Errorf("initialize Tekton catalog repository: %w", err) - } + catalogManager := forgejo.NewRepoManager(r.Config.Git.BaseURL, r.Config.Git.Token, r.Config.Git.Owner, r.Config.Git.Username, "", "", r.Config.Templates.TektonCatalogRepoRef, "") + if _, err := catalogManager.EnsureRepositoryCopy(r.Config.Flux.TektonCatalogRepo, "User-managed Tekton pipeline catalog", r.Config.Templates.TektonCatalogRepoURL); err != nil { + return fmt.Errorf("initialize Tekton catalog repository: %w", err) } if err := config.WriteRedacted(filepath.Join(workspace, "maidn-bootstrap.resolved.yaml"), r.Config); err != nil { return err @@ -213,23 +210,16 @@ func (r Runner) Run() error { }, func(dir string) error { clusterDir := filepath.Join(dir, strings.TrimPrefix(r.Config.Flux.ClusterPath, "./")) - 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 { + if err := copyDir(filepath.Join(cicdTemplateDir, "base"), filepath.Join(dir, "base"), false); err != nil { return err } - if err := copyTemplateBaseComponents(cicdTemplateDir, dir, deliveryConfigured); err != nil { + if err := copyTemplateBaseComponents(cicdTemplateDir, dir, true); 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 @@ -248,10 +238,8 @@ func (r Runner) Run() error { if err := copyAndRenderCiliumBases(cicdTemplateDir, dir, r.Config); err != nil { return err } - if deliveryConfigured { - if err := copyAndRenderDeliveryBases(cicdTemplateDir, dir, r.Config); err != nil { - return err - } + if err := copyAndRenderPlatformDeliveryBases(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 @@ -263,7 +251,7 @@ func (r Runner) Run() error { if err := ensureOpenBaoUnsealKustomization(filepath.Join(openbaoDir, "kustomization.yaml")); err != nil { return err } - if err := ensureClusterKustomizations(clusterDir, deliveryConfigured); err != nil { + if err := ensureClusterKustomizations(clusterDir, true); err != nil { return err } return ghrepo.WriteFluxStructure(dir, r.Config.Flux.RepoName, r.Config.Flux.ClusterPath, fluxConfig) @@ -327,7 +315,7 @@ func (r Runner) Run() error { if err := configureFluxSOPS(generatedDir); err != nil { return err } - return r.reconcileWebhook(generatedDir) + return r.completeFluxBootstrap(generatedDir) } return nil } @@ -386,7 +374,7 @@ func (r Runner) reconcileCloudflareTunnel() error { } func (r Runner) reconcileWebhook(generatedDir string) error { - operationalSecrets, err := initializeOpenBao(filepath.Join(generatedDir, "kubeconfig"), r.Config.SOPS.RecoveryRecipient, r.Config.SOPS.RecoveryIdentityPath, r.Config.SOPS.RecoveryBundlePath, r.Config.SOPS.AgeKeyPath, r.Config.SOPS.OperationalSecretsPath) + operationalSecrets, err := r.initializeOpenBaoForCluster(generatedDir) if err != nil { return fmt.Errorf("initialize OpenBao: %w", err) } @@ -403,6 +391,18 @@ func (r Runner) reconcileWebhook(generatedDir string) error { return nil } +func (r Runner) initializeOpenBaoForCluster(generatedDir string) (map[string]map[string]string, error) { + return initializeOpenBao(filepath.Join(generatedDir, "kubeconfig"), r.Config.SOPS.RecoveryRecipient, r.Config.SOPS.RecoveryIdentityPath, r.Config.SOPS.RecoveryBundlePath, r.Config.SOPS.AgeKeyPath, r.Config.SOPS.OperationalSecretsPath) +} + +// completeFluxBootstrap runs the post-Flux platform initialization only. +func (r Runner) completeFluxBootstrap(generatedDir string) error { + if _, err := r.initializeOpenBaoForCluster(generatedDir); err != nil { + return fmt.Errorf("initialize OpenBao: %w", err) + } + return nil +} + func renderCiliumConfig(dir string, cfg config.Config) error { replacements := strings.NewReplacer( "${CILIUM_K8S_SERVICE_HOST}", cfg.Talos.KubeconfigEndpoint, @@ -422,19 +422,16 @@ func renderCiliumConfig(dir string, cfg config.Config) error { }) } -func renderDeliveryConfig(dir string, cfg config.Config) error { +func renderPlatformDeliveryConfig(dir string, cfg config.Config) error { replacements := strings.NewReplacer( - "${APP_NAME}", cfg.Delivery.AppName, - "${APP_REPO_URL}", cfg.Delivery.AppRepoURL, - "${APP_REPO_REF}", cfg.Delivery.AppRepoRef, - "${PRODUCTION_BRANCH}", cfg.Delivery.ProductionBranch, - "${IMAGE_REPOSITORY}", cfg.Delivery.ImageRepository, "${FORGEJO_BASE_URL}", cfg.Git.BaseURL, "${CLUSTER_DOMAIN}", cfg.Flux.ClusterDomain, + "${GIT_OWNER}", cfg.Git.Owner, + "${FORGEJO_OWNER}", cfg.Git.Owner, "${TEKTON_CATALOG_REPO_URL}", forgejo.CloneURL(cfg.Git.BaseURL, cfg.Git.Owner, cfg.Flux.TektonCatalogRepo), "${TEKTON_CATALOG_REPO_REF}", cfg.Templates.TektonCatalogRepoRef, - "${WEBHOOK_HOSTNAME}", cfg.Delivery.WebhookHostname, - "${WEBHOOK_PATH}", cfg.Delivery.WebhookPath, + "${WEBHOOK_HOSTNAME}", "tekton."+cfg.Flux.ClusterDomain, + "${WEBHOOK_PATH}", "/", ) return filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { if err != nil || info.IsDir() { @@ -448,10 +445,7 @@ 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 - } +func copyAndRenderPlatformDeliveryBases(templateDir, repoDir string, cfg config.Config) error { bases := []string{"gateway", "tekton", "tekton-triggers"} for _, base := range bases { baseDir := filepath.Join(repoDir, "base", base) @@ -461,15 +455,11 @@ func copyAndRenderDeliveryBases(templateDir, repoDir string, cfg config.Config) } for _, base := range bases { baseDir := filepath.Join(repoDir, "base", base) - if err := renderDeliveryConfig(baseDir, cfg); err != nil { + if err := renderPlatformDeliveryConfig(baseDir, cfg); err != nil { return err } } - dir := filepath.Join(repoDir, "base", "tekton") - if err := writePreviewDeliveryConfig(dir, cfg); err != nil { - return err - } - return removeDuplicateAppDeliverySource(dir, cfg.Delivery.AppName) + return writePreviewDeliveryConfig(filepath.Join(repoDir, "base", "tekton"), cfg) } func writePreviewDeliveryConfig(dir string, cfg config.Config) error { @@ -585,10 +575,14 @@ func GenerateAppDelivery(dir string, cfg config.Config) error { if len(entries) != len(files) { return errors.New("app delivery .tekton contains unmanaged files") } - for name, want := range files { - got, err := os.ReadFile(filepath.Join(target, name)) - if err != nil || !bytes.Equal(got, want) { - return errors.New("app delivery .tekton differs from Maidn generated content") + for name, content := range files { + file := filepath.Join(target, name) + fileInfo, err := os.Lstat(file) + if err != nil || fileInfo.Mode()&os.ModeSymlink != 0 || !fileInfo.Mode().IsRegular() { + return errors.New("app delivery .tekton contains unmanaged files") + } + if err := os.WriteFile(file, content, 0644); err != nil { + return err } } return nil @@ -1031,7 +1025,7 @@ func waitForWebhookTargets(dir string, cfg config.Config, authorization string) if err := waitForWebhookAuthorization(dir, authorization); err != nil { return err } - resources := []string{"deployment/el-" + cfg.Delivery.AppName, "pipeline/" + cfg.Delivery.AppName} + resources := []string{"pipeline/" + cfg.Delivery.AppName} for _, resource := range resources { deadline := time.Now().Add(webhookTargetTimeout) for time.Now().Before(deadline) { @@ -1078,9 +1072,6 @@ func ensureClusterKustomizations(clusterDir string, includeDelivery bool) 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" } @@ -1093,9 +1084,6 @@ func ensureClusterKustomizations(clusterDir string, includeDelivery bool) 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 c930b6b..1617c00 100644 --- a/internal/bootstrap/bootstrap_test.go +++ b/internal/bootstrap/bootstrap_test.go @@ -52,22 +52,22 @@ func TestCiliumChartVersion(t *testing.T) { } } -func TestRenderDeliveryConfig(t *testing.T) { +func TestRenderPlatformDeliveryConfig(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "webhook.yaml") - if err := os.WriteFile(path, []byte("host: ${WEBHOOK_HOSTNAME}\npath: ${WEBHOOK_PATH}\nproduction: ${PRODUCTION_BRANCH}\ncatalog: ${TEKTON_CATALOG_REPO_URL}\nref: ${TEKTON_CATALOG_REPO_REF}\n"), 0644); err != nil { + if err := os.WriteFile(path, []byte("host: ${WEBHOOK_HOSTNAME}\npath: ${WEBHOOK_PATH}\nowner: ${GIT_OWNER}\ncatalog: ${TEKTON_CATALOG_REPO_URL}\nref: ${TEKTON_CATALOG_REPO_REF}\n"), 0644); err != nil { t.Fatal(err) } - cfg := config.Config{Git: config.GitConfig{BaseURL: "https://git.example.test", Owner: "user-org"}, Flux: config.FluxConfig{TektonCatalogRepo: "my-tekton-catalog"}, Delivery: config.DeliveryConfig{ProductionBranch: "production", WebhookHostname: "tekton.example.test", WebhookPath: "/hooks/forgejo"}, Templates: config.TemplateConfig{TektonCatalogRepoRef: "release"}} - if err := renderDeliveryConfig(dir, cfg); err != nil { + cfg := config.Config{Git: config.GitConfig{BaseURL: "https://git.example.test", Owner: "user-org"}, Flux: config.FluxConfig{ClusterDomain: "example.test", TektonCatalogRepo: "my-tekton-catalog"}, Templates: config.TemplateConfig{TektonCatalogRepoRef: "release"}} + if err := renderPlatformDeliveryConfig(dir, cfg); err != nil { t.Fatal(err) } content, err := os.ReadFile(path) if err != nil { t.Fatal(err) } - if strings.Contains(string(content), "${") || !strings.Contains(string(content), "/hooks/forgejo") || !strings.Contains(string(content), "production: production") || !strings.Contains(string(content), "https://git.example.test/user-org/my-tekton-catalog.git") || !strings.Contains(string(content), "ref: release") { - t.Fatalf("delivery configuration was not rendered: %s", content) + if strings.Contains(string(content), "${") || !strings.Contains(string(content), "host: tekton.example.test") || !strings.Contains(string(content), "owner: user-org") || !strings.Contains(string(content), "https://git.example.test/user-org/my-tekton-catalog.git") || !strings.Contains(string(content), "ref: release") { + t.Fatalf("platform delivery configuration was not rendered: %s", content) } } @@ -155,15 +155,15 @@ func TestWritePreviewDeliveryConfigRejectsAmbiguousKustomization(t *testing.T) { } } -func TestCopyAndRenderDeliveryBasesOverwritesExistingMigrationOutput(t *testing.T) { +func TestCopyAndRenderPlatformDeliveryBasesOverwritesExistingMigrationOutput(t *testing.T) { templateDir := t.TempDir() repoDir := t.TempDir() files := []struct { base, name, template, want string }{ - {"gateway", "route.yaml", "host: ${WEBHOOK_HOSTNAME}\npath: ${WEBHOOK_PATH}\n", "host: tekton.example.test\npath: /hooks/forgejo\n"}, + {"gateway", "route.yaml", "host: ${WEBHOOK_HOSTNAME}\npath: ${WEBHOOK_PATH}\n", "host: tekton.example.test\npath: /\n"}, {"tekton", "catalog-source.yaml", "url: ${TEKTON_CATALOG_REPO_URL}\nref: ${TEKTON_CATALOG_REPO_REF}\n", "url: https://git.example.test/user-org/my-tekton-catalog.git\nref: release\n"}, - {"tekton-triggers", "trigger.yaml", "app: ${APP_NAME}\nrepo: ${APP_REPO_URL}\n", "app: demo\nrepo: https://git.example.test/demo.git\n"}, + {"tekton-triggers", "trigger.yaml", "owner: ${FORGEJO_OWNER}\nhost: ${WEBHOOK_HOSTNAME}\n", "owner: user-org\nhost: tekton.example.test\n"}, } for _, file := range files { templatePath := filepath.Join(templateDir, "base", file.base, file.name) @@ -191,11 +191,10 @@ 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", AppRepoRef: "main", ProductionBranch: "production", ImageRepository: "registry.example.test/demo", BuildOutputDirectory: "dist", BuildConfiguration: "production", WebhookHostname: "tekton.example.test", WebhookPath: "/hooks/forgejo"}, + Flux: config.FluxConfig{ClusterDomain: "example.test", TektonCatalogRepo: "my-tekton-catalog", ManifestsRepo: "manifests", Branch: "main"}, Templates: config.TemplateConfig{TektonCatalogRepoRef: "release"}, } - if err := copyAndRenderDeliveryBases(templateDir, repoDir, cfg); err != nil { + if err := copyAndRenderPlatformDeliveryBases(templateDir, repoDir, cfg); err != nil { t.Fatal(err) } for _, file := range files { diff --git a/internal/bootstrap/fresh.go b/internal/bootstrap/fresh.go index 2946d6c..0e64b3e 100644 --- a/internal/bootstrap/fresh.go +++ b/internal/bootstrap/fresh.go @@ -50,9 +50,7 @@ func PlanFreshOrganization(cfg config.Config, options FreshOrganizationOptions) "ensure the Forgejo organization", "ensure baseline Flux and manifests repositories", } - if options.EnableDelivery { - phases = append(phases, "initialize the user-managed Tekton catalog repository") - } + phases = append(phases, "initialize the user-managed Tekton catalog repository") phases = append(phases, fmt.Sprintf("%s the CI/CD cluster", mode)) return resolved, FreshOrganizationPlan{Phases: phases}, nil } @@ -86,11 +84,10 @@ func freshLifecycleRunner(cfg config.Config, options FreshOrganizationOptions) R mode = Reconcile } return Runner{ - Config: cfg, - Mode: mode, - ConfirmRebuild: options.ConfirmRebuild, - EnableDelivery: options.EnableDelivery, - SkipDeliveryScaffolding: !options.EnableDelivery, + Config: cfg, + Mode: mode, + ConfirmRebuild: options.ConfirmRebuild, + SkipDeliveryScaffolding: true, AutoMergeBootstrapMigration: true, } } diff --git a/internal/bootstrap/fresh_test.go b/internal/bootstrap/fresh_test.go index e8e2ca1..293d0cf 100644 --- a/internal/bootstrap/fresh_test.go +++ b/internal/bootstrap/fresh_test.go @@ -172,10 +172,11 @@ func TestRunFreshOrganizationStopsBeforeLifecycleOnForgejoFailure(t *testing.T) } } -func TestFreshLifecycleUsesSelectedModeAndGatesDelivery(t *testing.T) { +func TestFreshLifecycleUsesSelectedModeWithoutAppDelivery(t *testing.T) { originalPreflight := preflight t.Cleanup(func() { preflight = originalPreflight }) cfg := runnerTestConfig(t.TempDir(), "") + cfg.Delivery = config.DeliveryConfig{} for _, test := range []struct { options FreshOrganizationOptions mode Mode @@ -184,12 +185,12 @@ func TestFreshLifecycleUsesSelectedModeAndGatesDelivery(t *testing.T) { {FreshOrganizationOptions{Mode: Rebuild, ConfirmRebuild: true, EnableDelivery: true}, Rebuild}, } { runner := freshLifecycleRunner(cfg, test.options) - if runner.Mode != test.mode || runner.ConfirmRebuild != test.options.ConfirmRebuild || runner.EnableDelivery != test.options.EnableDelivery || runner.SkipDeliveryScaffolding == test.options.EnableDelivery || !runner.AutoMergeBootstrapMigration { + if runner.Mode != test.mode || runner.ConfirmRebuild != test.options.ConfirmRebuild || runner.EnableDelivery || !runner.SkipDeliveryScaffolding || !runner.AutoMergeBootstrapMigration { t.Fatalf("fresh lifecycle runner = %#v", runner) } preflight = func(got config.Config) error { - if got.Delivery.Configured() != test.options.EnableDelivery { - t.Fatal("fresh lifecycle did not gate delivery scaffolding with --enable-delivery") + if got.Delivery.Configured() { + t.Fatal("fresh lifecycle resolved app delivery during platform initialization") } return errors.New("stop") } @@ -199,6 +200,32 @@ func TestFreshLifecycleUsesSelectedModeAndGatesDelivery(t *testing.T) { } } +func TestFreshPlatformRunnerInitializesOpenBaoWithoutRegisteringWebhookAfterFluxSetup(t *testing.T) { + originalInitialize, originalWebhook := initializeOpenBao, ensureForgejoWebhook + t.Cleanup(func() { + initializeOpenBao, ensureForgejoWebhook = originalInitialize, originalWebhook + }) + runner := freshLifecycleRunner(runnerTestConfig(t.TempDir(), "age-key"), FreshOrganizationOptions{}) + if !runner.SkipDeliveryScaffolding || runner.RegisterWebhook { + t.Fatalf("fresh platform runner = %#v", runner) + } + initialized := false + initializeOpenBao = func(kubeconfig, recipient, identityPath, bundlePath, ageKeyPath, operationalSecretsPath string) (map[string]map[string]string, error) { + if kubeconfig != filepath.Join("after-flux", "kubeconfig") || ageKeyPath != "age-key" { + t.Fatal("platform OpenBao initialization used unexpected paths") + } + initialized = true + return nil, nil + } + ensureForgejoWebhook = func(config.Config, string, string, string) error { + t.Fatal("platform initialization registered an application webhook") + return nil + } + if err := runner.completeFluxBootstrap("after-flux"); err != nil || !initialized { + t.Fatalf("post-Flux platform initialization = %v, initialized = %t", err, initialized) + } +} + func TestPlanFreshOrganizationRejectsUnrecognizedWorkspaceState(t *testing.T) { cfg := freshPlanConfig(t) prepareResumableFreshWorkspace(t, &cfg) diff --git a/internal/bootstrap/onboard.go b/internal/bootstrap/onboard.go new file mode 100644 index 0000000..4172475 --- /dev/null +++ b/internal/bootstrap/onboard.go @@ -0,0 +1,264 @@ +package bootstrap + +import ( + "bytes" + "errors" + "fmt" + "io" + "os" + "path/filepath" + + "github.com/Pingu-Studio/MaidnCLI/internal/config" + "github.com/Pingu-Studio/MaidnCLI/internal/forgejo" + "gopkg.in/yaml.v3" +) + +// OnboardApp imports one clean checkout into the configured Forgejo owner, +// publishes its delivery branch, registers it with Flux, then adds its webhook. +func OnboardApp(cfg config.Config, sourceDir string) error { + resolved, err := config.ResolveAppOnboarding(cfg) + if err != nil { + return err + } + if err := forgejo.EnsureCleanCheckout(sourceDir); err != nil { + return err + } + branch, err := forgejo.CurrentBranch(sourceDir) + if err != nil { + return err + } + if branch != resolved.Delivery.AppRepoRef { + return errors.New("--from branch must match delivery appRepoRef") + } + owner, repository, err := forgejo.RepositoryFromURL(resolved.Delivery.AppRepoURL) + if err != nil { + return err + } + if owner != resolved.Git.Owner { + return errors.New("delivery appRepoUrl owner must match git owner for app onboarding") + } + deliveryBranch, err := forgejo.DeliveryBranch(resolved.Delivery.AppName, resolved.Delivery.AppRepoRef) + if err != nil { + return err + } + manager := forgejo.NewRepoManager(resolved.Git.BaseURL, resolved.Git.Token, resolved.Git.Owner, resolved.Git.Username, "", "", resolved.Delivery.AppRepoRef, "") + if _, err := manager.EnsureRepository(repository, "Application source for Maidn CI/CD delivery"); err != nil { + return err + } + if err := manager.EnsureProtectedBranch(repository, resolved.Delivery.ProductionBranch); err != nil { + return fmt.Errorf("protect Forgejo production branch: %w", err) + } + if err := manager.PushRef(sourceDir, resolved.Delivery.AppRepoURL, branch, branch); err != nil { + return err + } + changed, err := manager.PublishDeliveryBranch(sourceDir, branch, resolved.Delivery.AppRepoURL, deliveryBranch, func(dir string) error { + return GenerateAppDelivery(dir, resolved) + }) + if err != nil { + return err + } + if changed { + if err := manager.EnsurePullRequest(repository, "feat: migrate delivery to Tekton", deliveryBranch, resolved.Delivery.AppRepoRef); err != nil { + return err + } + } + if open, err := manager.HasOpenPullRequest(repository, deliveryBranch); err != nil { + return err + } else if open { + if err := manager.MergePullRequest(repository, deliveryBranch); err != nil { + return err + } + } + + registrationBranch := "maidn/register-" + resolved.Delivery.AppName + if _, err := manager.PublishRepositoryPullRequest(resolved.Flux.RepoName, "feat: register "+resolved.Delivery.AppName+" delivery", registrationBranch, resolved.Flux.Branch, func(dir string) error { + return RegisterAppInCluster(dir, resolved) + }); err != nil { + return fmt.Errorf("register app in cluster repository: %w", err) + } + secrets, err := ReadOperationalSecrets(resolved.SOPS.OperationalSecretsPath, resolved.SOPS.AgeKeyPath) + if err != nil { + return fmt.Errorf("read encrypted webhook authorization: %w", err) + } + authorization := secrets["cicd/forgejo-webhook"]["authorization"] + if authorization == "" { + return errors.New("operational SOPS secrets requires cicd/forgejo-webhook.authorization") + } + generatedDir := filepath.Join(resolved.Git.CloneParent, resolved.Talos.RepoDirName, resolved.Talos.GeneratedDir) + if err := waitForWebhookTargets(generatedDir, resolved, authorization); err != nil { + return err + } + webhookURL := "https://tekton." + resolved.Flux.ClusterDomain + "/" + if err := manager.EnsureWebhook(repository, webhookURL, authorization); err != nil { + return fmt.Errorf("register Forgejo webhook: %w", err) + } + if err := manager.TriggerWebhookTest(repository, webhookURL, resolved.Delivery.AppRepoRef); err != nil { + return fmt.Errorf("trigger Forgejo webhook test: %w", err) + } + return nil +} + +// RegisterAppInCluster writes only the managed Flux registration for one app. +func RegisterAppInCluster(dir string, cfg config.Config) error { + content, err := renderAppRegistration(cfg) + if err != nil { + return err + } + tektonDir := filepath.Join(dir, "base", "tekton") + rootPath := filepath.Join(tektonDir, "kustomization.yaml") + root, err := readRegularFile(rootPath) + if err != nil { + return fmt.Errorf("read Tekton Kustomization: %w", err) + } + updatedRoot, err := addKustomizationResource(root, "apps") + if err != nil { + return fmt.Errorf("Tekton Kustomization: %w", err) + } + appsDir := filepath.Join(tektonDir, "apps") + if info, statErr := os.Lstat(appsDir); statErr == nil && (info.Mode()&os.ModeSymlink != 0 || !info.IsDir()) { + return errors.New("Tekton apps path must be a directory") + } else if statErr != nil && !os.IsNotExist(statErr) { + return statErr + } + appsPath := filepath.Join(appsDir, "kustomization.yaml") + apps, err := os.ReadFile(appsPath) + if err != nil && !os.IsNotExist(err) { + return err + } + if err == nil { + if info, statErr := os.Lstat(appsPath); statErr != nil || info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { + return errors.New("Tekton apps Kustomization must be a regular file") + } + } else { + apps = []byte("apiVersion: kustomize.config.k8s.io/v1beta1\nkind: Kustomization\nresources:\n") + } + updatedApps, err := addKustomizationResource(apps, cfg.Delivery.AppName+".yaml") + if err != nil { + return fmt.Errorf("Tekton apps Kustomization: %w", err) + } + registrationPath := filepath.Join(appsDir, cfg.Delivery.AppName+".yaml") + if existing, readErr := os.ReadFile(registrationPath); readErr == nil { + info, statErr := os.Lstat(registrationPath) + if statErr != nil || info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() || !bytes.Equal(existing, content) { + return errors.New("Tekton app registration conflicts with unmanaged content") + } + } else if !os.IsNotExist(readErr) { + return readErr + } + if err := os.MkdirAll(appsDir, 0755); err != nil { + return err + } + if err := os.WriteFile(rootPath, updatedRoot, 0644); err != nil { + return err + } + if err := os.WriteFile(appsPath, updatedApps, 0644); err != nil { + return err + } + return os.WriteFile(registrationPath, content, 0644) +} + +func renderAppRegistration(cfg config.Config) ([]byte, error) { + if err := config.ValidateDelivery(cfg); err != nil { + return nil, err + } + branch, err := forgejo.DeliveryBranch(cfg.Delivery.AppName, cfg.Delivery.AppRepoRef) + if err != nil { + return nil, err + } + return []byte(fmt.Sprintf(`apiVersion: source.toolkit.fluxcd.io/v1 +kind: GitRepository +metadata: + name: %s + namespace: flux-system +spec: + interval: 1m + url: %s + secretRef: + name: forgejo-flux-credentials + ref: + branch: %s +--- +apiVersion: kustomize.toolkit.fluxcd.io/v1 +kind: Kustomization +metadata: + name: %s + namespace: flux-system +spec: + interval: 5m + path: ./.tekton + prune: true + wait: true + targetNamespace: tekton-pipelines + dependsOn: + - name: tekton-catalog + sourceRef: + kind: GitRepository + name: %s +`, cfg.Delivery.AppName, cfg.Delivery.AppRepoURL, branch, cfg.Delivery.AppName, cfg.Delivery.AppName)), nil +} + +func readRegularFile(path string) ([]byte, error) { + info, err := os.Lstat(path) + if err != nil { + return nil, err + } + if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { + return nil, errors.New("must be a regular file") + } + return os.ReadFile(path) +} + +func addKustomizationResource(content []byte, resource string) ([]byte, error) { + decoder := yaml.NewDecoder(bytes.NewReader(content)) + var document yaml.Node + if err := decoder.Decode(&document); err != nil { + return nil, err + } + if err := decoder.Decode(&yaml.Node{}); !errors.Is(err, io.EOF) { + return nil, errors.New("must contain one YAML document") + } + if document.Kind != yaml.DocumentNode || len(document.Content) != 1 || document.Content[0].Kind != yaml.MappingNode { + return nil, errors.New("must be a Kustomization mapping") + } + root := document.Content[0] + apiVersion, err := yamlMappingValue(root, "apiVersion") + if err != nil || apiVersion == nil || apiVersion.Value != "kustomize.config.k8s.io/v1beta1" { + return nil, errors.New("must be a Kustomization") + } + kind, err := yamlMappingValue(root, "kind") + if err != nil || kind == nil || kind.Value != "Kustomization" { + return nil, errors.New("must be a Kustomization") + } + resources, err := yamlMappingValue(root, "resources") + if err != nil || resources == nil { + return nil, errors.New("must define a resources list") + } + if resources.Kind == yaml.ScalarNode && resources.Tag == "!!null" { + resources.Kind, resources.Tag, resources.Value = yaml.SequenceNode, "!!seq", "" + } + if resources.Kind != yaml.SequenceNode { + return nil, errors.New("must define a resources list") + } + for _, item := range resources.Content { + if item.Kind != yaml.ScalarNode || item.Value == "" { + return nil, errors.New("resources must contain non-empty scalar values") + } + if item.Value == resource { + var rendered bytes.Buffer + encoder := yaml.NewEncoder(&rendered) + encoder.SetIndent(2) + if err := encoder.Encode(&document); err != nil { + return nil, err + } + return rendered.Bytes(), nil + } + } + resources.Content = append(resources.Content, &yaml.Node{Kind: yaml.ScalarNode, Value: resource}) + var rendered bytes.Buffer + encoder := yaml.NewEncoder(&rendered) + encoder.SetIndent(2) + if err := encoder.Encode(&document); err != nil { + return nil, err + } + return rendered.Bytes(), nil +} diff --git a/internal/bootstrap/onboard_test.go b/internal/bootstrap/onboard_test.go new file mode 100644 index 0000000..f446eaf --- /dev/null +++ b/internal/bootstrap/onboard_test.go @@ -0,0 +1,78 @@ +package bootstrap + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Pingu-Studio/MaidnCLI/internal/config" +) + +func onboardingConfig() config.Config { + return config.Config{ + Git: config.GitConfig{BaseURL: "https://git.example.test", Owner: "test-org-2"}, + Flux: config.FluxConfig{Branch: "main", RepoName: "cluster", ManifestsRepo: "manifests", ClusterDomain: "example.test"}, + Delivery: config.DeliveryConfig{ + AppName: "web-ui", AppRepoURL: "https://git.example.test/test-org-2/web-ui.git", AppRepoRef: "main", ProductionBranch: "production", + ImageRepository: "registry.example.test/test-org-2/web-ui", BuildOutputDirectory: "dist", BuildConfiguration: "production", + WebhookHostname: "tekton.example.test", WebhookPath: "/", + }, + } +} + +func TestGenerateAppDeliveryReplacesOnlyKnownGeneratedFiles(t *testing.T) { + dir := t.TempDir() + tektonDir := filepath.Join(dir, ".tekton") + if err := os.Mkdir(tektonDir, 0755); err != nil { + t.Fatal(err) + } + for _, name := range []string{"kustomization.yaml", "pipeline.yaml"} { + if err := os.WriteFile(filepath.Join(tektonDir, name), []byte("old generated content\n"), 0644); err != nil { + t.Fatal(err) + } + } + if err := GenerateAppDelivery(dir, onboardingConfig()); err != nil { + t.Fatal(err) + } + pipeline, err := os.ReadFile(filepath.Join(tektonDir, "pipeline.yaml")) + if err != nil || !strings.Contains(string(pipeline), "https://git.example.test/test-org-2/web-ui.git") { + t.Fatalf("target-specific pipeline = %q, %v", pipeline, err) + } + if err := os.WriteFile(filepath.Join(tektonDir, "custom.yaml"), []byte("custom: true\n"), 0644); err != nil { + t.Fatal(err) + } + if err := GenerateAppDelivery(dir, onboardingConfig()); err == nil || !strings.Contains(err.Error(), "unmanaged") { + t.Fatalf("custom .tekton content was accepted: %v", err) + } +} + +func TestRegisterAppInClusterRendersManagedFluxSource(t *testing.T) { + dir := t.TempDir() + tektonDir := filepath.Join(dir, "base", "tekton") + if err := os.MkdirAll(tektonDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(tektonDir, "kustomization.yaml"), []byte("apiVersion: kustomize.config.k8s.io/v1beta1\nkind: Kustomization\nresources:\n"), 0644); err != nil { + t.Fatal(err) + } + if err := RegisterAppInCluster(dir, onboardingConfig()); err != nil { + t.Fatal(err) + } + registration, err := os.ReadFile(filepath.Join(tektonDir, "apps", "web-ui.yaml")) + if err != nil || !strings.Contains(string(registration), "branch: maidn/delivery-web-ui") || !strings.Contains(string(registration), "secretRef:\n name: forgejo-flux-credentials") || !strings.Contains(string(registration), "dependsOn:\n - name: tekton-catalog") || !strings.Contains(string(registration), "path: ./.tekton") { + t.Fatalf("registration = %q, %v", registration, err) + } + for path, resource := range map[string]string{filepath.Join(tektonDir, "kustomization.yaml"): "apps", filepath.Join(tektonDir, "apps", "kustomization.yaml"): "web-ui.yaml"} { + content, err := os.ReadFile(path) + if err != nil || !strings.Contains(string(content), resource) { + t.Fatalf("Kustomization %s does not include %s: %q, %v", path, resource, content, err) + } + } + if err := os.WriteFile(filepath.Join(tektonDir, "apps", "web-ui.yaml"), []byte("custom: true\n"), 0644); err != nil { + t.Fatal(err) + } + if err := RegisterAppInCluster(dir, onboardingConfig()); err == nil || !strings.Contains(err.Error(), "conflicts") { + t.Fatalf("unmanaged app registration was accepted: %v", err) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 2c7a400..64c4b7a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -148,25 +148,44 @@ func ResolveFreshBootstrap(cfg Config, organization string, enableDelivery bool) return cfg, err } } - if enableDelivery { - var err error - cfg, err = ResolveDelivery(cfg) - if 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.Flux.ManifestsRepo == "" || cfg.Flux.Branch == "" { - return cfg, errors.New("git owner and flux manifestsRepo and branch are required for app onboarding") + 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 } - return ResolveDelivery(cfg) + 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 { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 626289b..92d8fa9 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -91,6 +91,26 @@ func TestValidateDeliveryRequiresCompleteConfig(t *testing.T) { } } +func TestResolveAppOnboardingRequiresTargetOwner(t *testing.T) { + cfg := validConfig(t) + cfg.Git.Owner = "test-org-2" + if _, err := ResolveAppOnboarding(cfg); err == nil || !strings.Contains(err.Error(), "owner must match") { + t.Fatalf("ResolveAppOnboarding() accepted a source-owner target: %v", err) + } + cfg.Delivery.AppRepoURL = "https://git.example.test/test-org-2/web-ui.git" + if resolved, err := ResolveAppOnboarding(cfg); err != nil || resolved.Delivery.WebhookURL() != "https://tekton.example.test/" { + t.Fatalf("ResolveAppOnboarding() = %#v, %v", resolved.Delivery, err) + } +} + +func TestResolveAppOnboardingRequiresProductionBranch(t *testing.T) { + cfg := validConfig(t) + cfg.Delivery.ProductionBranch = "release" + if _, err := ResolveAppOnboarding(cfg); err == nil || !strings.Contains(err.Error(), "literal production") { + t.Fatalf("ResolveAppOnboarding() accepted nonstandard production branch: %v", err) + } +} + func TestValidateRejectsCredentialBearingDeliveryURLs(t *testing.T) { cfg := validConfig(t) cfg.Delivery.AppRepoURL = "https://reader:token@git.example.test/test-org/web-ui.git" diff --git a/internal/forgejo/repo.go b/internal/forgejo/repo.go index 4bb5922..8750d12 100644 --- a/internal/forgejo/repo.go +++ b/internal/forgejo/repo.go @@ -405,6 +405,51 @@ func (rm *RepoManager) CreatePullRequest(repo, title, head, base string) error { return nil } +// EnsurePullRequest creates one pull request or returns the one already open +// for the exact head branch. It refuses duplicate or otherwise ambiguous state. +func (rm *RepoManager) EnsurePullRequest(repo, title, head, base string) error { + if head == "" || base == "" || head == base { + return fmt.Errorf("Forgejo pull request head and base must be different non-empty branches") + } + open, err := rm.HasOpenPullRequest(repo, head) + if err != nil { + return err + } + if open { + return nil + } + body, err := json.Marshal(pullRequestRequest{Title: title, Head: head, Base: base}) + if err != nil { + return err + } + status, err := rm.apiRequest(http.MethodPost, fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls", rm.BaseURL, rm.Owner, repo), body) + if err != nil { + return err + } + if status != http.StatusCreated { + return fmt.Errorf("unexpected Forgejo pull request status %d", status) + } + return nil +} + +// HasOpenPullRequest reports whether exactly one pull request is open for head. +func (rm *RepoManager) HasOpenPullRequest(repo, head string) (bool, error) { + values := url.Values{"state": {"open"}, "head": {head}} + endpoint := fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls?%s", rm.BaseURL, rm.Owner, repo, values.Encode()) + var pullRequests []pullRequest + status, err := rm.apiJSONRequest(http.MethodGet, endpoint, nil, &pullRequests) + if err != nil { + return false, err + } + if status != http.StatusOK { + return false, fmt.Errorf("unexpected Forgejo pull request lookup status %d", status) + } + if len(pullRequests) > 1 { + return false, fmt.Errorf("multiple open Forgejo pull requests exist for branch %q", head) + } + return len(pullRequests) == 1, nil +} + func (rm *RepoManager) MergePullRequest(repo, head string) error { values := url.Values{"state": {"open"}, "head": {head}} endpoint := fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls?%s", rm.BaseURL, rm.Owner, repo, values.Encode()) @@ -453,64 +498,116 @@ func DeliveryBranch(appName, baseBranch string) (string, error) { } // PublishDeliveryBranch generates and commits delivery content in a temporary clone. -func (rm *RepoManager) PublishDeliveryBranch(sourceDir, sourceBranch, repoURL, deliveryBranch string, generate func(string) error) error { +func (rm *RepoManager) PublishDeliveryBranch(sourceDir, sourceBranch, repoURL, deliveryBranch string, generate func(string) error) (bool, error) { if sourceDir == "" || sourceBranch == "" || deliveryBranch == "" || deliveryBranch == rm.Branch { - return fmt.Errorf("delivery source branch and dedicated delivery branch are required and must differ from the base branch") + return false, fmt.Errorf("delivery source branch and dedicated delivery branch are required and must differ from the base branch") } temporary, err := os.MkdirTemp("", "maidn-delivery-*") if err != nil { - return err + return false, err } defer os.RemoveAll(temporary) if err := runGit("", os.Environ(), "clone", "--no-local", "--branch", sourceBranch, sourceDir, temporary); err != nil { - return err + return false, err } cleanupAskPass, environment, err := rm.gitEnvironment() if err != nil { - return err + return false, err } defer cleanupAskPass() if err := runGit(temporary, environment, "checkout", "-B", deliveryBranch); err != nil { - return err - } - if err := os.RemoveAll(filepath.Join(temporary, ".tekton")); err != nil { - return err + return false, err } if err := generate(temporary); err != nil { - return err + return false, err } if err := runGit(temporary, environment, "add", ".tekton"); err != nil { - return err + return false, err } changed, err := gitDiffQuiet(temporary, environment, "--cached") if err != nil { - return err + return false, err } if changed { for _, args := range [][]string{{"config", "user.name", "Maidn"}, {"config", "user.email", "maidn@free-maidn.com"}, {"commit", "-m", "feat: add Maidn delivery pipeline"}} { if err := runGit(temporary, environment, args...); err != nil { - return err + return false, err } } } hasBranch, err := rm.HasRemoteBranch(repoURL, deliveryBranch) if err != nil { - return err + return false, err } if hasBranch { if err := runGit(temporary, environment, "fetch", repoURL, "refs/heads/"+deliveryBranch); err != nil { - return err + return false, err } different, err := gitDiffQuiet(temporary, environment, "HEAD", "FETCH_HEAD") if err != nil { - return err + return false, err } if !different { - return nil + return false, nil } - return fmt.Errorf("dedicated delivery branch %q differs from generated content; refusing to overwrite it", deliveryBranch) + return false, fmt.Errorf("dedicated delivery branch %q differs from generated content; refusing to overwrite it", deliveryBranch) } - return rm.PushBranch(temporary, repoURL, deliveryBranch) + if err := rm.PushBranch(temporary, repoURL, deliveryBranch); err != nil { + return false, err + } + return true, nil +} + +// PublishRepositoryPullRequest applies a managed change on a dedicated branch. +// An existing branch is accepted only when it has exactly one open pull request. +func (rm *RepoManager) PublishRepositoryPullRequest(repo, title, branch, base string, change func(string) error) (bool, error) { + if repo == "" || branch == "" || base == "" || branch == base { + return false, errors.New("repository pull request requires distinct non-empty branches") + } + repoURL := CloneURL(rm.BaseURL, rm.Owner, repo) + hasBranch, err := rm.HasRemoteBranch(repoURL, branch) + if err != nil { + return false, err + } + temporary, err := os.MkdirTemp("", "maidn-registration-*") + if err != nil { + return false, err + } + defer os.RemoveAll(temporary) + cleanupAskPass, environment, err := rm.gitEnvironment() + if err != nil { + return false, err + } + defer cleanupAskPass() + checkout := base + if hasBranch { + checkout = branch + } + if err := runGit("", environment, "clone", "--branch", checkout, repoURL, temporary); err != nil { + return false, err + } + if !hasBranch { + if err := runGit(temporary, environment, "checkout", "-B", branch, "origin/"+base); err != nil { + return false, err + } + } + if err := change(temporary); err != nil { + return false, err + } + changed, err := commitAndPush(temporary, repo, branch, environment) + if err != nil { + return false, err + } + if !changed && !hasBranch { + return false, nil + } + if err := rm.EnsurePullRequest(repo, title, branch, base); err != nil { + return false, err + } + if err := rm.MergePullRequest(repo, branch); err != nil { + return false, err + } + return true, nil } func gitDiffQuiet(dir string, environment []string, args ...string) (bool, error) { @@ -648,6 +745,40 @@ func (rm *RepoManager) EnsureWebhook(repo, webhookURL, authorization string) err return nil } +// TriggerWebhookTest asks Forgejo to deliver a test push for the managed hook. +func (rm *RepoManager) TriggerWebhookTest(repo, webhookURL, branch string) error { + if repo == "" || webhookURL == "" || branch == "" { + return errors.New("Forgejo repository, webhook URL, and branch are required") + } + endpoint := fmt.Sprintf("%s/api/v1/repos/%s/%s/hooks", rm.BaseURL, rm.Owner, repo) + var hooks []hook + status, err := rm.apiJSONRequest(http.MethodGet, endpoint, nil, &hooks) + if err != nil { + return err + } + if status != http.StatusOK { + return fmt.Errorf("unexpected Forgejo webhook lookup status %d", status) + } + var matching []hook + for _, candidate := range hooks { + if candidate.URL == webhookURL { + matching = append(matching, candidate) + } + } + if len(matching) != 1 { + return fmt.Errorf("expected one Forgejo webhook for URL %q", webhookURL) + } + values := url.Values{"ref": {branch}} + status, err = rm.apiRequest(http.MethodPost, fmt.Sprintf("%s/%d/tests?%s", endpoint, matching[0].ID, values.Encode()), nil) + if err != nil { + return err + } + if status != http.StatusNoContent { + return fmt.Errorf("unexpected Forgejo webhook test status %d", status) + } + return nil +} + // EnsureProtectedBranch disables direct pushes to the configured production branch. func (rm *RepoManager) EnsureProtectedBranch(repo, branch string) error { if repo == "" || branch == "" { diff --git a/internal/forgejo/repo_test.go b/internal/forgejo/repo_test.go index d81333b..eb7f8b7 100644 --- a/internal/forgejo/repo_test.go +++ b/internal/forgejo/repo_test.go @@ -238,6 +238,28 @@ func TestEnsureWebhookCreatesMissingWebhook(t *testing.T) { } } +func TestTriggerWebhookTestUsesManagedHookAndBaseBranch(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + switch request.Method + " " + request.URL.Path { + case http.MethodGet + " /api/v1/repos/owner/app/hooks": + _ = json.NewEncoder(writer).Encode([]hook{{ID: 7, URL: "https://tekton.example.test/"}}) + case http.MethodPost + " /api/v1/repos/owner/app/hooks/7/tests": + if request.URL.Query().Get("ref") != "main" || request.Header.Get("Authorization") == "" { + t.Fatal("webhook test did not use the managed hook and base branch") + } + writer.WriteHeader(http.StatusNoContent) + default: + t.Fatalf("unexpected Forgejo request %s %s", request.Method, request.URL.String()) + } + })) + defer server.Close() + manager := NewRepoManager(server.URL, "test-token", "owner", "user", "", "", "main", "") + manager.HTTPClient = server.Client() + if err := manager.TriggerWebhookTest("app", "https://tekton.example.test/", "main"); err != nil { + t.Fatal(err) + } +} + func TestEnsureProtectedBranchCreatesDirectPushProtection(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { if request.URL.Path != "/api/v1/repos/owner/app/branch_protections" { @@ -354,6 +376,34 @@ func TestMergePullRequest(t *testing.T) { } } +func TestEnsurePullRequestChecksExactOpenBranchBeforeCreating(t *testing.T) { + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + requests++ + switch request.Method { + case http.MethodGet: + if request.URL.Path != "/api/v1/repos/owner/app/pulls" || request.URL.Query().Get("state") != "open" || request.URL.Query().Get("head") != "maidn/delivery-app" { + t.Fatalf("unexpected pull request lookup: %s", request.URL.String()) + } + _, _ = writer.Write([]byte("[]")) + case http.MethodPost: + var body pullRequestRequest + if err := json.NewDecoder(request.Body).Decode(&body); err != nil || body.Head != "maidn/delivery-app" || body.Base != "main" { + t.Fatalf("unexpected pull request create: %#v, %v", body, err) + } + writer.WriteHeader(http.StatusCreated) + default: + t.Fatalf("unexpected request method %s", request.Method) + } + })) + defer server.Close() + manager := NewRepoManager(server.URL, "test-token", "owner", "bot", "", "", "main", "") + manager.HTTPClient = server.Client() + if err := manager.EnsurePullRequest("app", "delivery", "maidn/delivery-app", "main"); err != nil || requests != 2 { + t.Fatalf("EnsurePullRequest() = %v, requests = %d", err, requests) + } +} + func TestRepoExistsReturnsFalseOnNotFound(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { writer.WriteHeader(http.StatusNotFound)