feat: onboard canonical E2E sources #55

Merged
eding merged 2 commits from feat/canonical-e2e-onboarding into main 2026-09-15 20:32:44 +02:00
5 changed files with 217 additions and 24 deletions
Showing only changes of commit 67518977eb - Show all commits

View file

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

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,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)
}
}

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 {