feat: add platform app onboarding #30

Merged
eding merged 1 commit from feat/platform-app-onboarding into main 2026-09-06 20:26:06 +02:00
15 changed files with 726 additions and 236 deletions

View file

@ -23,15 +23,11 @@ var bootstrapInitializeOpenBao bool
var bootstrapCreateForgejoRegistryToken bool
var bootstrapRegisterWebhook bool
var bootstrapRotateWebhookAuthorization bool
var bootstrapPublishAppFrom string
var bootstrapMergeBootstrapPR bool
var bootstrapManageNetworkBridges bool
var bootstrapEnableDelivery bool
var upsertOperationalSecret = bootstrap.UpsertOperationalSecret
var loadPublishAppConfig = config.Load
var ensurePublishAppCheckoutClean = forgejo.EnsureCleanCheckout
var bootstrapCmd = &cobra.Command{
Use: "bootstrap",
Short: "Bootstrap Talos and Flux from config or an interactive wizard.",
@ -52,7 +48,6 @@ func init() {
bootstrapCmd.Flags().BoolVar(&bootstrapCreateForgejoRegistryToken, "create-forgejo-registry-token", false, "Create a least-privilege Forgejo package registry token and seed it through OpenBao")
bootstrapCmd.Flags().BoolVar(&bootstrapRegisterWebhook, "register-webhook", false, "Seed OpenBao secrets and register the Forgejo webhook")
bootstrapCmd.Flags().BoolVar(&bootstrapRotateWebhookAuthorization, "rotate-webhook-authorization", false, "Replace the Forgejo webhook authorization and reconcile it through OpenBao")
bootstrapCmd.Flags().StringVar(&bootstrapPublishAppFrom, "publish-app-from", "", "Push this app checkout's current branch and create a Forgejo delivery PR")
bootstrapCmd.Flags().BoolVar(&bootstrapMergeBootstrapPR, "merge-bootstrap-pr", false, "Merge the generated Flux repository migration PR before bootstrapping")
bootstrapCmd.Flags().BoolVar(&bootstrapManageNetworkBridges, "manage-network-bridges", false, "Persist Terraform management for existing Talos network bridges")
bootstrapCmd.Flags().BoolVar(&bootstrapEnableDelivery, "enable-delivery", false, "Resolve delivery defaults and reconcile the configured app delivery source")
@ -128,57 +123,6 @@ func runBootstrap(cmd *cobra.Command, args []string) error {
return err
}
}
if bootstrapPublishAppFrom != "" {
if bootstrapConfigPath == "" {
return fmt.Errorf("--publish-app-from requires --config")
}
cfg, err = loadPublishAppConfig(bootstrapConfigPath)
if err != nil {
return err
}
cfg, err = config.ResolveDelivery(cfg)
if err != nil {
return err
}
if err := ensurePublishAppCheckoutClean(bootstrapPublishAppFrom); err != nil {
return err
}
branch, err := forgejo.CurrentBranch(bootstrapPublishAppFrom)
if err != nil {
return err
}
deliveryBranch, err := forgejo.DeliveryBranch(cfg.Delivery.AppName, cfg.Delivery.AppRepoRef)
if err != nil {
return err
}
if err := bootstrap.EnsureTemplateRevisions(cfg); err != nil {
return err
}
owner, repo, err := forgejo.RepositoryFromURL(cfg.Delivery.AppRepoURL)
if err != nil {
return err
}
manager := forgejo.NewRepoManager(cfg.Git.BaseURL, cfg.Git.Token, owner, cfg.Git.Username, "", "", cfg.Delivery.AppRepoRef, "")
created, err := manager.EnsureRepository(repo, "Application source for Maidn CI/CD delivery")
if err != nil {
return err
}
if created && branch != cfg.Delivery.AppRepoRef {
return fmt.Errorf("new application repository requires the checkout branch to match delivery appRepoRef")
}
if err := manager.EnsureProtectedBranch(repo, cfg.Delivery.ProductionBranch); err != nil {
return fmt.Errorf("protect Forgejo production branch: %w", err)
}
if err := manager.PushRef(bootstrapPublishAppFrom, cfg.Delivery.AppRepoURL, branch, branch); err != nil {
return err
}
if err := manager.PublishDeliveryBranch(bootstrapPublishAppFrom, branch, cfg.Delivery.AppRepoURL, deliveryBranch, func(dir string) error {
return bootstrap.GenerateAppDelivery(dir, cfg)
}); err != nil {
return err
}
return manager.CreatePullRequest(repo, "feat: migrate delivery to Tekton", deliveryBranch, cfg.Delivery.AppRepoRef)
}
if bootstrapConfigPath != "" {
if bootstrapPromptDemocraticCSI || bootstrapPromptOperationalSecrets || bootstrapInitializeOpenBaoRecovery || bootstrapManageNetworkBridges {
cfg, err = config.LoadRaw(bootstrapConfigPath)

View file

@ -21,31 +21,6 @@ func TestCreateForgejoRegistryTokenRequiresConfig(t *testing.T) {
}
}
func TestPublishAppRequiresDeliveryConfigBeforeCheckout(t *testing.T) {
originalConfigPath, originalPublish := bootstrapConfigPath, bootstrapPublishAppFrom
originalLoad, originalClean := loadPublishAppConfig, ensurePublishAppCheckoutClean
t.Cleanup(func() {
bootstrapConfigPath = originalConfigPath
bootstrapPublishAppFrom = originalPublish
loadPublishAppConfig = originalLoad
ensurePublishAppCheckoutClean = originalClean
})
bootstrapConfigPath = "test-config.yaml"
bootstrapPublishAppFrom = "app-checkout"
loadPublishAppConfig = func(string) (config.Config, error) {
return config.Config{Git: config.GitConfig{BaseURL: "https://git.example.test"}, Delivery: config.DeliveryConfig{AppName: "legacy-app"}}, nil
}
ensurePublishAppCheckoutClean = func(string) error {
t.Fatal("publish inspected the checkout before validating delivery config")
return nil
}
err := runBootstrap(nil, nil)
if err == nil || !strings.Contains(err.Error(), "delivery appName") {
t.Fatalf("runBootstrap() error = %v, want incomplete delivery error", err)
}
}
func TestSeedForgejoOperationalCredentialsUsesEncryptedUpsertBoundary(t *testing.T) {
original := upsertOperationalSecret
t.Cleanup(func() { upsertOperationalSecret = original })

View file

@ -5,7 +5,6 @@ import (
"github.com/Pingu-Studio/MaidnCLI/internal/bootstrap"
"github.com/Pingu-Studio/MaidnCLI/internal/config"
"github.com/Pingu-Studio/MaidnCLI/internal/forgejo"
"github.com/spf13/cobra"
)
@ -17,10 +16,7 @@ var loadFreshConfig = config.Load
var loadAppOnboardConfig = config.LoadRaw
var runFreshOrganization = bootstrap.RunFreshOrganization
var resolveAppOnboarding = config.ResolveAppOnboarding
var ensureOnboardCheckoutClean = forgejo.EnsureCleanCheckout
var onboardCheckoutOrigin = forgejo.CheckoutOrigin
var onboardCheckoutBranch = forgejo.CurrentBranch
var generateAppDelivery = bootstrap.GenerateAppDelivery
var onboardApp = bootstrap.OnboardApp
var bootstrapInitCmd = &cobra.Command{
Use: "init",
@ -44,7 +40,7 @@ func init() {
bootstrapInitCmd.Flags().StringVar(&freshConfigPath, "config", "", "Path to private bootstrap config YAML")
bootstrapInitCmd.Flags().StringVar(&freshOrganization, "organization", "", "New Forgejo organization name")
bootstrapInitCmd.Flags().BoolVar(&freshCreateOrganization, "create-organization", false, "Create the Forgejo organization when absent")
bootstrapInitCmd.Flags().BoolVar(&freshEnableDelivery, "enable-delivery", false, "Initialize delivery repository prerequisites")
bootstrapInitCmd.Flags().BoolVar(&freshEnableDelivery, "enable-delivery", false, "Deprecated: init always initializes the shared delivery platform")
bootstrapInitCmd.Flags().StringVar(&freshMode, "mode", string(bootstrap.Reconcile), "Lifecycle mode: reconcile or rebuild")
bootstrapInitCmd.Flags().BoolVar(&freshYes, "yes", false, "Confirm destructive rebuild")
_ = bootstrapInitCmd.MarkFlagRequired("config")
@ -82,22 +78,5 @@ func runAppOnboard(_ *cobra.Command, _ []string) error {
if err != nil {
return err
}
if err := ensureOnboardCheckoutClean(onboardFrom); err != nil {
return err
}
origin, err := onboardCheckoutOrigin(onboardFrom)
if err != nil {
return err
}
if config.RedactURL(origin) != cfg.Delivery.AppRepoURL {
return fmt.Errorf("--from origin does not match delivery appRepoUrl")
}
branch, err := onboardCheckoutBranch(onboardFrom)
if err != nil {
return err
}
if branch != cfg.Delivery.AppRepoRef {
return fmt.Errorf("--from branch must match delivery appRepoRef")
}
return generateAppDelivery(onboardFrom, cfg)
return onboardApp(cfg, onboardFrom)
}

View file

@ -62,19 +62,17 @@ func TestBootstrapInitAppliesFluxDefaultsBeforeFreshValidation(t *testing.T) {
}
}
func TestAppOnboardValidatesConfigBeforeInspectingCheckout(t *testing.T) {
originalConfig, originalResolve, originalClean := loadAppOnboardConfig, resolveAppOnboarding, ensureOnboardCheckoutClean
func TestAppOnboardValidatesConfigBeforeExternalWork(t *testing.T) {
originalConfig, originalResolve, originalOnboard := loadAppOnboardConfig, resolveAppOnboarding, onboardApp
originalConfigPath, originalFrom := onboardConfigPath, onboardFrom
t.Cleanup(func() {
loadAppOnboardConfig = originalConfig
resolveAppOnboarding = originalResolve
ensureOnboardCheckoutClean = originalClean
loadAppOnboardConfig, resolveAppOnboarding, onboardApp = originalConfig, originalResolve, originalOnboard
onboardConfigPath, onboardFrom = originalConfigPath, originalFrom
})
loadAppOnboardConfig = func(string) (config.Config, error) { return config.Config{}, nil }
resolveAppOnboarding = func(config.Config) (config.Config, error) { return config.Config{}, errors.New("incomplete delivery") }
ensureOnboardCheckoutClean = func(string) error {
t.Fatal("onboarding inspected checkout before validating config")
onboardApp = func(config.Config, string) error {
t.Fatal("onboarding reached external work before validating config")
return nil
}
onboardConfigPath, onboardFrom = "private.yaml", "app-checkout"
@ -83,36 +81,26 @@ func TestAppOnboardValidatesConfigBeforeInspectingCheckout(t *testing.T) {
}
}
func TestAppOnboardScaffoldsOnlyTheValidatedCheckout(t *testing.T) {
originalConfig, originalResolve, originalClean := loadAppOnboardConfig, resolveAppOnboarding, ensureOnboardCheckoutClean
originalOrigin, originalBranch, originalGenerate := onboardCheckoutOrigin, onboardCheckoutBranch, generateAppDelivery
func TestAppOnboardPassesOnlyValidatedConfigAndCheckout(t *testing.T) {
originalConfig, originalResolve, originalOnboard := loadAppOnboardConfig, resolveAppOnboarding, onboardApp
originalConfigPath, originalFrom := onboardConfigPath, onboardFrom
t.Cleanup(func() {
loadAppOnboardConfig, resolveAppOnboarding, ensureOnboardCheckoutClean = originalConfig, originalResolve, originalClean
onboardCheckoutOrigin, onboardCheckoutBranch, generateAppDelivery = originalOrigin, originalBranch, originalGenerate
loadAppOnboardConfig, resolveAppOnboarding, onboardApp = originalConfig, originalResolve, originalOnboard
onboardConfigPath, onboardFrom = originalConfigPath, originalFrom
})
cfg := config.Config{Delivery: config.DeliveryConfig{AppRepoURL: "https://git.example.test/new-org/app.git", AppRepoRef: "main"}}
loadAppOnboardConfig = func(string) (config.Config, error) { return cfg, nil }
resolveAppOnboarding = func(config.Config) (config.Config, error) { return cfg, nil }
ensureOnboardCheckoutClean = func(path string) error {
if path != "app-checkout" {
t.Fatal("onboarding checked the wrong checkout")
calls := 0
onboardApp = func(got config.Config, checkout string) error {
if checkout != "app-checkout" || got.Delivery.AppRepoURL != cfg.Delivery.AppRepoURL {
t.Fatal("onboarding used the wrong checkout or config")
}
return nil
}
onboardCheckoutOrigin = func(string) (string, error) { return cfg.Delivery.AppRepoURL, nil }
onboardCheckoutBranch = func(string) (string, error) { return cfg.Delivery.AppRepoRef, nil }
generations := 0
generateAppDelivery = func(path string, got config.Config) error {
if path != "app-checkout" || got.Delivery.AppRepoURL != cfg.Delivery.AppRepoURL {
t.Fatal("onboarding generated delivery for the wrong checkout or config")
}
generations++
calls++
return nil
}
onboardConfigPath, onboardFrom = "private.yaml", "app-checkout"
if err := runAppOnboard(nil, nil); err != nil || generations != 1 {
t.Fatalf("runAppOnboard() = %v, generations = %d", err, generations)
if err := runAppOnboard(nil, nil); err != nil || calls != 1 {
t.Fatalf("runAppOnboard() = %v, calls = %d", err, calls)
}
}

View file

@ -23,6 +23,37 @@ new template revisions intentionally, use a new empty `workspaceDir` (and a
fresh `cloneParent` when it is configured separately) and keep the prior
secret-bearing workspace intact for recovery.
## Platform Initialization And App Onboarding
Initialize the shared delivery platform before onboarding any application. This
creates the Gateway, Tekton, and Tekton Triggers platform resources using
`tekton.<cluster-domain>` and the configured Forgejo owner; it does not render
an application Pipeline or register an application webhook:
```powershell
bootstrap init --config <private-bootstrap-config> --organization <owner> --create-organization
```
Import a clean checkout into the configured owner with a separate command. The
checkout may originate in another Forgejo organization, but `delivery.appRepoUrl`
must target `<owner>/<app>.git` and its current branch must be
`delivery.appRepoRef`:
```powershell
app onboard --config <private-bootstrap-config> --from <clean-checkout>
```
Onboarding copies the current branch to the target repository, replaces only
the two Maidn-generated `.tekton` files, protects the production branch, and
auto-merges the app delivery and cluster registration PRs. The registration is
stored in `base/tekton/apps/<app>.yaml` in the configured cluster repository;
the generic EventListener dispatches by the Forgejo repository name.
`delivery.productionBranch` is the application production branch. A production
delivery PR targets the configured generic
`<owner>/<manifests-repo>` repository semantics (for example,
`test-org-2/<manifests-repo>`), never through a source checkout's former owner.
## Rebuild
Use only when an authorized recovery requires recreating the Talos VM:

View file

@ -156,7 +156,6 @@ func (r Runner) Run() error {
}
r.Config = resolvedDelivery
}
deliveryConfigured := r.Config.Delivery.Configured()
if err := preflight(r.Config); err != nil {
return fmt.Errorf("preflight: %w", err)
}
@ -190,11 +189,9 @@ func (r Runner) Run() error {
if err := EnsureTemplateRevisions(r.Config); err != nil {
return err
}
if !r.SkipDeliveryScaffolding {
catalogManager := forgejo.NewRepoManager(r.Config.Git.BaseURL, r.Config.Git.Token, r.Config.Git.Owner, r.Config.Git.Username, "", "", r.Config.Templates.TektonCatalogRepoRef, "")
if _, err := catalogManager.EnsureRepositoryCopy(r.Config.Flux.TektonCatalogRepo, "User-managed Tekton pipeline catalog", r.Config.Templates.TektonCatalogRepoURL); err != nil {
return fmt.Errorf("initialize Tekton catalog repository: %w", err)
}
catalogManager := forgejo.NewRepoManager(r.Config.Git.BaseURL, r.Config.Git.Token, r.Config.Git.Owner, r.Config.Git.Username, "", "", r.Config.Templates.TektonCatalogRepoRef, "")
if _, err := catalogManager.EnsureRepositoryCopy(r.Config.Flux.TektonCatalogRepo, "User-managed Tekton pipeline catalog", r.Config.Templates.TektonCatalogRepoURL); err != nil {
return fmt.Errorf("initialize Tekton catalog repository: %w", err)
}
if err := config.WriteRedacted(filepath.Join(workspace, "maidn-bootstrap.resolved.yaml"), r.Config); err != nil {
return err
@ -213,23 +210,16 @@ func (r Runner) Run() error {
},
func(dir string) error {
clusterDir := filepath.Join(dir, strings.TrimPrefix(r.Config.Flux.ClusterPath, "./"))
if deliveryConfigured {
if err := copyDir(filepath.Join(cicdTemplateDir, "base"), filepath.Join(dir, "base"), false); err != nil {
return err
}
} else if err := copyDirExcept(filepath.Join(cicdTemplateDir, "base"), filepath.Join(dir, "base"), false, deliveryTemplateBaseComponents); err != nil {
if err := copyDir(filepath.Join(cicdTemplateDir, "base"), filepath.Join(dir, "base"), false); err != nil {
return err
}
if err := copyTemplateBaseComponents(cicdTemplateDir, dir, deliveryConfigured); err != nil {
if err := copyTemplateBaseComponents(cicdTemplateDir, dir, true); err != nil {
return err
}
if err := copyClusterTemplate(filepath.Join(cicdTemplateDir, "clusters", "template"), clusterDir); err != nil {
return err
}
for _, name := range []string{"external-secrets", "cnpg", "cloudflare-tunnel", "external-dns", "monitoring", "tekton"} {
if !deliveryConfigured && name == "tekton" {
continue
}
content, err := os.ReadFile(filepath.Join(cicdTemplateDir, "clusters", "template", name+"-kustomization.yaml"))
if err != nil {
return err
@ -248,10 +238,8 @@ func (r Runner) Run() error {
if err := copyAndRenderCiliumBases(cicdTemplateDir, dir, r.Config); err != nil {
return err
}
if deliveryConfigured {
if err := copyAndRenderDeliveryBases(cicdTemplateDir, dir, r.Config); err != nil {
return err
}
if err := copyAndRenderPlatformDeliveryBases(cicdTemplateDir, dir, r.Config); err != nil {
return err
}
if err := writeDemocraticCSISecret(filepath.Join(dir, "base", "democratic-csi", "secret.sops.yaml"), r.Config.DemocraticCSI, r.Config.SOPS.AgeKeyPath); err != nil {
return err
@ -263,7 +251,7 @@ func (r Runner) Run() error {
if err := ensureOpenBaoUnsealKustomization(filepath.Join(openbaoDir, "kustomization.yaml")); err != nil {
return err
}
if err := ensureClusterKustomizations(clusterDir, deliveryConfigured); err != nil {
if err := ensureClusterKustomizations(clusterDir, true); err != nil {
return err
}
return ghrepo.WriteFluxStructure(dir, r.Config.Flux.RepoName, r.Config.Flux.ClusterPath, fluxConfig)
@ -327,7 +315,7 @@ func (r Runner) Run() error {
if err := configureFluxSOPS(generatedDir); err != nil {
return err
}
return r.reconcileWebhook(generatedDir)
return r.completeFluxBootstrap(generatedDir)
}
return nil
}
@ -386,7 +374,7 @@ func (r Runner) reconcileCloudflareTunnel() error {
}
func (r Runner) reconcileWebhook(generatedDir string) error {
operationalSecrets, err := initializeOpenBao(filepath.Join(generatedDir, "kubeconfig"), r.Config.SOPS.RecoveryRecipient, r.Config.SOPS.RecoveryIdentityPath, r.Config.SOPS.RecoveryBundlePath, r.Config.SOPS.AgeKeyPath, r.Config.SOPS.OperationalSecretsPath)
operationalSecrets, err := r.initializeOpenBaoForCluster(generatedDir)
if err != nil {
return fmt.Errorf("initialize OpenBao: %w", err)
}
@ -403,6 +391,18 @@ func (r Runner) reconcileWebhook(generatedDir string) error {
return nil
}
func (r Runner) initializeOpenBaoForCluster(generatedDir string) (map[string]map[string]string, error) {
return initializeOpenBao(filepath.Join(generatedDir, "kubeconfig"), r.Config.SOPS.RecoveryRecipient, r.Config.SOPS.RecoveryIdentityPath, r.Config.SOPS.RecoveryBundlePath, r.Config.SOPS.AgeKeyPath, r.Config.SOPS.OperationalSecretsPath)
}
// completeFluxBootstrap runs the post-Flux platform initialization only.
func (r Runner) completeFluxBootstrap(generatedDir string) error {
if _, err := r.initializeOpenBaoForCluster(generatedDir); err != nil {
return fmt.Errorf("initialize OpenBao: %w", err)
}
return nil
}
func renderCiliumConfig(dir string, cfg config.Config) error {
replacements := strings.NewReplacer(
"${CILIUM_K8S_SERVICE_HOST}", cfg.Talos.KubeconfigEndpoint,
@ -422,19 +422,16 @@ func renderCiliumConfig(dir string, cfg config.Config) error {
})
}
func renderDeliveryConfig(dir string, cfg config.Config) error {
func renderPlatformDeliveryConfig(dir string, cfg config.Config) error {
replacements := strings.NewReplacer(
"${APP_NAME}", cfg.Delivery.AppName,
"${APP_REPO_URL}", cfg.Delivery.AppRepoURL,
"${APP_REPO_REF}", cfg.Delivery.AppRepoRef,
"${PRODUCTION_BRANCH}", cfg.Delivery.ProductionBranch,
"${IMAGE_REPOSITORY}", cfg.Delivery.ImageRepository,
"${FORGEJO_BASE_URL}", cfg.Git.BaseURL,
"${CLUSTER_DOMAIN}", cfg.Flux.ClusterDomain,
"${GIT_OWNER}", cfg.Git.Owner,
"${FORGEJO_OWNER}", cfg.Git.Owner,
"${TEKTON_CATALOG_REPO_URL}", forgejo.CloneURL(cfg.Git.BaseURL, cfg.Git.Owner, cfg.Flux.TektonCatalogRepo),
"${TEKTON_CATALOG_REPO_REF}", cfg.Templates.TektonCatalogRepoRef,
"${WEBHOOK_HOSTNAME}", cfg.Delivery.WebhookHostname,
"${WEBHOOK_PATH}", cfg.Delivery.WebhookPath,
"${WEBHOOK_HOSTNAME}", "tekton."+cfg.Flux.ClusterDomain,
"${WEBHOOK_PATH}", "/",
)
return filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
@ -448,10 +445,7 @@ func renderDeliveryConfig(dir string, cfg config.Config) error {
})
}
func copyAndRenderDeliveryBases(templateDir, repoDir string, cfg config.Config) error {
if err := config.ValidateDelivery(cfg); err != nil {
return err
}
func copyAndRenderPlatformDeliveryBases(templateDir, repoDir string, cfg config.Config) error {
bases := []string{"gateway", "tekton", "tekton-triggers"}
for _, base := range bases {
baseDir := filepath.Join(repoDir, "base", base)
@ -461,15 +455,11 @@ func copyAndRenderDeliveryBases(templateDir, repoDir string, cfg config.Config)
}
for _, base := range bases {
baseDir := filepath.Join(repoDir, "base", base)
if err := renderDeliveryConfig(baseDir, cfg); err != nil {
if err := renderPlatformDeliveryConfig(baseDir, cfg); err != nil {
return err
}
}
dir := filepath.Join(repoDir, "base", "tekton")
if err := writePreviewDeliveryConfig(dir, cfg); err != nil {
return err
}
return removeDuplicateAppDeliverySource(dir, cfg.Delivery.AppName)
return writePreviewDeliveryConfig(filepath.Join(repoDir, "base", "tekton"), cfg)
}
func writePreviewDeliveryConfig(dir string, cfg config.Config) error {
@ -585,10 +575,14 @@ func GenerateAppDelivery(dir string, cfg config.Config) error {
if len(entries) != len(files) {
return errors.New("app delivery .tekton contains unmanaged files")
}
for name, want := range files {
got, err := os.ReadFile(filepath.Join(target, name))
if err != nil || !bytes.Equal(got, want) {
return errors.New("app delivery .tekton differs from Maidn generated content")
for name, content := range files {
file := filepath.Join(target, name)
fileInfo, err := os.Lstat(file)
if err != nil || fileInfo.Mode()&os.ModeSymlink != 0 || !fileInfo.Mode().IsRegular() {
return errors.New("app delivery .tekton contains unmanaged files")
}
if err := os.WriteFile(file, content, 0644); err != nil {
return err
}
}
return nil
@ -1031,7 +1025,7 @@ func waitForWebhookTargets(dir string, cfg config.Config, authorization string)
if err := waitForWebhookAuthorization(dir, authorization); err != nil {
return err
}
resources := []string{"deployment/el-" + cfg.Delivery.AppName, "pipeline/" + cfg.Delivery.AppName}
resources := []string{"pipeline/" + cfg.Delivery.AppName}
for _, resource := range resources {
deadline := time.Now().Add(webhookTargetTimeout)
for time.Now().Before(deadline) {
@ -1078,9 +1072,6 @@ func ensureClusterKustomizations(clusterDir string, includeDelivery bool) error
updated := string(content)
updated = strings.ReplaceAll(updated, " - bootstrap-secrets.sops.yaml\n", "")
for _, resource := range requiredClusterKustomizations {
if !includeDelivery && (resource == "gateway-kustomization.yaml" || resource == "tekton-kustomization.yaml" || resource == "tekton-triggers-kustomization.yaml") {
continue
}
if !strings.Contains(updated, resource) {
updated += " - " + resource + "\n"
}
@ -1093,9 +1084,6 @@ func ensureClusterKustomizations(clusterDir string, includeDelivery bool) error
func copyTemplateBaseComponents(templateDir, repoDir string, includeDelivery bool) error {
for _, component := range templateBaseComponents {
if !includeDelivery && deliveryTemplateBaseComponents[component] {
continue
}
if err := copyDirExcept(filepath.Join(templateDir, "base", component), filepath.Join(repoDir, "base", component), true, generatedTemplateFiles[component]); err != nil {
return err
}

View file

@ -52,22 +52,22 @@ func TestCiliumChartVersion(t *testing.T) {
}
}
func TestRenderDeliveryConfig(t *testing.T) {
func TestRenderPlatformDeliveryConfig(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "webhook.yaml")
if err := os.WriteFile(path, []byte("host: ${WEBHOOK_HOSTNAME}\npath: ${WEBHOOK_PATH}\nproduction: ${PRODUCTION_BRANCH}\ncatalog: ${TEKTON_CATALOG_REPO_URL}\nref: ${TEKTON_CATALOG_REPO_REF}\n"), 0644); err != nil {
if err := os.WriteFile(path, []byte("host: ${WEBHOOK_HOSTNAME}\npath: ${WEBHOOK_PATH}\nowner: ${GIT_OWNER}\ncatalog: ${TEKTON_CATALOG_REPO_URL}\nref: ${TEKTON_CATALOG_REPO_REF}\n"), 0644); err != nil {
t.Fatal(err)
}
cfg := config.Config{Git: config.GitConfig{BaseURL: "https://git.example.test", Owner: "user-org"}, Flux: config.FluxConfig{TektonCatalogRepo: "my-tekton-catalog"}, Delivery: config.DeliveryConfig{ProductionBranch: "production", WebhookHostname: "tekton.example.test", WebhookPath: "/hooks/forgejo"}, Templates: config.TemplateConfig{TektonCatalogRepoRef: "release"}}
if err := renderDeliveryConfig(dir, cfg); err != nil {
cfg := config.Config{Git: config.GitConfig{BaseURL: "https://git.example.test", Owner: "user-org"}, Flux: config.FluxConfig{ClusterDomain: "example.test", TektonCatalogRepo: "my-tekton-catalog"}, Templates: config.TemplateConfig{TektonCatalogRepoRef: "release"}}
if err := renderPlatformDeliveryConfig(dir, cfg); err != nil {
t.Fatal(err)
}
content, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(content), "${") || !strings.Contains(string(content), "/hooks/forgejo") || !strings.Contains(string(content), "production: production") || !strings.Contains(string(content), "https://git.example.test/user-org/my-tekton-catalog.git") || !strings.Contains(string(content), "ref: release") {
t.Fatalf("delivery configuration was not rendered: %s", content)
if strings.Contains(string(content), "${") || !strings.Contains(string(content), "host: tekton.example.test") || !strings.Contains(string(content), "owner: user-org") || !strings.Contains(string(content), "https://git.example.test/user-org/my-tekton-catalog.git") || !strings.Contains(string(content), "ref: release") {
t.Fatalf("platform delivery configuration was not rendered: %s", content)
}
}
@ -155,15 +155,15 @@ func TestWritePreviewDeliveryConfigRejectsAmbiguousKustomization(t *testing.T) {
}
}
func TestCopyAndRenderDeliveryBasesOverwritesExistingMigrationOutput(t *testing.T) {
func TestCopyAndRenderPlatformDeliveryBasesOverwritesExistingMigrationOutput(t *testing.T) {
templateDir := t.TempDir()
repoDir := t.TempDir()
files := []struct {
base, name, template, want string
}{
{"gateway", "route.yaml", "host: ${WEBHOOK_HOSTNAME}\npath: ${WEBHOOK_PATH}\n", "host: tekton.example.test\npath: /hooks/forgejo\n"},
{"gateway", "route.yaml", "host: ${WEBHOOK_HOSTNAME}\npath: ${WEBHOOK_PATH}\n", "host: tekton.example.test\npath: /\n"},
{"tekton", "catalog-source.yaml", "url: ${TEKTON_CATALOG_REPO_URL}\nref: ${TEKTON_CATALOG_REPO_REF}\n", "url: https://git.example.test/user-org/my-tekton-catalog.git\nref: release\n"},
{"tekton-triggers", "trigger.yaml", "app: ${APP_NAME}\nrepo: ${APP_REPO_URL}\n", "app: demo\nrepo: https://git.example.test/demo.git\n"},
{"tekton-triggers", "trigger.yaml", "owner: ${FORGEJO_OWNER}\nhost: ${WEBHOOK_HOSTNAME}\n", "owner: user-org\nhost: tekton.example.test\n"},
}
for _, file := range files {
templatePath := filepath.Join(templateDir, "base", file.base, file.name)
@ -191,11 +191,10 @@ func TestCopyAndRenderDeliveryBasesOverwritesExistingMigrationOutput(t *testing.
cfg := config.Config{
Git: config.GitConfig{BaseURL: "https://git.example.test", Owner: "user-org"},
Flux: config.FluxConfig{TektonCatalogRepo: "my-tekton-catalog", ManifestsRepo: "manifests", Branch: "main"},
Delivery: config.DeliveryConfig{AppName: "demo", AppRepoURL: "https://git.example.test/demo.git", AppRepoRef: "main", ProductionBranch: "production", ImageRepository: "registry.example.test/demo", BuildOutputDirectory: "dist", BuildConfiguration: "production", WebhookHostname: "tekton.example.test", WebhookPath: "/hooks/forgejo"},
Flux: config.FluxConfig{ClusterDomain: "example.test", TektonCatalogRepo: "my-tekton-catalog", ManifestsRepo: "manifests", Branch: "main"},
Templates: config.TemplateConfig{TektonCatalogRepoRef: "release"},
}
if err := copyAndRenderDeliveryBases(templateDir, repoDir, cfg); err != nil {
if err := copyAndRenderPlatformDeliveryBases(templateDir, repoDir, cfg); err != nil {
t.Fatal(err)
}
for _, file := range files {

View file

@ -50,9 +50,7 @@ func PlanFreshOrganization(cfg config.Config, options FreshOrganizationOptions)
"ensure the Forgejo organization",
"ensure baseline Flux and manifests repositories",
}
if options.EnableDelivery {
phases = append(phases, "initialize the user-managed Tekton catalog repository")
}
phases = append(phases, "initialize the user-managed Tekton catalog repository")
phases = append(phases, fmt.Sprintf("%s the CI/CD cluster", mode))
return resolved, FreshOrganizationPlan{Phases: phases}, nil
}
@ -86,11 +84,10 @@ func freshLifecycleRunner(cfg config.Config, options FreshOrganizationOptions) R
mode = Reconcile
}
return Runner{
Config: cfg,
Mode: mode,
ConfirmRebuild: options.ConfirmRebuild,
EnableDelivery: options.EnableDelivery,
SkipDeliveryScaffolding: !options.EnableDelivery,
Config: cfg,
Mode: mode,
ConfirmRebuild: options.ConfirmRebuild,
SkipDeliveryScaffolding: true,
AutoMergeBootstrapMigration: true,
}
}

View file

@ -172,10 +172,11 @@ func TestRunFreshOrganizationStopsBeforeLifecycleOnForgejoFailure(t *testing.T)
}
}
func TestFreshLifecycleUsesSelectedModeAndGatesDelivery(t *testing.T) {
func TestFreshLifecycleUsesSelectedModeWithoutAppDelivery(t *testing.T) {
originalPreflight := preflight
t.Cleanup(func() { preflight = originalPreflight })
cfg := runnerTestConfig(t.TempDir(), "")
cfg.Delivery = config.DeliveryConfig{}
for _, test := range []struct {
options FreshOrganizationOptions
mode Mode
@ -184,12 +185,12 @@ func TestFreshLifecycleUsesSelectedModeAndGatesDelivery(t *testing.T) {
{FreshOrganizationOptions{Mode: Rebuild, ConfirmRebuild: true, EnableDelivery: true}, Rebuild},
} {
runner := freshLifecycleRunner(cfg, test.options)
if runner.Mode != test.mode || runner.ConfirmRebuild != test.options.ConfirmRebuild || runner.EnableDelivery != test.options.EnableDelivery || runner.SkipDeliveryScaffolding == test.options.EnableDelivery || !runner.AutoMergeBootstrapMigration {
if runner.Mode != test.mode || runner.ConfirmRebuild != test.options.ConfirmRebuild || runner.EnableDelivery || !runner.SkipDeliveryScaffolding || !runner.AutoMergeBootstrapMigration {
t.Fatalf("fresh lifecycle runner = %#v", runner)
}
preflight = func(got config.Config) error {
if got.Delivery.Configured() != test.options.EnableDelivery {
t.Fatal("fresh lifecycle did not gate delivery scaffolding with --enable-delivery")
if got.Delivery.Configured() {
t.Fatal("fresh lifecycle resolved app delivery during platform initialization")
}
return errors.New("stop")
}
@ -199,6 +200,32 @@ func TestFreshLifecycleUsesSelectedModeAndGatesDelivery(t *testing.T) {
}
}
func TestFreshPlatformRunnerInitializesOpenBaoWithoutRegisteringWebhookAfterFluxSetup(t *testing.T) {
originalInitialize, originalWebhook := initializeOpenBao, ensureForgejoWebhook
t.Cleanup(func() {
initializeOpenBao, ensureForgejoWebhook = originalInitialize, originalWebhook
})
runner := freshLifecycleRunner(runnerTestConfig(t.TempDir(), "age-key"), FreshOrganizationOptions{})
if !runner.SkipDeliveryScaffolding || runner.RegisterWebhook {
t.Fatalf("fresh platform runner = %#v", runner)
}
initialized := false
initializeOpenBao = func(kubeconfig, recipient, identityPath, bundlePath, ageKeyPath, operationalSecretsPath string) (map[string]map[string]string, error) {
if kubeconfig != filepath.Join("after-flux", "kubeconfig") || ageKeyPath != "age-key" {
t.Fatal("platform OpenBao initialization used unexpected paths")
}
initialized = true
return nil, nil
}
ensureForgejoWebhook = func(config.Config, string, string, string) error {
t.Fatal("platform initialization registered an application webhook")
return nil
}
if err := runner.completeFluxBootstrap("after-flux"); err != nil || !initialized {
t.Fatalf("post-Flux platform initialization = %v, initialized = %t", err, initialized)
}
}
func TestPlanFreshOrganizationRejectsUnrecognizedWorkspaceState(t *testing.T) {
cfg := freshPlanConfig(t)
prepareResumableFreshWorkspace(t, &cfg)

View file

@ -0,0 +1,264 @@
package bootstrap
import (
"bytes"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"github.com/Pingu-Studio/MaidnCLI/internal/config"
"github.com/Pingu-Studio/MaidnCLI/internal/forgejo"
"gopkg.in/yaml.v3"
)
// OnboardApp imports one clean checkout into the configured 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)
if err != nil {
return err
}
if err := forgejo.EnsureCleanCheckout(sourceDir); err != nil {
return err
}
branch, err := forgejo.CurrentBranch(sourceDir)
if err != nil {
return err
}
if branch != resolved.Delivery.AppRepoRef {
return errors.New("--from branch must match delivery appRepoRef")
}
owner, repository, err := forgejo.RepositoryFromURL(resolved.Delivery.AppRepoURL)
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 {
return err
}
if err := manager.EnsureProtectedBranch(repository, resolved.Delivery.ProductionBranch); err != nil {
return fmt.Errorf("protect Forgejo production branch: %w", err)
}
if err := manager.PushRef(sourceDir, resolved.Delivery.AppRepoURL, branch, branch); err != nil {
return err
}
changed, err := manager.PublishDeliveryBranch(sourceDir, branch, resolved.Delivery.AppRepoURL, deliveryBranch, func(dir string) error {
return GenerateAppDelivery(dir, resolved)
})
if err != nil {
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 {
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 {
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)
if err != nil {
return fmt.Errorf("read encrypted webhook authorization: %w", err)
}
authorization := secrets["cicd/forgejo-webhook"]["authorization"]
if authorization == "" {
return errors.New("operational SOPS secrets requires cicd/forgejo-webhook.authorization")
}
generatedDir := filepath.Join(resolved.Git.CloneParent, resolved.Talos.RepoDirName, resolved.Talos.GeneratedDir)
if err := waitForWebhookTargets(generatedDir, resolved, authorization); err != nil {
return err
}
webhookURL := "https://tekton." + resolved.Flux.ClusterDomain + "/"
if err := manager.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 {
return fmt.Errorf("trigger Forgejo webhook test: %w", err)
}
return nil
}
// RegisterAppInCluster writes only the managed Flux registration for one app.
func RegisterAppInCluster(dir string, cfg config.Config) error {
content, err := renderAppRegistration(cfg)
if err != nil {
return err
}
tektonDir := filepath.Join(dir, "base", "tekton")
rootPath := filepath.Join(tektonDir, "kustomization.yaml")
root, err := readRegularFile(rootPath)
if err != nil {
return fmt.Errorf("read Tekton Kustomization: %w", err)
}
updatedRoot, err := addKustomizationResource(root, "apps")
if err != nil {
return fmt.Errorf("Tekton Kustomization: %w", err)
}
appsDir := filepath.Join(tektonDir, "apps")
if info, statErr := os.Lstat(appsDir); statErr == nil && (info.Mode()&os.ModeSymlink != 0 || !info.IsDir()) {
return errors.New("Tekton apps path must be a directory")
} else if statErr != nil && !os.IsNotExist(statErr) {
return statErr
}
appsPath := filepath.Join(appsDir, "kustomization.yaml")
apps, err := os.ReadFile(appsPath)
if err != nil && !os.IsNotExist(err) {
return err
}
if err == nil {
if info, statErr := os.Lstat(appsPath); statErr != nil || info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
return errors.New("Tekton apps Kustomization must be a regular file")
}
} else {
apps = []byte("apiVersion: kustomize.config.k8s.io/v1beta1\nkind: Kustomization\nresources:\n")
}
updatedApps, err := addKustomizationResource(apps, cfg.Delivery.AppName+".yaml")
if err != nil {
return fmt.Errorf("Tekton apps Kustomization: %w", err)
}
registrationPath := filepath.Join(appsDir, cfg.Delivery.AppName+".yaml")
if existing, readErr := os.ReadFile(registrationPath); readErr == nil {
info, statErr := os.Lstat(registrationPath)
if statErr != nil || info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() || !bytes.Equal(existing, content) {
return errors.New("Tekton app registration conflicts with unmanaged content")
}
} else if !os.IsNotExist(readErr) {
return readErr
}
if err := os.MkdirAll(appsDir, 0755); err != nil {
return err
}
if err := os.WriteFile(rootPath, updatedRoot, 0644); err != nil {
return err
}
if err := os.WriteFile(appsPath, updatedApps, 0644); err != nil {
return err
}
return os.WriteFile(registrationPath, content, 0644)
}
func renderAppRegistration(cfg config.Config) ([]byte, error) {
if err := config.ValidateDelivery(cfg); err != nil {
return nil, err
}
branch, err := forgejo.DeliveryBranch(cfg.Delivery.AppName, cfg.Delivery.AppRepoRef)
if err != nil {
return nil, err
}
return []byte(fmt.Sprintf(`apiVersion: source.toolkit.fluxcd.io/v1
kind: GitRepository
metadata:
name: %s
namespace: flux-system
spec:
interval: 1m
url: %s
secretRef:
name: forgejo-flux-credentials
ref:
branch: %s
---
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: %s
namespace: flux-system
spec:
interval: 5m
path: ./.tekton
prune: true
wait: true
targetNamespace: tekton-pipelines
dependsOn:
- name: tekton-catalog
sourceRef:
kind: GitRepository
name: %s
`, cfg.Delivery.AppName, cfg.Delivery.AppRepoURL, branch, cfg.Delivery.AppName, cfg.Delivery.AppName)), nil
}
func readRegularFile(path string) ([]byte, error) {
info, err := os.Lstat(path)
if err != nil {
return nil, err
}
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
return nil, errors.New("must be a regular file")
}
return os.ReadFile(path)
}
func addKustomizationResource(content []byte, resource string) ([]byte, error) {
decoder := yaml.NewDecoder(bytes.NewReader(content))
var document yaml.Node
if err := decoder.Decode(&document); err != nil {
return nil, err
}
if err := decoder.Decode(&yaml.Node{}); !errors.Is(err, io.EOF) {
return nil, errors.New("must contain one YAML document")
}
if document.Kind != yaml.DocumentNode || len(document.Content) != 1 || document.Content[0].Kind != yaml.MappingNode {
return nil, errors.New("must be a Kustomization mapping")
}
root := document.Content[0]
apiVersion, err := yamlMappingValue(root, "apiVersion")
if err != nil || apiVersion == nil || apiVersion.Value != "kustomize.config.k8s.io/v1beta1" {
return nil, errors.New("must be a Kustomization")
}
kind, err := yamlMappingValue(root, "kind")
if err != nil || kind == nil || kind.Value != "Kustomization" {
return nil, errors.New("must be a Kustomization")
}
resources, err := yamlMappingValue(root, "resources")
if err != nil || resources == nil {
return nil, errors.New("must define a resources list")
}
if resources.Kind == yaml.ScalarNode && resources.Tag == "!!null" {
resources.Kind, resources.Tag, resources.Value = yaml.SequenceNode, "!!seq", ""
}
if resources.Kind != yaml.SequenceNode {
return nil, errors.New("must define a resources list")
}
for _, item := range resources.Content {
if item.Kind != yaml.ScalarNode || item.Value == "" {
return nil, errors.New("resources must contain non-empty scalar values")
}
if item.Value == resource {
var rendered bytes.Buffer
encoder := yaml.NewEncoder(&rendered)
encoder.SetIndent(2)
if err := encoder.Encode(&document); err != nil {
return nil, err
}
return rendered.Bytes(), nil
}
}
resources.Content = append(resources.Content, &yaml.Node{Kind: yaml.ScalarNode, Value: resource})
var rendered bytes.Buffer
encoder := yaml.NewEncoder(&rendered)
encoder.SetIndent(2)
if err := encoder.Encode(&document); err != nil {
return nil, err
}
return rendered.Bytes(), nil
}

View file

@ -0,0 +1,78 @@
package bootstrap
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/Pingu-Studio/MaidnCLI/internal/config"
)
func onboardingConfig() config.Config {
return config.Config{
Git: config.GitConfig{BaseURL: "https://git.example.test", Owner: "test-org-2"},
Flux: config.FluxConfig{Branch: "main", RepoName: "cluster", ManifestsRepo: "manifests", ClusterDomain: "example.test"},
Delivery: config.DeliveryConfig{
AppName: "web-ui", AppRepoURL: "https://git.example.test/test-org-2/web-ui.git", AppRepoRef: "main", ProductionBranch: "production",
ImageRepository: "registry.example.test/test-org-2/web-ui", BuildOutputDirectory: "dist", BuildConfiguration: "production",
WebhookHostname: "tekton.example.test", WebhookPath: "/",
},
}
}
func TestGenerateAppDeliveryReplacesOnlyKnownGeneratedFiles(t *testing.T) {
dir := t.TempDir()
tektonDir := filepath.Join(dir, ".tekton")
if err := os.Mkdir(tektonDir, 0755); err != nil {
t.Fatal(err)
}
for _, name := range []string{"kustomization.yaml", "pipeline.yaml"} {
if err := os.WriteFile(filepath.Join(tektonDir, name), []byte("old generated content\n"), 0644); err != nil {
t.Fatal(err)
}
}
if err := GenerateAppDelivery(dir, onboardingConfig()); err != nil {
t.Fatal(err)
}
pipeline, err := os.ReadFile(filepath.Join(tektonDir, "pipeline.yaml"))
if err != nil || !strings.Contains(string(pipeline), "https://git.example.test/test-org-2/web-ui.git") {
t.Fatalf("target-specific pipeline = %q, %v", pipeline, err)
}
if err := os.WriteFile(filepath.Join(tektonDir, "custom.yaml"), []byte("custom: true\n"), 0644); err != nil {
t.Fatal(err)
}
if err := GenerateAppDelivery(dir, onboardingConfig()); err == nil || !strings.Contains(err.Error(), "unmanaged") {
t.Fatalf("custom .tekton content was accepted: %v", err)
}
}
func TestRegisterAppInClusterRendersManagedFluxSource(t *testing.T) {
dir := t.TempDir()
tektonDir := filepath.Join(dir, "base", "tekton")
if err := os.MkdirAll(tektonDir, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(tektonDir, "kustomization.yaml"), []byte("apiVersion: kustomize.config.k8s.io/v1beta1\nkind: Kustomization\nresources:\n"), 0644); err != nil {
t.Fatal(err)
}
if err := RegisterAppInCluster(dir, onboardingConfig()); err != nil {
t.Fatal(err)
}
registration, err := os.ReadFile(filepath.Join(tektonDir, "apps", "web-ui.yaml"))
if err != nil || !strings.Contains(string(registration), "branch: maidn/delivery-web-ui") || !strings.Contains(string(registration), "secretRef:\n name: forgejo-flux-credentials") || !strings.Contains(string(registration), "dependsOn:\n - name: tekton-catalog") || !strings.Contains(string(registration), "path: ./.tekton") {
t.Fatalf("registration = %q, %v", registration, err)
}
for path, resource := range map[string]string{filepath.Join(tektonDir, "kustomization.yaml"): "apps", filepath.Join(tektonDir, "apps", "kustomization.yaml"): "web-ui.yaml"} {
content, err := os.ReadFile(path)
if err != nil || !strings.Contains(string(content), resource) {
t.Fatalf("Kustomization %s does not include %s: %q, %v", path, resource, content, err)
}
}
if err := os.WriteFile(filepath.Join(tektonDir, "apps", "web-ui.yaml"), []byte("custom: true\n"), 0644); err != nil {
t.Fatal(err)
}
if err := RegisterAppInCluster(dir, onboardingConfig()); err == nil || !strings.Contains(err.Error(), "conflicts") {
t.Fatalf("unmanaged app registration was accepted: %v", err)
}
}

View file

@ -148,25 +148,44 @@ func ResolveFreshBootstrap(cfg Config, organization string, enableDelivery bool)
return cfg, err
}
}
if enableDelivery {
var err error
cfg, err = ResolveDelivery(cfg)
if err != nil {
return cfg, err
}
}
return cfg, nil
}
// ResolveAppOnboarding validates only the source-owned delivery contract.
func ResolveAppOnboarding(cfg Config) (Config, error) {
if cfg.Git.Owner == "" || cfg.Flux.ManifestsRepo == "" || cfg.Flux.Branch == "" {
return cfg, errors.New("git owner and flux manifestsRepo and branch are required for app onboarding")
if cfg.Git.Owner == "" || cfg.Git.Username == "" || cfg.Git.Token == "" || cfg.Flux.RepoName == "" || cfg.Flux.ManifestsRepo == "" || cfg.Flux.Branch == "" || cfg.Flux.ClusterDomain == "" {
return cfg, errors.New("git owner, username, token, flux repoName, manifestsRepo, branch, and clusterDomain are required for app onboarding")
}
if err := validateForgejoOrigin(cfg.Git.BaseURL); err != nil {
return cfg, err
}
return ResolveDelivery(cfg)
resolved, err := ResolveDelivery(cfg)
if err != nil {
return cfg, err
}
owner, _, 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 resolved.Delivery.ProductionBranch != "production" {
return cfg, errors.New("delivery productionBranch must be literal production for app onboarding")
}
return resolved, nil
}
func deliveryRepositoryOwner(value string) (string, string, error) {
parsed, err := url.Parse(value)
if err != nil {
return "", "", errors.New("delivery appRepoUrl must identify one Forgejo owner/repository.git")
}
parts := strings.Split(strings.Trim(parsed.Path, "/"), "/")
if len(parts) != 2 || !strings.HasSuffix(parts[1], ".git") || !validRepositoryPart(parts[0]) || !validRepositoryPart(strings.TrimSuffix(parts[1], ".git")) {
return "", "", errors.New("delivery appRepoUrl must identify one Forgejo owner/repository.git")
}
return parts[0], strings.TrimSuffix(parts[1], ".git"), nil
}
func validRepositoryPart(value string) bool {

View file

@ -91,6 +91,26 @@ func TestValidateDeliveryRequiresCompleteConfig(t *testing.T) {
}
}
func TestResolveAppOnboardingRequiresTargetOwner(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/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)
}
}
func TestResolveAppOnboardingRequiresProductionBranch(t *testing.T) {
cfg := validConfig(t)
cfg.Delivery.ProductionBranch = "release"
if _, err := ResolveAppOnboarding(cfg); err == nil || !strings.Contains(err.Error(), "literal production") {
t.Fatalf("ResolveAppOnboarding() accepted nonstandard production branch: %v", err)
}
}
func TestValidateRejectsCredentialBearingDeliveryURLs(t *testing.T) {
cfg := validConfig(t)
cfg.Delivery.AppRepoURL = "https://reader:token@git.example.test/test-org/web-ui.git"

View file

@ -405,6 +405,51 @@ func (rm *RepoManager) CreatePullRequest(repo, title, head, base string) error {
return nil
}
// EnsurePullRequest creates one pull request or returns the one already open
// for the exact head branch. It refuses duplicate or otherwise ambiguous state.
func (rm *RepoManager) EnsurePullRequest(repo, title, head, base string) error {
if head == "" || base == "" || head == base {
return fmt.Errorf("Forgejo pull request head and base must be different non-empty branches")
}
open, err := rm.HasOpenPullRequest(repo, head)
if err != nil {
return err
}
if open {
return nil
}
body, err := json.Marshal(pullRequestRequest{Title: title, Head: head, Base: base})
if err != nil {
return err
}
status, err := rm.apiRequest(http.MethodPost, fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls", rm.BaseURL, rm.Owner, repo), body)
if err != nil {
return err
}
if status != http.StatusCreated {
return fmt.Errorf("unexpected Forgejo pull request status %d", status)
}
return nil
}
// HasOpenPullRequest reports whether exactly one pull request is open for head.
func (rm *RepoManager) HasOpenPullRequest(repo, head string) (bool, error) {
values := url.Values{"state": {"open"}, "head": {head}}
endpoint := fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls?%s", rm.BaseURL, rm.Owner, repo, values.Encode())
var pullRequests []pullRequest
status, err := rm.apiJSONRequest(http.MethodGet, endpoint, nil, &pullRequests)
if err != nil {
return false, err
}
if status != http.StatusOK {
return false, fmt.Errorf("unexpected Forgejo pull request lookup status %d", status)
}
if len(pullRequests) > 1 {
return false, fmt.Errorf("multiple open Forgejo pull requests exist for branch %q", head)
}
return len(pullRequests) == 1, nil
}
func (rm *RepoManager) MergePullRequest(repo, head string) error {
values := url.Values{"state": {"open"}, "head": {head}}
endpoint := fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls?%s", rm.BaseURL, rm.Owner, repo, values.Encode())
@ -453,64 +498,116 @@ func DeliveryBranch(appName, baseBranch string) (string, error) {
}
// PublishDeliveryBranch generates and commits delivery content in a temporary clone.
func (rm *RepoManager) PublishDeliveryBranch(sourceDir, sourceBranch, repoURL, deliveryBranch string, generate func(string) error) error {
func (rm *RepoManager) PublishDeliveryBranch(sourceDir, sourceBranch, repoURL, deliveryBranch string, generate func(string) error) (bool, error) {
if sourceDir == "" || sourceBranch == "" || deliveryBranch == "" || deliveryBranch == rm.Branch {
return fmt.Errorf("delivery source branch and dedicated delivery branch are required and must differ from the base branch")
return false, fmt.Errorf("delivery source branch and dedicated delivery branch are required and must differ from the base branch")
}
temporary, err := os.MkdirTemp("", "maidn-delivery-*")
if err != nil {
return err
return false, err
}
defer os.RemoveAll(temporary)
if err := runGit("", os.Environ(), "clone", "--no-local", "--branch", sourceBranch, sourceDir, temporary); err != nil {
return err
return false, err
}
cleanupAskPass, environment, err := rm.gitEnvironment()
if err != nil {
return err
return false, err
}
defer cleanupAskPass()
if err := runGit(temporary, environment, "checkout", "-B", deliveryBranch); err != nil {
return err
}
if err := os.RemoveAll(filepath.Join(temporary, ".tekton")); err != nil {
return err
return false, err
}
if err := generate(temporary); err != nil {
return err
return false, err
}
if err := runGit(temporary, environment, "add", ".tekton"); err != nil {
return err
return false, err
}
changed, err := gitDiffQuiet(temporary, environment, "--cached")
if err != nil {
return err
return false, err
}
if changed {
for _, args := range [][]string{{"config", "user.name", "Maidn"}, {"config", "user.email", "maidn@free-maidn.com"}, {"commit", "-m", "feat: add Maidn delivery pipeline"}} {
if err := runGit(temporary, environment, args...); err != nil {
return err
return false, err
}
}
}
hasBranch, err := rm.HasRemoteBranch(repoURL, deliveryBranch)
if err != nil {
return err
return false, err
}
if hasBranch {
if err := runGit(temporary, environment, "fetch", repoURL, "refs/heads/"+deliveryBranch); err != nil {
return err
return false, err
}
different, err := gitDiffQuiet(temporary, environment, "HEAD", "FETCH_HEAD")
if err != nil {
return err
return false, err
}
if !different {
return nil
return false, nil
}
return fmt.Errorf("dedicated delivery branch %q differs from generated content; refusing to overwrite it", deliveryBranch)
return false, fmt.Errorf("dedicated delivery branch %q differs from generated content; refusing to overwrite it", deliveryBranch)
}
return rm.PushBranch(temporary, repoURL, deliveryBranch)
if err := rm.PushBranch(temporary, repoURL, deliveryBranch); err != nil {
return false, err
}
return true, nil
}
// PublishRepositoryPullRequest applies a managed change on a dedicated branch.
// An existing branch is accepted only when it has exactly one open pull request.
func (rm *RepoManager) PublishRepositoryPullRequest(repo, title, branch, base string, change func(string) error) (bool, error) {
if repo == "" || branch == "" || base == "" || branch == base {
return false, errors.New("repository pull request requires distinct non-empty branches")
}
repoURL := CloneURL(rm.BaseURL, rm.Owner, repo)
hasBranch, err := rm.HasRemoteBranch(repoURL, branch)
if err != nil {
return false, err
}
temporary, err := os.MkdirTemp("", "maidn-registration-*")
if err != nil {
return false, err
}
defer os.RemoveAll(temporary)
cleanupAskPass, environment, err := rm.gitEnvironment()
if err != nil {
return false, err
}
defer cleanupAskPass()
checkout := base
if hasBranch {
checkout = branch
}
if err := runGit("", environment, "clone", "--branch", checkout, repoURL, temporary); err != nil {
return false, err
}
if !hasBranch {
if err := runGit(temporary, environment, "checkout", "-B", branch, "origin/"+base); err != nil {
return false, err
}
}
if err := change(temporary); err != nil {
return false, err
}
changed, err := commitAndPush(temporary, repo, branch, environment)
if err != nil {
return false, err
}
if !changed && !hasBranch {
return false, nil
}
if err := rm.EnsurePullRequest(repo, title, branch, base); err != nil {
return false, err
}
if err := rm.MergePullRequest(repo, branch); err != nil {
return false, err
}
return true, nil
}
func gitDiffQuiet(dir string, environment []string, args ...string) (bool, error) {
@ -648,6 +745,40 @@ func (rm *RepoManager) EnsureWebhook(repo, webhookURL, authorization string) err
return nil
}
// TriggerWebhookTest asks Forgejo to deliver a test push for the managed hook.
func (rm *RepoManager) TriggerWebhookTest(repo, webhookURL, branch string) error {
if repo == "" || webhookURL == "" || branch == "" {
return errors.New("Forgejo repository, webhook URL, and branch are required")
}
endpoint := fmt.Sprintf("%s/api/v1/repos/%s/%s/hooks", rm.BaseURL, rm.Owner, repo)
var hooks []hook
status, err := rm.apiJSONRequest(http.MethodGet, endpoint, nil, &hooks)
if err != nil {
return err
}
if status != http.StatusOK {
return fmt.Errorf("unexpected Forgejo webhook lookup status %d", status)
}
var matching []hook
for _, candidate := range hooks {
if candidate.URL == webhookURL {
matching = append(matching, candidate)
}
}
if len(matching) != 1 {
return fmt.Errorf("expected one Forgejo webhook for URL %q", webhookURL)
}
values := url.Values{"ref": {branch}}
status, err = rm.apiRequest(http.MethodPost, fmt.Sprintf("%s/%d/tests?%s", endpoint, matching[0].ID, values.Encode()), nil)
if err != nil {
return err
}
if status != http.StatusNoContent {
return fmt.Errorf("unexpected Forgejo webhook test status %d", status)
}
return nil
}
// EnsureProtectedBranch disables direct pushes to the configured production branch.
func (rm *RepoManager) EnsureProtectedBranch(repo, branch string) error {
if repo == "" || branch == "" {

View file

@ -238,6 +238,28 @@ func TestEnsureWebhookCreatesMissingWebhook(t *testing.T) {
}
}
func TestTriggerWebhookTestUsesManagedHookAndBaseBranch(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
switch request.Method + " " + request.URL.Path {
case http.MethodGet + " /api/v1/repos/owner/app/hooks":
_ = json.NewEncoder(writer).Encode([]hook{{ID: 7, URL: "https://tekton.example.test/"}})
case http.MethodPost + " /api/v1/repos/owner/app/hooks/7/tests":
if request.URL.Query().Get("ref") != "main" || request.Header.Get("Authorization") == "" {
t.Fatal("webhook test did not use the managed hook and base branch")
}
writer.WriteHeader(http.StatusNoContent)
default:
t.Fatalf("unexpected Forgejo request %s %s", request.Method, request.URL.String())
}
}))
defer server.Close()
manager := NewRepoManager(server.URL, "test-token", "owner", "user", "", "", "main", "")
manager.HTTPClient = server.Client()
if err := manager.TriggerWebhookTest("app", "https://tekton.example.test/", "main"); err != nil {
t.Fatal(err)
}
}
func TestEnsureProtectedBranchCreatesDirectPushProtection(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
if request.URL.Path != "/api/v1/repos/owner/app/branch_protections" {
@ -354,6 +376,34 @@ func TestMergePullRequest(t *testing.T) {
}
}
func TestEnsurePullRequestChecksExactOpenBranchBeforeCreating(t *testing.T) {
requests := 0
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
requests++
switch request.Method {
case http.MethodGet:
if request.URL.Path != "/api/v1/repos/owner/app/pulls" || request.URL.Query().Get("state") != "open" || request.URL.Query().Get("head") != "maidn/delivery-app" {
t.Fatalf("unexpected pull request lookup: %s", request.URL.String())
}
_, _ = writer.Write([]byte("[]"))
case http.MethodPost:
var body pullRequestRequest
if err := json.NewDecoder(request.Body).Decode(&body); err != nil || body.Head != "maidn/delivery-app" || body.Base != "main" {
t.Fatalf("unexpected pull request create: %#v, %v", body, err)
}
writer.WriteHeader(http.StatusCreated)
default:
t.Fatalf("unexpected request method %s", request.Method)
}
}))
defer server.Close()
manager := NewRepoManager(server.URL, "test-token", "owner", "bot", "", "", "main", "")
manager.HTTPClient = server.Client()
if err := manager.EnsurePullRequest("app", "delivery", "maidn/delivery-app", "main"); err != nil || requests != 2 {
t.Fatalf("EnsurePullRequest() = %v, requests = %d", err, requests)
}
}
func TestRepoExistsReturnsFalseOnNotFound(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
writer.WriteHeader(http.StatusNotFound)