Merge pull request 'feat: onboard canonical E2E sources' (#55) from feat/canonical-e2e-onboarding into main
Reviewed-on: #55
This commit is contained in:
commit
8684ed9427
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue