From 67518977ebf772173d28f1a2b04ca85842f94149 Mon Sep 17 00:00:00 2001 From: eding Date: Tue, 15 Sep 2026 01:10:05 +0200 Subject: [PATCH 1/2] feat: onboard canonical E2E sources --- internal/bootstrap/onboard.go | 66 +++++++++++---- internal/bootstrap/onboard_test.go | 131 +++++++++++++++++++++++++++++ internal/config/config.go | 6 +- internal/config/config_test.go | 16 +++- internal/forgejo/repo_test.go | 22 +++++ 5 files changed, 217 insertions(+), 24 deletions(-) diff --git a/internal/bootstrap/onboard.go b/internal/bootstrap/onboard.go index f4532cf..0c5f02f 100644 --- a/internal/bootstrap/onboard.go +++ b/internal/bootstrap/onboard.go @@ -13,7 +13,25 @@ import ( "gopkg.in/yaml.v3" ) -// OnboardApp imports one clean checkout into the configured Forgejo owner, +type onboardingRepoManager interface { + EnsureRepository(string, string) (bool, error) + RemoteBranchRevision(string, string) (string, error) + PushRef(string, string, string, string) error + EnsureProtectedBranch(string, string) error + PublishDeliveryBranch(string, string, string, string, func(string) error) (bool, error) + EnsurePullRequest(string, string, string, string) error + HasOpenPullRequest(string, string) (bool, error) + MergePullRequest(string, string) error + PublishRepositoryPullRequest(string, string, string, string, func(string) error) (bool, error) + EnsureWebhook(string, string, string) error + TriggerWebhookTest(string, string, string) error +} + +var newOnboardingRepoManager = func(baseURL, token, owner, username, manifestsRepo, fluxRepo, branch, migrationBranch string) onboardingRepoManager { + return forgejo.NewRepoManager(baseURL, token, owner, username, manifestsRepo, fluxRepo, branch, migrationBranch) +} + +// OnboardApp imports one clean checkout into its source 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) @@ -34,24 +52,21 @@ func OnboardApp(cfg config.Config, sourceDir string) error { 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 { + sourceManager := newOnboardingRepoManager(resolved.Git.BaseURL, resolved.Git.Token, owner, resolved.Git.Username, "", "", resolved.Delivery.AppRepoRef, "") + if _, err := sourceManager.EnsureRepository(repository, "Application source for Maidn CI/CD delivery"); err != nil { return err } - if err := publishInitialAppBranches(manager, sourceDir, resolved.Delivery.AppRepoURL, sourceBranch, resolved.Delivery.AppRepoRef, resolved.Delivery.ProductionBranch); err != nil { + if err := publishInitialAppBranches(sourceManager, sourceDir, resolved.Delivery.AppRepoURL, sourceBranch, resolved.Delivery.AppRepoRef, resolved.Delivery.ProductionBranch); err != nil { return err } - if err := manager.EnsureProtectedBranch(repository, resolved.Delivery.ProductionBranch); err != nil { + if err := sourceManager.EnsureProtectedBranch(repository, resolved.Delivery.ProductionBranch); err != nil { return fmt.Errorf("protect Forgejo production branch: %w", err) } - changed, err := manager.PublishDeliveryBranch(sourceDir, sourceBranch, resolved.Delivery.AppRepoURL, deliveryBranch, func(dir string) error { + changed, err := sourceManager.PublishDeliveryBranch(sourceDir, sourceBranch, resolved.Delivery.AppRepoURL, deliveryBranch, func(dir string) error { if err := GenerateAppDelivery(dir, resolved); err != nil { return err } @@ -61,25 +76,26 @@ func OnboardApp(cfg config.Config, sourceDir string) error { return err } if changed { - if err := manager.EnsurePullRequest(repository, "feat: migrate delivery to Tekton", deliveryBranch, resolved.Delivery.AppRepoRef); err != nil { + if err := sourceManager.EnsurePullRequest(repository, "feat: migrate delivery to Tekton", deliveryBranch, resolved.Delivery.AppRepoRef); err != nil { return err } } - if open, err := manager.HasOpenPullRequest(repository, deliveryBranch); err != nil { + if open, err := sourceManager.HasOpenPullRequest(repository, deliveryBranch); err != nil { return err } else if open { - if err := manager.MergePullRequest(repository, deliveryBranch); err != nil { + if err := sourceManager.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 { + clusterManager := newOnboardingRepoManager(resolved.Git.BaseURL, resolved.Git.Token, resolved.Git.Owner, resolved.Git.Username, "", "", resolved.Flux.Branch, "") + if _, err := clusterManager.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) + secrets, err := readOperationalSecrets(resolved.SOPS.OperationalSecretsPath, resolved.SOPS.AgeKeyPath) if err != nil { return fmt.Errorf("read encrypted webhook authorization: %w", err) } @@ -92,17 +108,17 @@ func OnboardApp(cfg config.Config, sourceDir string) error { return err } webhookURL := "https://tekton." + resolved.Flux.ClusterDomain + "/" - if err := manager.EnsureWebhook(repository, webhookURL, authorization); err != nil { + if err := sourceManager.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 { + if err := sourceManager.TriggerWebhookTest(repository, webhookURL, resolved.Delivery.AppRepoRef); err != nil { return fmt.Errorf("trigger Forgejo webhook test: %w", err) } return nil } // publishInitialAppBranches establishes the immutable source baseline before delivery setup. -func publishInitialAppBranches(manager *forgejo.RepoManager, sourceDir, targetURL, sourceBranch, targetBranch, productionBranch string) error { +func publishInitialAppBranches(manager onboardingRepoManager, sourceDir, targetURL, sourceBranch, targetBranch, productionBranch string) error { sourceRevision, err := forgejo.BranchRevision(sourceDir, sourceBranch) if err != nil { return err @@ -331,7 +347,21 @@ spec: if err := os.MkdirAll(maidnDir, 0755); err != nil { return err } - if err := os.WriteFile(filepath.Join(maidnDir, "kustomization.yaml"), []byte("apiVersion: kustomize.config.k8s.io/v1beta1\nkind: Kustomization\nresources:\n - secret-access.yaml\n"), 0644); err != nil { + kustomization := filepath.Join(maidnDir, "kustomization.yaml") + content := []byte("apiVersion: kustomize.config.k8s.io/v1beta1\nkind: Kustomization\nresources:\n - secret-access.yaml\n") + if _, err := os.Lstat(kustomization); err == nil { + content, err = readRegularFile(kustomization) + if err != nil { + return err + } + content, err = addKustomizationResource(content, "secret-access.yaml") + if err != nil { + return err + } + } else if !os.IsNotExist(err) { + return err + } + if err := os.WriteFile(kustomization, content, 0644); err != nil { return err } return os.WriteFile(filepath.Join(maidnDir, "secret-access.yaml"), []byte(manifests), 0644) diff --git a/internal/bootstrap/onboard_test.go b/internal/bootstrap/onboard_test.go index 1649c44..d3dd84c 100644 --- a/internal/bootstrap/onboard_test.go +++ b/internal/bootstrap/onboard_test.go @@ -2,6 +2,7 @@ package bootstrap import ( "bytes" + "errors" "io" "os" "os/exec" @@ -115,6 +116,12 @@ func TestGenerateAppSecretAccessRendersOnlyDeclaredRuntimeSecrets(t *testing.T) dir := t.TempDir() cfg := onboardingConfig() cfg.SecretGrants = []config.SecretGrant{{Application: "web-ui", Consumer: "runtime", Environment: "staging", Secrets: []string{"api-key"}, Shared: []string{"payments"}}} + if err := os.Mkdir(filepath.Join(dir, ".maidn"), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, ".maidn", "kustomization.yaml"), []byte("apiVersion: kustomize.config.k8s.io/v1beta1\nkind: Kustomization\nresources:\n - database.yaml\n"), 0644); err != nil { + t.Fatal(err) + } if err := GenerateAppSecretAccess(dir, cfg); err != nil { t.Fatal(err) } @@ -122,6 +129,10 @@ func TestGenerateAppSecretAccessRendersOnlyDeclaredRuntimeSecrets(t *testing.T) if err != nil || !strings.Contains(string(access), "namespace: staging") || !strings.Contains(string(access), "key: apps/web-ui/api-key") || strings.Contains(string(access), "shared/payments") { t.Fatalf("secret access = %q, %v", access, err) } + kustomization, err := os.ReadFile(filepath.Join(dir, ".maidn", "kustomization.yaml")) + if err != nil || !strings.Contains(string(kustomization), "database.yaml") || !strings.Contains(string(kustomization), "secret-access.yaml") { + t.Fatalf("secret Kustomization = %q, %v", kustomization, err) + } registration, err := renderAppRegistration(cfg) if err != nil || !strings.Contains(string(registration), "name: web-ui-secrets") || !strings.Contains(string(registration), "path: ./.maidn") { t.Fatalf("secret registration = %q, %v", registration, err) @@ -190,3 +201,123 @@ func TestPublishInitialAppBranchesCreatesAndPreservesProduction(t *testing.T) { t.Fatalf("main = %s, want existing %s", got, sourceRevision) } } + +type onboardingManagerFake struct { + owner string + calls []string +} + +func (m *onboardingManagerFake) EnsureRepository(repo, _ string) (bool, error) { + m.calls = append(m.calls, "ensure "+m.owner+"/"+repo) + return false, nil +} + +func (m *onboardingManagerFake) RemoteBranchRevision(targetURL, branch string) (string, error) { + m.calls = append(m.calls, "remote "+targetURL+":"+branch) + return "existing", nil +} + +func (m *onboardingManagerFake) PushRef(_, targetURL, _, targetBranch string) error { + m.calls = append(m.calls, "push "+targetURL+":"+targetBranch) + return nil +} + +func (m *onboardingManagerFake) EnsureProtectedBranch(repo, branch string) error { + m.calls = append(m.calls, "protect "+m.owner+"/"+repo+":"+branch) + return nil +} + +func (m *onboardingManagerFake) PublishDeliveryBranch(_, _, targetURL, branch string, _ func(string) error) (bool, error) { + m.calls = append(m.calls, "delivery "+targetURL+":"+branch) + return false, nil +} + +func (m *onboardingManagerFake) EnsurePullRequest(repo, _, branch, _ string) error { + m.calls = append(m.calls, "ensure-pr "+m.owner+"/"+repo+":"+branch) + return nil +} + +func (m *onboardingManagerFake) HasOpenPullRequest(repo, branch string) (bool, error) { + m.calls = append(m.calls, "open-pr "+m.owner+"/"+repo+":"+branch) + return false, nil +} + +func (m *onboardingManagerFake) MergePullRequest(repo, branch string) error { + m.calls = append(m.calls, "merge-pr "+m.owner+"/"+repo+":"+branch) + return nil +} + +func (m *onboardingManagerFake) PublishRepositoryPullRequest(repo, _, branch, _ string, _ func(string) error) (bool, error) { + m.calls = append(m.calls, "register "+m.owner+"/"+repo+":"+branch) + return true, nil +} + +func (m *onboardingManagerFake) EnsureWebhook(repo, _, _ string) error { + m.calls = append(m.calls, "webhook "+m.owner+"/"+repo) + return nil +} + +func (m *onboardingManagerFake) TriggerWebhookTest(repo, _, branch string) error { + m.calls = append(m.calls, "webhook-test "+m.owner+"/"+repo+":"+branch) + return nil +} + +func TestOnboardAppUsesCanonicalSourceAndExecutionClusterManagers(t *testing.T) { + source := filepath.Join(t.TempDir(), "source") + if err := os.Mkdir(source, 0755); err != nil { + t.Fatal(err) + } + onboardingGit(t, source, "init", "-b", "main") + onboardingGit(t, source, "config", "user.name", "Test") + onboardingGit(t, source, "config", "user.email", "test@example.test") + if err := os.WriteFile(filepath.Join(source, "README.md"), []byte("source\n"), 0644); err != nil { + t.Fatal(err) + } + onboardingGit(t, source, "add", "README.md") + onboardingGit(t, source, "commit", "-m", "source") + + cfg := onboardingConfig() + cfg.Git.Username, cfg.Git.Token = "bot", "test-token" + cfg.Delivery.AppRepoURL = "https://git.example.test/Maidn/maidn-e2e-web.git" + sourceManager := &onboardingManagerFake{owner: "Maidn"} + clusterManager := &onboardingManagerFake{owner: cfg.Git.Owner} + originalManager, originalSecrets := newOnboardingRepoManager, readOperationalSecrets + t.Cleanup(func() { + newOnboardingRepoManager, readOperationalSecrets = originalManager, originalSecrets + }) + newOnboardingRepoManager = func(_, _, owner, _, _, _, _, _ string) onboardingRepoManager { + switch owner { + case "Maidn": + return sourceManager + case cfg.Git.Owner: + return clusterManager + default: + t.Fatalf("unexpected onboarding manager owner %q", owner) + return nil + } + } + readOperationalSecrets = func(string, string) (map[string]map[string]string, error) { + return nil, errors.New("stop after registration") + } + + err := OnboardApp(cfg, source) + if err == nil || !strings.Contains(err.Error(), "read encrypted webhook authorization") { + t.Fatalf("OnboardApp() = %v", err) + } + sourceCalls := strings.Join(sourceManager.calls, "\n") + for _, want := range []string{ + "ensure Maidn/maidn-e2e-web", + "remote https://git.example.test/Maidn/maidn-e2e-web.git:main", + "remote https://git.example.test/Maidn/maidn-e2e-web.git:production", + "protect Maidn/maidn-e2e-web:production", + "delivery https://git.example.test/Maidn/maidn-e2e-web.git:maidn/delivery-web-ui", + "open-pr Maidn/maidn-e2e-web:maidn/delivery-web-ui", + } { + if !strings.Contains(sourceCalls, want) { + t.Fatalf("source manager calls = %q, missing %q", sourceCalls, want) + } + } + if got := strings.Join(clusterManager.calls, "\n"); got != "register test-org-2/cluster:maidn/register-web-ui" { + t.Fatalf("cluster manager calls = %q", got) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 19cab6b..f2e4197 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -164,12 +164,12 @@ func ResolveAppOnboarding(cfg Config) (Config, error) { if err != nil { return cfg, err } - owner, _, err := deliveryRepositoryOwner(resolved.Delivery.AppRepoURL) + owner, repository, 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 owner != resolved.Git.Owner && (owner != "Maidn" || !strings.HasPrefix(repository, "maidn-e2e-")) { + return cfg, errors.New("delivery appRepoUrl owner must match git owner or identify a canonical Maidn E2E fixture") } if resolved.Delivery.ProductionBranch != "production" { return cfg, errors.New("delivery productionBranch must be literal production for app onboarding") diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 73b1414..93f1181 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -99,16 +99,26 @@ func TestValidateDeliveryRejectsUnsafeTektonDashboardURL(t *testing.T) { } } -func TestResolveAppOnboardingRequiresTargetOwner(t *testing.T) { +func TestResolveAppOnboardingAllowsOnlyCanonicalCrossOwnerSource(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/Maidn/maidn-e2e-web.git" + if _, err := ResolveAppOnboarding(cfg); err != nil { + t.Fatalf("ResolveAppOnboarding() rejected canonical source: %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) } + for _, appRepoURL := range []string{ + "https://git.example.test/Maidn/web-ui.git", + "https://git.example.test/other-org/maidn-e2e-web.git", + } { + cfg.Delivery.AppRepoURL = appRepoURL + if _, err := ResolveAppOnboarding(cfg); err == nil || !strings.Contains(err.Error(), "canonical Maidn E2E fixture") { + t.Fatalf("ResolveAppOnboarding() accepted noncanonical cross-owner source %q: %v", appRepoURL, err) + } + } } func TestResolveAppOnboardingRequiresProductionBranch(t *testing.T) { diff --git a/internal/forgejo/repo_test.go b/internal/forgejo/repo_test.go index b0366c6..7fd3da8 100644 --- a/internal/forgejo/repo_test.go +++ b/internal/forgejo/repo_test.go @@ -317,6 +317,28 @@ func TestEnsureProtectedBranchCreatesDirectPushProtection(t *testing.T) { } } +func TestEnsureProtectedBranchUsesCanonicalSourceOwnerPath(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.URL.Path != "/api/v1/repos/Maidn/maidn-e2e-web/branch_protections" { + t.Fatalf("canonical source mutation targeted %q", request.URL.Path) + } + switch request.Method { + case http.MethodGet: + _ = json.NewEncoder(writer).Encode([]branchProtection{}) + case http.MethodPost: + writer.WriteHeader(http.StatusCreated) + default: + t.Fatalf("unexpected method %q", request.Method) + } + })) + defer server.Close() + manager := NewRepoManager(server.URL, "token", "Maidn", "user", "", "", "main", "") + manager.HTTPClient = server.Client() + if err := manager.EnsureProtectedBranch("maidn-e2e-web", "production"); err != nil { + t.Fatal(err) + } +} + func TestEnsureProtectedBranchRejectsExistingDirectPushRule(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { if request.Method != http.MethodGet { From 11f9271fd080dacfda1091944d979b48f3beb097 Mon Sep 17 00:00:00 2001 From: eding Date: Tue, 15 Sep 2026 20:25:20 +0200 Subject: [PATCH 2/2] fix: leave delivery PRs for review --- internal/bootstrap/onboard.go | 9 --------- internal/bootstrap/onboard_test.go | 4 +++- 2 files changed, 3 insertions(+), 10 deletions(-) diff --git a/internal/bootstrap/onboard.go b/internal/bootstrap/onboard.go index 0c5f02f..02d1123 100644 --- a/internal/bootstrap/onboard.go +++ b/internal/bootstrap/onboard.go @@ -20,8 +20,6 @@ type onboardingRepoManager interface { EnsureProtectedBranch(string, string) error PublishDeliveryBranch(string, string, string, string, func(string) error) (bool, error) EnsurePullRequest(string, string, string, string) error - HasOpenPullRequest(string, string) (bool, error) - MergePullRequest(string, string) error PublishRepositoryPullRequest(string, string, string, string, func(string) error) (bool, error) EnsureWebhook(string, string, string) error TriggerWebhookTest(string, string, string) error @@ -80,13 +78,6 @@ func OnboardApp(cfg config.Config, sourceDir string) error { return err } } - if open, err := sourceManager.HasOpenPullRequest(repository, deliveryBranch); err != nil { - return err - } else if open { - if err := sourceManager.MergePullRequest(repository, deliveryBranch); err != nil { - return err - } - } registrationBranch := "maidn/register-" + resolved.Delivery.AppName clusterManager := newOnboardingRepoManager(resolved.Git.BaseURL, resolved.Git.Token, resolved.Git.Owner, resolved.Git.Username, "", "", resolved.Flux.Branch, "") diff --git a/internal/bootstrap/onboard_test.go b/internal/bootstrap/onboard_test.go index d3dd84c..d51ce62 100644 --- a/internal/bootstrap/onboard_test.go +++ b/internal/bootstrap/onboard_test.go @@ -311,12 +311,14 @@ func TestOnboardAppUsesCanonicalSourceAndExecutionClusterManagers(t *testing.T) "remote https://git.example.test/Maidn/maidn-e2e-web.git:production", "protect Maidn/maidn-e2e-web:production", "delivery https://git.example.test/Maidn/maidn-e2e-web.git:maidn/delivery-web-ui", - "open-pr Maidn/maidn-e2e-web:maidn/delivery-web-ui", } { if !strings.Contains(sourceCalls, want) { t.Fatalf("source manager calls = %q, missing %q", sourceCalls, want) } } + if strings.Contains(sourceCalls, "merge-pr") { + t.Fatalf("source delivery PR was merged without review: %q", sourceCalls) + } if got := strings.Join(clusterManager.calls, "\n"); got != "register test-org-2/cluster:maidn/register-web-ui" { t.Fatalf("cluster manager calls = %q", got) }