Merge pull request 'feat: onboard canonical E2E sources' (#55) from feat/canonical-e2e-onboarding into main

Reviewed-on: #55
This commit is contained in:
eding 2026-09-15 20:32:43 +02:00
commit 8684ed9427
5 changed files with 215 additions and 29 deletions

View file

@ -13,7 +13,23 @@ 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
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 +50,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 +74,19 @@ 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 {
return err
}
}
if open, err := manager.HasOpenPullRequest(repository, deliveryBranch); err != nil {
return err
} else if open {
if err := manager.MergePullRequest(repository, deliveryBranch); err != nil {
if err := sourceManager.EnsurePullRequest(repository, "feat: migrate delivery to Tekton", deliveryBranch, resolved.Delivery.AppRepoRef); 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 +99,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 +338,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)

View file

@ -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,125 @@ 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",
} {
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)
}
}

View file

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

View file

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

View file

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