diff --git a/docs/architecture/delivery-ownership.md b/docs/architecture/delivery-ownership.md index dbd9989..357f2a4 100644 --- a/docs/architecture/delivery-ownership.md +++ b/docs/architecture/delivery-ownership.md @@ -2,8 +2,8 @@ ## Status -This is the approved target architecture. The current source-owned onboarding -implementation is being migrated and must not be used for new applications. +This is the approved target architecture. Central onboarding is available for +new applications; existing source-owned registrations remain migration work. ## Trust Boundary @@ -24,6 +24,10 @@ operators and approved automation may push or merge into it. Flux must track only that branch for chart content. Flux must never track an application `main` branch or `maidn/delivery-*` branch. +Onboarding requires an existing `maidn/platform-` branch and verifies its +no-direct-push protection before it opens the central registration PR. It never +seeds a platform branch from developer-controlled `main`. + ## Resource Flow ```mermaid diff --git a/docs/operations.md b/docs/operations.md index 8938b85..1432518 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -46,8 +46,9 @@ go run . bootstrap init --config --organization ` package through a reviewed platform PR. See [Delivery +registration, a platform operator must create the corresponding +`maidn/platform-` package through a reviewed platform PR; onboarding +verifies its existence and enforces its protection. See [Delivery Ownership](architecture/delivery-ownership.md) for the approved architecture and migration rules. diff --git a/internal/bootstrap/onboard.go b/internal/bootstrap/onboard.go index 4970a3a..227e9c0 100644 --- a/internal/bootstrap/onboard.go +++ b/internal/bootstrap/onboard.go @@ -57,6 +57,9 @@ func OnboardApp(cfg config.Config, sourceDir string) error { if err := sourceManager.EnsureProtectedBranch(repository, resolved.Delivery.ProductionBranch); err != nil { return fmt.Errorf("protect Forgejo production branch: %w", err) } + if err := ensurePlatformBranch(sourceManager, repository, resolved.Delivery.AppRepoURL, resolved.Delivery.AppName); 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, "") if _, err := clusterManager.PublishRepositoryPullRequest(resolved.Flux.RepoName, "feat: register "+resolved.Delivery.AppName+" delivery", registrationBranch, resolved.Flux.Branch, func(dir string) error { @@ -83,6 +86,25 @@ func OnboardApp(cfg config.Config, sourceDir string) error { return nil } +func platformBranch(appName string) string { + return "maidn/platform-" + appName +} + +func ensurePlatformBranch(manager onboardingRepoManager, repository, repositoryURL, appName string) error { + branch := platformBranch(appName) + revision, err := manager.RemoteBranchRevision(repositoryURL, branch) + if err != nil { + return fmt.Errorf("read Forgejo platform branch: %w", err) + } + if revision == "" { + return fmt.Errorf("approved Forgejo platform branch %q must exist before central registration", branch) + } + if err := manager.EnsureProtectedBranch(repository, branch); err != nil { + return fmt.Errorf("protect Forgejo platform branch: %w", err) + } + return nil +} + // publishInitialAppBranches establishes the immutable source baseline before central registration. func publishInitialAppBranches(manager onboardingRepoManager, sourceDir, targetURL, sourceBranch, targetBranch, productionBranch string) error { sourceRevision, err := forgejo.BranchRevision(sourceDir, sourceBranch) @@ -209,8 +231,8 @@ spec: secretRef: name: forgejo-flux-credentials ref: - branch: maidn/platform-%s -`, cfg.Delivery.AppName, cfg.Delivery.AppRepoURL, cfg.Delivery.AppName) + branch: %s +`, cfg.Delivery.AppName, cfg.Delivery.AppRepoURL, platformBranch(cfg.Delivery.AppName)) if len(secretAccess) != 0 { content += "---\n" + string(secretAccess) } diff --git a/internal/bootstrap/onboard_test.go b/internal/bootstrap/onboard_test.go index 7d90a79..f68036e 100644 --- a/internal/bootstrap/onboard_test.go +++ b/internal/bootstrap/onboard_test.go @@ -158,8 +158,9 @@ func TestPublishInitialAppBranchesCreatesAndPreservesProduction(t *testing.T) { } type onboardingManagerFake struct { - owner string - calls []string + owner string + calls []string + remoteRevisions map[string]string } func (m *onboardingManagerFake) EnsureRepository(repo, _ string) (bool, error) { @@ -169,6 +170,9 @@ func (m *onboardingManagerFake) EnsureRepository(repo, _ string) (bool, error) { func (m *onboardingManagerFake) RemoteBranchRevision(targetURL, branch string) (string, error) { m.calls = append(m.calls, "remote "+targetURL+":"+branch) + if m.remoteRevisions != nil { + return m.remoteRevisions[branch], nil + } return "existing", nil } @@ -255,6 +259,8 @@ func TestOnboardAppUsesCanonicalSourceAndCentralClusterManagers(t *testing.T) { "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", + "remote https://git.example.test/Maidn/maidn-e2e-web.git:maidn/platform-web-ui", + "protect Maidn/maidn-e2e-web:maidn/platform-web-ui", } { if !strings.Contains(sourceCalls, want) { t.Fatalf("source manager calls = %q, missing %q", sourceCalls, want) @@ -267,3 +273,14 @@ func TestOnboardAppUsesCanonicalSourceAndCentralClusterManagers(t *testing.T) { t.Fatalf("cluster manager calls = %q", got) } } + +func TestEnsurePlatformBranchRequiresExistingBranch(t *testing.T) { + manager := &onboardingManagerFake{owner: "Maidn", remoteRevisions: map[string]string{}} + err := ensurePlatformBranch(manager, "maidn-e2e-web", "https://git.example.test/Maidn/maidn-e2e-web.git", "web-ui") + if err == nil || !strings.Contains(err.Error(), "maidn/platform-web-ui") { + t.Fatalf("ensurePlatformBranch() error = %v", err) + } + if strings.Contains(strings.Join(manager.calls, "\n"), "protect") { + t.Fatalf("missing platform branch was protected: %q", manager.calls) + } +} diff --git a/internal/forgejo/repo.go b/internal/forgejo/repo.go index 88606b5..be10d33 100644 --- a/internal/forgejo/repo.go +++ b/internal/forgejo/repo.go @@ -780,10 +780,10 @@ func (rm *RepoManager) TriggerWebhookTest(repo, webhookURL, branch string) error return nil } -// EnsureProtectedBranch disables direct pushes to the configured production branch. +// EnsureProtectedBranch disables direct pushes to a managed branch. func (rm *RepoManager) EnsureProtectedBranch(repo, branch string) error { if repo == "" || branch == "" { - return errors.New("Forgejo repository and production branch are required") + return errors.New("Forgejo repository and branch are required") } endpoint := fmt.Sprintf("%s/api/v1/repos/%s/%s/branch_protections", rm.BaseURL, rm.Owner, repo) var protections []branchProtection @@ -801,11 +801,11 @@ func (rm *RepoManager) EnsureProtectedBranch(repo, branch string) error { } } if len(matching) > 1 { - return fmt.Errorf("multiple Forgejo branch protections match production branch %q", branch) + return fmt.Errorf("multiple Forgejo branch protections match branch %q", branch) } if len(matching) == 1 { if matching[0].EnablePush || matching[0].EnablePushWhitelist { - return fmt.Errorf("Forgejo production branch %q permits direct pushes", branch) + return fmt.Errorf("Forgejo branch %q permits direct pushes", branch) } return nil }