Compare commits

..

No commits in common. "8684ed942725d48de9062cbdc565fcfc3bd3412d" and "7df7d03dbe55cb0d8f16dc253f2f821b5a18ce71" have entirely different histories.

5 changed files with 29 additions and 215 deletions

View file

@ -13,23 +13,7 @@ import (
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
type onboardingRepoManager interface { // OnboardApp imports one clean checkout into the configured Forgejo owner,
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. // publishes its delivery branch, registers it with Flux, then adds its webhook.
func OnboardApp(cfg config.Config, sourceDir string) error { func OnboardApp(cfg config.Config, sourceDir string) error {
resolved, err := config.ResolveAppOnboarding(cfg) resolved, err := config.ResolveAppOnboarding(cfg)
@ -50,21 +34,24 @@ func OnboardApp(cfg config.Config, sourceDir string) error {
if err != nil { if err != nil {
return err 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) deliveryBranch, err := forgejo.DeliveryBranch(resolved.Delivery.AppName, resolved.Delivery.AppRepoRef)
if err != nil { if err != nil {
return err return err
} }
sourceManager := newOnboardingRepoManager(resolved.Git.BaseURL, resolved.Git.Token, owner, resolved.Git.Username, "", "", resolved.Delivery.AppRepoRef, "") manager := forgejo.NewRepoManager(resolved.Git.BaseURL, resolved.Git.Token, resolved.Git.Owner, resolved.Git.Username, "", "", resolved.Delivery.AppRepoRef, "")
if _, err := sourceManager.EnsureRepository(repository, "Application source for Maidn CI/CD delivery"); err != nil { if _, err := manager.EnsureRepository(repository, "Application source for Maidn CI/CD delivery"); err != nil {
return err return err
} }
if err := publishInitialAppBranches(sourceManager, sourceDir, resolved.Delivery.AppRepoURL, sourceBranch, resolved.Delivery.AppRepoRef, resolved.Delivery.ProductionBranch); err != nil { if err := publishInitialAppBranches(manager, sourceDir, resolved.Delivery.AppRepoURL, sourceBranch, resolved.Delivery.AppRepoRef, resolved.Delivery.ProductionBranch); err != nil {
return err return err
} }
if err := sourceManager.EnsureProtectedBranch(repository, resolved.Delivery.ProductionBranch); err != nil { if err := manager.EnsureProtectedBranch(repository, resolved.Delivery.ProductionBranch); err != nil {
return fmt.Errorf("protect Forgejo production branch: %w", err) return fmt.Errorf("protect Forgejo production branch: %w", err)
} }
changed, err := sourceManager.PublishDeliveryBranch(sourceDir, sourceBranch, resolved.Delivery.AppRepoURL, deliveryBranch, func(dir string) error { changed, err := manager.PublishDeliveryBranch(sourceDir, sourceBranch, resolved.Delivery.AppRepoURL, deliveryBranch, func(dir string) error {
if err := GenerateAppDelivery(dir, resolved); err != nil { if err := GenerateAppDelivery(dir, resolved); err != nil {
return err return err
} }
@ -74,19 +61,25 @@ func OnboardApp(cfg config.Config, sourceDir string) error {
return err return err
} }
if changed { if changed {
if err := sourceManager.EnsurePullRequest(repository, "feat: migrate delivery to Tekton", deliveryBranch, resolved.Delivery.AppRepoRef); err != nil { 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 return err
} }
} }
registrationBranch := "maidn/register-" + resolved.Delivery.AppName 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 := manager.PublishRepositoryPullRequest(resolved.Flux.RepoName, "feat: register "+resolved.Delivery.AppName+" delivery", registrationBranch, resolved.Flux.Branch, func(dir string) error {
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) return RegisterAppInCluster(dir, resolved)
}); err != nil { }); err != nil {
return fmt.Errorf("register app in cluster repository: %w", err) 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 { if err != nil {
return fmt.Errorf("read encrypted webhook authorization: %w", err) return fmt.Errorf("read encrypted webhook authorization: %w", err)
} }
@ -99,17 +92,17 @@ func OnboardApp(cfg config.Config, sourceDir string) error {
return err return err
} }
webhookURL := "https://tekton." + resolved.Flux.ClusterDomain + "/" webhookURL := "https://tekton." + resolved.Flux.ClusterDomain + "/"
if err := sourceManager.EnsureWebhook(repository, webhookURL, authorization); err != nil { if err := manager.EnsureWebhook(repository, webhookURL, authorization); err != nil {
return fmt.Errorf("register Forgejo webhook: %w", err) return fmt.Errorf("register Forgejo webhook: %w", err)
} }
if err := sourceManager.TriggerWebhookTest(repository, webhookURL, resolved.Delivery.AppRepoRef); err != nil { if err := manager.TriggerWebhookTest(repository, webhookURL, resolved.Delivery.AppRepoRef); err != nil {
return fmt.Errorf("trigger Forgejo webhook test: %w", err) return fmt.Errorf("trigger Forgejo webhook test: %w", err)
} }
return nil return nil
} }
// publishInitialAppBranches establishes the immutable source baseline before delivery setup. // publishInitialAppBranches establishes the immutable source baseline before delivery setup.
func publishInitialAppBranches(manager onboardingRepoManager, sourceDir, targetURL, sourceBranch, targetBranch, productionBranch string) error { func publishInitialAppBranches(manager *forgejo.RepoManager, sourceDir, targetURL, sourceBranch, targetBranch, productionBranch string) error {
sourceRevision, err := forgejo.BranchRevision(sourceDir, sourceBranch) sourceRevision, err := forgejo.BranchRevision(sourceDir, sourceBranch)
if err != nil { if err != nil {
return err return err
@ -338,21 +331,7 @@ spec:
if err := os.MkdirAll(maidnDir, 0755); err != nil { if err := os.MkdirAll(maidnDir, 0755); err != nil {
return err return err
} }
kustomization := filepath.Join(maidnDir, "kustomization.yaml") 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 {
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 err
} }
return os.WriteFile(filepath.Join(maidnDir, "secret-access.yaml"), []byte(manifests), 0644) return os.WriteFile(filepath.Join(maidnDir, "secret-access.yaml"), []byte(manifests), 0644)

View file

@ -2,7 +2,6 @@ package bootstrap
import ( import (
"bytes" "bytes"
"errors"
"io" "io"
"os" "os"
"os/exec" "os/exec"
@ -116,12 +115,6 @@ func TestGenerateAppSecretAccessRendersOnlyDeclaredRuntimeSecrets(t *testing.T)
dir := t.TempDir() dir := t.TempDir()
cfg := onboardingConfig() cfg := onboardingConfig()
cfg.SecretGrants = []config.SecretGrant{{Application: "web-ui", Consumer: "runtime", Environment: "staging", Secrets: []string{"api-key"}, Shared: []string{"payments"}}} 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 { if err := GenerateAppSecretAccess(dir, cfg); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@ -129,10 +122,6 @@ 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") { 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) 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) registration, err := renderAppRegistration(cfg)
if err != nil || !strings.Contains(string(registration), "name: web-ui-secrets") || !strings.Contains(string(registration), "path: ./.maidn") { 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) t.Fatalf("secret registration = %q, %v", registration, err)
@ -201,125 +190,3 @@ func TestPublishInitialAppBranchesCreatesAndPreservesProduction(t *testing.T) {
t.Fatalf("main = %s, want existing %s", got, sourceRevision) 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 { if err != nil {
return cfg, err return cfg, err
} }
owner, repository, err := deliveryRepositoryOwner(resolved.Delivery.AppRepoURL) owner, _, err := deliveryRepositoryOwner(resolved.Delivery.AppRepoURL)
if err != nil { if err != nil {
return cfg, err return cfg, err
} }
if owner != resolved.Git.Owner && (owner != "Maidn" || !strings.HasPrefix(repository, "maidn-e2e-")) { if owner != resolved.Git.Owner {
return cfg, errors.New("delivery appRepoUrl owner must match git owner or identify a canonical Maidn E2E fixture") return cfg, errors.New("delivery appRepoUrl owner must match git owner for app onboarding")
} }
if resolved.Delivery.ProductionBranch != "production" { if resolved.Delivery.ProductionBranch != "production" {
return cfg, errors.New("delivery productionBranch must be literal production for app onboarding") return cfg, errors.New("delivery productionBranch must be literal production for app onboarding")

View file

@ -99,26 +99,16 @@ func TestValidateDeliveryRejectsUnsafeTektonDashboardURL(t *testing.T) {
} }
} }
func TestResolveAppOnboardingAllowsOnlyCanonicalCrossOwnerSource(t *testing.T) { func TestResolveAppOnboardingRequiresTargetOwner(t *testing.T) {
cfg := validConfig(t) cfg := validConfig(t)
cfg.Git.Owner = "test-org-2" cfg.Git.Owner = "test-org-2"
cfg.Delivery.AppRepoURL = "https://git.example.test/Maidn/maidn-e2e-web.git" if _, err := ResolveAppOnboarding(cfg); err == nil || !strings.Contains(err.Error(), "owner must match") {
if _, err := ResolveAppOnboarding(cfg); err != nil { t.Fatalf("ResolveAppOnboarding() accepted a source-owner target: %v", err)
t.Fatalf("ResolveAppOnboarding() rejected canonical source: %v", err)
} }
cfg.Delivery.AppRepoURL = "https://git.example.test/test-org-2/web-ui.git" 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/" { if resolved, err := ResolveAppOnboarding(cfg); err != nil || resolved.Delivery.WebhookURL() != "https://tekton.example.test/" {
t.Fatalf("ResolveAppOnboarding() = %#v, %v", resolved.Delivery, err) 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) { func TestResolveAppOnboardingRequiresProductionBranch(t *testing.T) {

View file

@ -317,28 +317,6 @@ 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) { func TestEnsureProtectedBranchRejectsExistingDirectPushRule(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
if request.Method != http.MethodGet { if request.Method != http.MethodGet {