Compare commits

..

No commits in common. "main" and "feat/runtime-delivery-selector" have entirely different histories.

17 changed files with 380 additions and 571 deletions

View file

@ -16,15 +16,6 @@
- The testing suite, onboarding, and mutation E2E commands must target `Maidn` fixture sources. - The testing suite, onboarding, and mutation E2E commands must target `Maidn` fixture sources.
- `test-org-2` is disposable execution state only. It may host temporary delivery branches and resources, but it is never a fixture source or test-suite owner. - `test-org-2` is disposable execution state only. It may host temporary delivery branches and resources, but it is never a fixture source or test-suite owner.
## Delivery Ownership
- Application repositories are build inputs only; do not add or update active
`.tekton/` or `.maidn/` delivery resources in them.
- The cluster repository owns Pipelines, Tasks, triggers, and runtime secret
access. The manifests repository owns image tags and promotion state.
- Flux chart sources must use only the protected `maidn/platform-<app>` branch,
never an application `main` or `maidn/delivery-*` branch.
## Required Checks ## Required Checks
- Before each commit: inspect `git status --short`, `git diff --check`, and `git log --oneline -10`. - Before each commit: inspect `git status --short`, `git diff --check`, and `git log --oneline -10`.

View file

@ -1,15 +1,26 @@
## Commands ## Commands
- `cicd-tool repo init` is a legacy GitHub workflow and is not used for new Forgejo/GitOps platform onboarding. go mod init github.com/Pingu-Studio/MaidnCLI
go get -u github.com/spf13/cobra@latest
go get golang.org/x/term
go mod tidy
go get gopkg.in/yaml.v3
in powershell run
```powershell
go install github.com/go-delve/delve/cmd/dlv@latest
dlv version
```
## Commands
- `cicd-tool repo init --org <org> --flux-repo <repo>` creates the manifests and Flux repos
- `cicd-tool bootstrap` runs a shorter Forgejo-first wizard, asks for a Forgejo PAT, asks where local repos should be cloned, discovers Proxmox nodes/storage/networks, retries without losing entered answers when discovery fails, shows the latest Talos version, derives the standardized Talos factory URL, schematic, and required extensions automatically from the chosen version, writes `terraform.tfvars`, stages Talos images on Proxmox, and can execute Terraform, Talos bootstrap, and Flux bootstrap - `cicd-tool bootstrap` runs a shorter Forgejo-first wizard, asks for a Forgejo PAT, asks where local repos should be cloned, discovers Proxmox nodes/storage/networks, retries without losing entered answers when discovery fails, shows the latest Talos version, derives the standardized Talos factory URL, schematic, and required extensions automatically from the chosen version, writes `terraform.tfvars`, stages Talos images on Proxmox, and can execute Terraform, Talos bootstrap, and Flux bootstrap
- `cicd-tool bootstrap --config maidn-bootstrap.yaml` skips the wizard and uses the saved config - `cicd-tool bootstrap --config maidn-bootstrap.yaml` skips the wizard and uses the saved config
- `cicd-tool bootstrap init --config <private-config> --organization <new-org> --create-organization` locks an isolated workspace, initializes Forgejo repositories, then runs the non-destructive bootstrap reconcile lifecycle. Use `--mode=rebuild --yes` only for an authorized rebuild. - `maidn bootstrap init --config <private-config> --organization <new-org> --create-organization --enable-delivery` locks an isolated workspace, initializes the Forgejo repositories, then runs the non-destructive bootstrap reconcile lifecycle; use `--mode=rebuild --yes` for an authorized rebuild. Delivery scaffolding requires `--enable-delivery`.
- `cicd-tool app onboard --config <private-config> --from <app-checkout>` is being migrated to central delivery ownership. Do not use the source-owned implementation for new applications; see `docs/architecture/delivery-ownership.md`. - `maidn app onboard --config <private-config> --from <app-checkout>` validates a clean configured checkout and adds its `.tekton` delivery contract.
- `cicd-tool e2e` runs bounded, read-only Flux, ExternalSecret, PipelineRun, preview, and promotion-PR checks with JSON output. See `docs/e2e.md`. - `cicd-tool e2e` runs bounded, read-only Flux, ExternalSecret, PipelineRun, preview, and promotion-PR checks with JSON output. See `docs/e2e.md`.
See `docs/operations.md` for the authorized operating and verification runbook. See `docs/operations.md` for the authorized operating and verification runbook.
See `docs/architecture/delivery-ownership.md` for the developer and platform
ownership boundary.
App authors: see `docs/delivery-feedback.md` for preview feedback and the scoped Forgejo token contract. App authors: see `docs/delivery-feedback.md` for preview feedback and the scoped Forgejo token contract.
## Forgejo setup ## Forgejo setup

View file

@ -9,7 +9,7 @@ import (
) )
var freshConfigPath, freshOrganization, onboardConfigPath, onboardFrom string var freshConfigPath, freshOrganization, onboardConfigPath, onboardFrom string
var onboardAppName, onboardAppRepoURL, onboardImageRepository, onboardBuildStrategy, onboardBuildOutputDirectory, onboardBuildConfiguration string var onboardAppName, onboardAppRepoURL, onboardImageRepository, onboardBuildStrategy string
var freshCreateOrganization, freshEnableDelivery, freshYes bool var freshCreateOrganization, freshEnableDelivery, freshYes bool
var freshMode string var freshMode string
@ -32,7 +32,7 @@ var appCmd = &cobra.Command{
var appOnboardCmd = &cobra.Command{ var appOnboardCmd = &cobra.Command{
Use: "onboard", Use: "onboard",
Short: "Validate an application checkout and add its centrally-owned delivery contract.", Short: "Validate an application checkout and add its source-owned delivery contract.",
RunE: runAppOnboard, RunE: runAppOnboard,
} }
@ -50,13 +50,11 @@ func init() {
rootCmd.AddCommand(appCmd) rootCmd.AddCommand(appCmd)
appCmd.AddCommand(appOnboardCmd) appCmd.AddCommand(appOnboardCmd)
appOnboardCmd.Flags().StringVar(&onboardConfigPath, "config", "", "Path to private bootstrap config YAML") appOnboardCmd.Flags().StringVar(&onboardConfigPath, "config", "", "Path to private bootstrap config YAML")
appOnboardCmd.Flags().StringVar(&onboardFrom, "from", "", "Clean application checkout to validate") appOnboardCmd.Flags().StringVar(&onboardFrom, "from", "", "Clean application checkout to scaffold")
appOnboardCmd.Flags().StringVar(&onboardAppName, "app-name", "", "Application name override") appOnboardCmd.Flags().StringVar(&onboardAppName, "app-name", "", "Application name override")
appOnboardCmd.Flags().StringVar(&onboardAppRepoURL, "app-repo-url", "", "Application repository URL override") appOnboardCmd.Flags().StringVar(&onboardAppRepoURL, "app-repo-url", "", "Application repository URL override")
appOnboardCmd.Flags().StringVar(&onboardImageRepository, "image-repository", "", "OCI image repository override") appOnboardCmd.Flags().StringVar(&onboardImageRepository, "image-repository", "", "OCI image repository override")
appOnboardCmd.Flags().StringVar(&onboardBuildStrategy, "build-strategy", "", "Build strategy override: static or runtime") appOnboardCmd.Flags().StringVar(&onboardBuildStrategy, "build-strategy", "", "Build strategy override: static or runtime")
appOnboardCmd.Flags().StringVar(&onboardBuildOutputDirectory, "build-output-directory", "", "Static build output directory override")
appOnboardCmd.Flags().StringVar(&onboardBuildConfiguration, "build-configuration", "", "Static build configuration override")
_ = appOnboardCmd.MarkFlagRequired("config") _ = appOnboardCmd.MarkFlagRequired("config")
_ = appOnboardCmd.MarkFlagRequired("from") _ = appOnboardCmd.MarkFlagRequired("from")
} }
@ -93,12 +91,6 @@ func runAppOnboard(_ *cobra.Command, _ []string) error {
if onboardBuildStrategy != "" { if onboardBuildStrategy != "" {
cfg.Delivery.BuildStrategy = onboardBuildStrategy cfg.Delivery.BuildStrategy = onboardBuildStrategy
} }
if onboardBuildOutputDirectory != "" {
cfg.Delivery.BuildOutputDirectory = onboardBuildOutputDirectory
}
if onboardBuildConfiguration != "" {
cfg.Delivery.BuildConfiguration = onboardBuildConfiguration
}
cfg, err = resolveAppOnboarding(cfg) cfg, err = resolveAppOnboarding(cfg)
if err != nil { if err != nil {
return err return err

View file

@ -65,11 +65,11 @@ func TestBootstrapInitAppliesFluxDefaultsBeforeFreshValidation(t *testing.T) {
func TestAppOnboardValidatesConfigBeforeExternalWork(t *testing.T) { func TestAppOnboardValidatesConfigBeforeExternalWork(t *testing.T) {
originalConfig, originalResolve, originalOnboard := loadAppOnboardConfig, resolveAppOnboarding, onboardApp originalConfig, originalResolve, originalOnboard := loadAppOnboardConfig, resolveAppOnboarding, onboardApp
originalConfigPath, originalFrom := onboardConfigPath, onboardFrom originalConfigPath, originalFrom := onboardConfigPath, onboardFrom
originalName, originalRepo, originalImage, originalBuildStrategy, originalBuildOutput, originalBuildConfiguration := onboardAppName, onboardAppRepoURL, onboardImageRepository, onboardBuildStrategy, onboardBuildOutputDirectory, onboardBuildConfiguration originalName, originalRepo, originalImage, originalBuildStrategy := onboardAppName, onboardAppRepoURL, onboardImageRepository, onboardBuildStrategy
t.Cleanup(func() { t.Cleanup(func() {
loadAppOnboardConfig, resolveAppOnboarding, onboardApp = originalConfig, originalResolve, originalOnboard loadAppOnboardConfig, resolveAppOnboarding, onboardApp = originalConfig, originalResolve, originalOnboard
onboardConfigPath, onboardFrom = originalConfigPath, originalFrom onboardConfigPath, onboardFrom = originalConfigPath, originalFrom
onboardAppName, onboardAppRepoURL, onboardImageRepository, onboardBuildStrategy, onboardBuildOutputDirectory, onboardBuildConfiguration = originalName, originalRepo, originalImage, originalBuildStrategy, originalBuildOutput, originalBuildConfiguration onboardAppName, onboardAppRepoURL, onboardImageRepository, onboardBuildStrategy = originalName, originalRepo, originalImage, originalBuildStrategy
}) })
loadAppOnboardConfig = func(string) (config.Config, error) { return config.Config{}, nil } loadAppOnboardConfig = func(string) (config.Config, error) { return config.Config{}, nil }
resolveAppOnboarding = func(config.Config) (config.Config, error) { return config.Config{}, errors.New("incomplete delivery") } resolveAppOnboarding = func(config.Config) (config.Config, error) { return config.Config{}, errors.New("incomplete delivery") }
@ -78,7 +78,7 @@ func TestAppOnboardValidatesConfigBeforeExternalWork(t *testing.T) {
return nil return nil
} }
onboardConfigPath, onboardFrom = "private.yaml", "app-checkout" onboardConfigPath, onboardFrom = "private.yaml", "app-checkout"
onboardAppName, onboardAppRepoURL, onboardImageRepository, onboardBuildStrategy, onboardBuildOutputDirectory, onboardBuildConfiguration = "", "", "", "", "", "" onboardAppName, onboardAppRepoURL, onboardImageRepository, onboardBuildStrategy = "", "", "", ""
if err := runAppOnboard(nil, nil); err == nil { if err := runAppOnboard(nil, nil); err == nil {
t.Fatal("onboarding accepted invalid configuration") t.Fatal("onboarding accepted invalid configuration")
} }
@ -87,25 +87,25 @@ func TestAppOnboardValidatesConfigBeforeExternalWork(t *testing.T) {
func TestAppOnboardPassesOnlyValidatedConfigAndCheckout(t *testing.T) { func TestAppOnboardPassesOnlyValidatedConfigAndCheckout(t *testing.T) {
originalConfig, originalResolve, originalOnboard := loadAppOnboardConfig, resolveAppOnboarding, onboardApp originalConfig, originalResolve, originalOnboard := loadAppOnboardConfig, resolveAppOnboarding, onboardApp
originalConfigPath, originalFrom := onboardConfigPath, onboardFrom originalConfigPath, originalFrom := onboardConfigPath, onboardFrom
originalName, originalRepo, originalImage, originalBuildStrategy, originalBuildOutput, originalBuildConfiguration := onboardAppName, onboardAppRepoURL, onboardImageRepository, onboardBuildStrategy, onboardBuildOutputDirectory, onboardBuildConfiguration originalName, originalRepo, originalImage, originalBuildStrategy := onboardAppName, onboardAppRepoURL, onboardImageRepository, onboardBuildStrategy
t.Cleanup(func() { t.Cleanup(func() {
loadAppOnboardConfig, resolveAppOnboarding, onboardApp = originalConfig, originalResolve, originalOnboard loadAppOnboardConfig, resolveAppOnboarding, onboardApp = originalConfig, originalResolve, originalOnboard
onboardConfigPath, onboardFrom = originalConfigPath, originalFrom onboardConfigPath, onboardFrom = originalConfigPath, originalFrom
onboardAppName, onboardAppRepoURL, onboardImageRepository, onboardBuildStrategy, onboardBuildOutputDirectory, onboardBuildConfiguration = originalName, originalRepo, originalImage, originalBuildStrategy, originalBuildOutput, originalBuildConfiguration onboardAppName, onboardAppRepoURL, onboardImageRepository, onboardBuildStrategy = originalName, originalRepo, originalImage, originalBuildStrategy
}) })
cfg := config.Config{Delivery: config.DeliveryConfig{AppRepoURL: "https://git.example.test/new-org/app.git", AppRepoRef: "main"}} 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 } loadAppOnboardConfig = func(string) (config.Config, error) { return cfg, nil }
resolveAppOnboarding = func(got config.Config) (config.Config, error) { return got, nil } resolveAppOnboarding = func(got config.Config) (config.Config, error) { return got, nil }
calls := 0 calls := 0
onboardApp = func(got config.Config, checkout string) error { onboardApp = func(got config.Config, checkout string) error {
if checkout != "app-checkout" || got.Delivery.AppName != "fixture" || got.Delivery.AppRepoURL != "https://git.example.test/new-org/fixture.git" || got.Delivery.ImageRepository != "registry.example.test/new-org/fixture" || got.Delivery.BuildStrategy != "runtime" || got.Delivery.BuildOutputDirectory != "dist/fixture" || got.Delivery.BuildConfiguration != "ci" { if checkout != "app-checkout" || got.Delivery.AppName != "fixture" || got.Delivery.AppRepoURL != "https://git.example.test/new-org/fixture.git" || got.Delivery.ImageRepository != "registry.example.test/new-org/fixture" || got.Delivery.BuildStrategy != "runtime" {
t.Fatal("onboarding used the wrong checkout or config") t.Fatal("onboarding used the wrong checkout or config")
} }
calls++ calls++
return nil return nil
} }
onboardConfigPath, onboardFrom = "private.yaml", "app-checkout" onboardConfigPath, onboardFrom = "private.yaml", "app-checkout"
onboardAppName, onboardAppRepoURL, onboardImageRepository, onboardBuildStrategy, onboardBuildOutputDirectory, onboardBuildConfiguration = "fixture", "https://git.example.test/new-org/fixture.git", "registry.example.test/new-org/fixture", "runtime", "dist/fixture", "ci" onboardAppName, onboardAppRepoURL, onboardImageRepository, onboardBuildStrategy = "fixture", "https://git.example.test/new-org/fixture.git", "registry.example.test/new-org/fixture", "runtime"
if err := runAppOnboard(nil, nil); err != nil || calls != 1 { if err := runAppOnboard(nil, nil); err != nil || calls != 1 {
t.Fatalf("runAppOnboard() = %v, calls = %d", err, calls) t.Fatalf("runAppOnboard() = %v, calls = %d", err, calls)
} }

View file

@ -1,107 +0,0 @@
# Delivery Ownership
## Status
This is the approved target architecture. Central onboarding is available for
new applications; existing source-owned registrations remain migration work.
## Trust Boundary
Application repositories are build inputs, not GitOps inputs. A developer can
change application code on `main`, but cannot change an active Pipeline, Task,
runtime secret reference, Helm chart, environment value, or promotion policy.
| Concern | Owner | Location |
| --- | --- | --- |
| Application code, tests, Dockerfile | Developers | `Maidn/<app>` `main` |
| Approved charts and environment values | Platform | `Maidn/<app>` `maidn/platform-<app>` |
| Pipeline, Tasks, triggers, runtime access | Platform | Cluster repository |
| Image tags, preview ownership, promotion PRs | Platform | Deployment manifests repository |
| Secret values and policies | Platform | Private config, SOPS operational state, OpenBao |
The protected platform branch may be readable by developers, but only platform
operators and approved automation may push or merge into it. Flux must track
only that branch for chart content. Flux must never track an application `main`
branch or `maidn/delivery-*` branch.
Onboarding requires an existing `maidn/platform-<app>` branch and verifies its
no-direct-push protection before it opens the central registration PR. It never
seeds a platform branch from developer-controlled `main`.
## Resource Flow
```mermaid
flowchart LR
App[Application main branch\ncode only]
Platform[Protected platform branch\nchart and values]
Cluster[Cluster repository\nPipeline, Tasks, secret access]
Manifests[Manifests repository\nimage tags and promotion]
Flux[Flux]
Workload[Preview, staging, production]
App -->|exact source SHA| Cluster
Cluster --> Manifests
Platform --> Flux
Manifests --> Flux
Flux --> Workload
```
The central Pipeline clones the application repository at the event SHA only to
build an image. It obtains chart content from the protected platform branch and
writes only image-tag and promotion state to the manifests repository.
## Prohibited Application Content
Application repositories must not contain active delivery control-plane
resources:
- `.tekton/` Pipeline or Task resources
- `.maidn/` Flux, RBAC, SecretStore, or ExternalSecret resources
- Flux Kustomizations or GitRepositories
- Kubernetes Secret values, SOPS identities, OpenBao tokens, or kubeconfigs
An application `main` branch may retain a chart as a developer proposal, but it
has no delivery effect. A platform operator explicitly reviews and copies an
approved chart and environment values to `maidn/platform-<app>`.
## Approval Sequence
1. A platform operator creates or updates the protected platform branch through
a reviewed platform PR.
2. A platform operator reviews and merges the central cluster registration that
renders the Pipeline, Tasks, protected chart source, and runtime references.
3. Flux applies only the central cluster resources and protected chart branch.
4. Developers use Forgejo pull requests and pushes to trigger builds; they do
not edit deployment control-plane resources.
5. Production promotion remains a reviewed PR in the manifests repository.
## Migration Rules
Existing source-owned registrations are migrated one application at a time.
First establish the protected platform package, then merge the central cluster
registration, then verify Flux and delivery. Remove the legacy source
GitRepository, Kustomizations, and delivery branch only after the replacement
is Ready.
The canonical fixtures are migration pilots. `Maidn/maidn-e2e-secret` remains
disabled until its central replacement is reviewed. Angular and web legacy
registrations must follow the same migration path.
## Command Status
`cicd-tool bootstrap`, `cicd-tool app`, and `cicd-tool e2e` are the current
Forgejo and GitOps command families. `cicd-tool repo init` and `cicd-tool vault
create-*` are legacy GitHub/direct-apply paths and are not part of new platform
onboarding. They will be deprecated or removed after the central delivery
migration.
## Acceptance
The architecture is accepted only after a canonical fixture proves all of the
following with a real Forgejo event:
- pull request preview build, route, and delivery feedback
- main-to-staging deployment at an immutable image SHA
- reviewed production promotion PR
- runtime secret and shared database projection without preview credentials
- closed-preview cleanup and orphan-cleaner recovery

View file

@ -1,11 +1,10 @@
# Delivery feedback # Delivery feedback
The central cluster registration owns the Tekton tasks that update one marked `maidn app onboard` adds source-owned Tekton tasks that update one marked
Forgejo pull-request comment. Application repositories do not carry active Forgejo pull-request comment. The comment contains only the verified preview
delivery Tasks. The comment contains only the verified preview URL, a redacted URL, a redacted task-status summary, and the PipelineRun name. Set the optional
task-status summary, and the PipelineRun name. Set the optional `delivery.tektonDashboardUrl` to a credential-free HTTPS Tekton Dashboard
`delivery.tektonDashboardUrl` to a credential-free HTTPS Tekton Dashboard origin origin to add a PipelineRun link.
to add a PipelineRun link.
Before enabling delivery feedback, create the separate Forgejo token with Before enabling delivery feedback, create the separate Forgejo token with
`bootstrap --config <private-bootstrap-config> --create-forgejo-delivery-status-token`. `bootstrap --config <private-bootstrap-config> --create-forgejo-delivery-status-token`.
@ -18,6 +17,5 @@ Forgejo API responses.
Preview and staging feedback waits up to ten minutes for the app Deployment Preview and staging feedback waits up to ten minutes for the app Deployment
and HTTPRoute, then performs a bounded HTTPS check. A production event reports and HTTPRoute, then performs a bounded HTTPS check. A production event reports
the manifest-repository promotion PR; it does not claim a production deploy. the manifest-repository promotion PR; it does not claim a production deploy.
The protected `maidn/platform-<app>` chart must name both resources after The chart must name both resources after `delivery.appName`; the HTTPRoute's
`delivery.appName`; the HTTPRoute's first hostname must be the public HTTPS first hostname must be the public HTTPS preview/staging URL.
preview/staging URL.

View file

@ -29,10 +29,3 @@ Build the portable OCI runner with `docker build -t maidn-e2e-runner .`.
Mount the kubeconfig and optional token file read-only; ensure they are readable Mount the kubeconfig and optional token file read-only; ensure they are readable
by the image's non-root user. The build context excludes known secret-bearing by the image's non-root user. The build context excludes known secret-bearing
bootstrap inputs. bootstrap inputs.
## Fixture Boundary
Use `cicd-tool e2e-mutate` only with canonical `Maidn/maidn-e2e-*` fixtures.
The test runner must prove preview, staging, production promotion, runtime
secret/database access, preview cleanup, and orphan cleanup against central
delivery resources. It must not mutate `test-org-2` as a fixture source.

View file

@ -23,80 +23,31 @@ new template revisions intentionally, use a new empty `workspaceDir` (and a
fresh `cloneParent` when it is configured separately) and keep the prior fresh `cloneParent` when it is configured separately) and keep the prior
secret-bearing workspace intact for recovery. secret-bearing workspace intact for recovery.
## Standard Delivery Workflow ## Platform Initialization And App Onboarding
Open and merge reviewed platform-source PRs before reconciling. When a merged Initialize the shared delivery platform before onboarding any application. This
template source must replace an existing revision lock, run the refresh by
itself. It creates migration PRs; review and merge them, then rerun normal
reconciliation:
```powershell
go run . bootstrap --config <private-bootstrap-config> --mode=reconcile --refresh-template-revisions
go run . bootstrap --config <private-bootstrap-config> --mode=reconcile
```
Initialize the shared delivery platform before onboarding an application. This
creates the Gateway, Tekton, and Tekton Triggers platform resources using creates the Gateway, Tekton, and Tekton Triggers platform resources using
`tekton.<cluster-domain>` and the configured Forgejo owner: `tekton.<cluster-domain>` and the configured Forgejo owner; it does not render
an application Pipeline or register an application webhook:
```powershell ```powershell
go run . bootstrap init --config <private-bootstrap-config> --organization <owner> --create-organization bootstrap init --config <private-bootstrap-config> --organization <owner> --create-organization
``` ```
The source-owned onboarding implementation is retired. Central onboarding Import a clean checkout into the configured owner with a separate command. The
creates a reviewed cluster registration only; it never writes `.tekton` or checkout may originate in another Forgejo organization, but `delivery.appRepoUrl`
`.maidn` resources to an application repository. Before merging that must target `<owner>/<app>.git` and its current branch must be
registration, a platform operator must create the corresponding `delivery.appRepoRef`:
`maidn/platform-<app>` package through a reviewed platform PR; onboarding
verifies its existence and enforces its protection. See [Delivery
Ownership](architecture/delivery-ownership.md) for the approved architecture
and migration rules.
The onboarding command uses a clean checkout on `delivery.appRepoRef`.
The app repository URL must be the canonical source owner, such as
`Maidn/<app>.git`; `test-org-2` is execution state only. Per-app static build
values remain command-line overrides rather than private defaults:
```powershell ```powershell
go run . app onboard --config <private-bootstrap-config> --from <clean-checkout> ` app onboard --config <private-bootstrap-config> --from <clean-checkout>
--app-name <app> --app-repo-url https://<forgejo>/Maidn/<app>.git `
--image-repository <registry>/<owner>/<app> --build-strategy static `
--build-output-directory <output-directory> --build-configuration <configuration>
``` ```
For a runtime build, set `--build-strategy runtime`; static-only output options Onboarding copies the current branch to the target repository, replaces only
remain harmless. Central onboarding creates the reviewed cluster-registration the two Maidn-generated `.tekton` files, protects the production branch, and
change after the platform package exists. The cluster repository owns auto-merges the app delivery and cluster registration PRs. The registration is
`base/tekton/apps/<app>.yaml`; the application repository remains a build input stored in `base/tekton/apps/<app>.yaml` in the configured cluster repository;
only. The generic EventListener dispatches by Forgejo repository name. the generic EventListener dispatches by the Forgejo repository name.
Existing source-owned registrations are migrated in separate reviewed cluster
repository PRs. Never overwrite an unmanaged registration.
After Flux reports the app Kustomization Ready, use Forgejo's hook test endpoint
against a non-`main` ref and inspect the resulting PipelineRun. The command and
read-only checks are in [Webhook Smoke Test](#webhook-smoke-test).
### Shared Services And Add-ons
Environment databases are platform-owned shared services. Staging workloads use
the CNPG-generated `staging-postgres-app` Secret and production workloads use
`production-postgres-app`; applications must not declare their own CNPG Cluster
by default. Shared credentials are appropriate only for the shared environment
database. Use a dedicated service only when isolation, lifecycle, or storage
requirements demand it.
The cluster repository owns reviewed dedicated resources and runtime secret
access. Application repositories must not carry active `.maidn` resources.
Runtime references never contain credential values. Preview namespaces do not
receive staging or production runtime credentials; preview-safe configuration is
defined in the protected platform chart branch.
Declare runtime secret access in the private configuration and run normal
bootstrap reconciliation to create its policy and role. Set values only with
`app secret set` using stdin, `--file`, or `--generate`; provision the scoped
E2E identity with `bootstrap --provision-app-secret-identities --e2e-app <app>`
only when a probe needs it. See [secret-grants.md](secret-grants.md).
`delivery.productionBranch` is the application production branch. A production `delivery.productionBranch` is the application production branch. A production
delivery PR targets the configured generic delivery PR targets the configured generic

View file

@ -27,6 +27,10 @@ spec:
- name: environment - name: environment
- name: pr-number - name: pr-number
default: "" default: ""
- name: app-url
default: ""
- name: app-revision
default: ""
- name: forgejo-base-url - name: forgejo-base-url
default: "" default: ""
- name: forgejo-owner - name: forgejo-owner
@ -53,6 +57,10 @@ spec:
value: $(params.environment) value: $(params.environment)
- name: PR_NUMBER - name: PR_NUMBER
value: $(params.pr-number) value: $(params.pr-number)
- name: APP_URL
value: $(params.app-url)
- name: APP_REVISION
value: $(params.app-revision)
- name: FORGEJO_BASE_URL - name: FORGEJO_BASE_URL
value: $(params.forgejo-base-url) value: $(params.forgejo-base-url)
- name: FORGEJO_OWNER - name: FORGEJO_OWNER
@ -94,6 +102,8 @@ spec:
cd /tmp/manifests cd /tmp/manifests
if [ "$ENVIRONMENT" = preview ]; then if [ "$ENVIRONMENT" = preview ]; then
valid_pr_number "$PR_NUMBER" valid_pr_number "$PR_NUMBER"
valid_url "$APP_URL"
valid_revision "$APP_REVISION"
app_dir="apps/previews/$APP_NAME-pr-$PR_NUMBER" app_dir="apps/previews/$APP_NAME-pr-$PR_NUMBER"
marker="$app_dir/ownership.yaml" marker="$app_dir/ownership.yaml"
if [ -e "$app_dir" ]; then if [ -e "$app_dir" ]; then
@ -119,7 +129,12 @@ spec:
EOF EOF
cmp -s "$expected_marker" "$marker" || fail cmp -s "$expected_marker" "$marker" || fail
fi fi
git clone "$APP_URL" /tmp/app
git -C /tmp/app checkout "$APP_REVISION"
[ -f /tmp/app/preview/values.yaml ] || fail
mkdir -p "$app_dir" mkdir -p "$app_dir"
cp /tmp/app/preview/values.yaml "$app_dir/values.yaml"
sed -i "s/PLACEHOLDER_PR/$PR_NUMBER/g" "$app_dir/values.yaml"
cat > "$marker" <<EOF cat > "$marker" <<EOF
apiVersion: v1 apiVersion: v1
kind: ConfigMap kind: ConfigMap
@ -154,20 +169,14 @@ spec:
chart: chart:
spec: spec:
chart: ./charts/$APP_NAME chart: ./charts/$APP_NAME
reconcileStrategy: Revision
sourceRef: sourceRef:
kind: GitRepository kind: GitRepository
name: $APP_NAME name: $APP_NAME
namespace: flux-system namespace: flux-system
valuesFiles:
- ./charts/$APP_NAME/values.yaml
- ./preview/values.yaml
values: values:
image: image:
repository: $IMAGE repository: $IMAGE
tag: $TAG tag: $TAG
gateway:
hostname: $APP_NAME-pr-$PR_NUMBER.{{ .ClusterDomain }}
EOF EOF
cat > "$app_dir/kustomization.yaml" <<EOF cat > "$app_dir/kustomization.yaml" <<EOF
apiVersion: kustomize.config.k8s.io/v1beta1 apiVersion: kustomize.config.k8s.io/v1beta1
@ -203,14 +212,10 @@ spec:
chart: chart:
spec: spec:
chart: ./charts/$APP_NAME chart: ./charts/$APP_NAME
reconcileStrategy: Revision
sourceRef: sourceRef:
kind: GitRepository kind: GitRepository
name: $APP_NAME name: $APP_NAME
namespace: flux-system namespace: flux-system
valuesFiles:
- ./charts/$APP_NAME/values.yaml
- ./staging/values.yaml
values: values:
image: image:
repository: $IMAGE repository: $IMAGE
@ -254,14 +259,10 @@ spec:
chart: chart:
spec: spec:
chart: ./charts/$APP_NAME chart: ./charts/$APP_NAME
reconcileStrategy: Revision
sourceRef: sourceRef:
kind: GitRepository kind: GitRepository
name: $APP_NAME name: $APP_NAME
namespace: flux-system namespace: flux-system
valuesFiles:
- ./charts/$APP_NAME/values.yaml
- ./production/values.yaml
values: values:
image: image:
repository: $IMAGE repository: $IMAGE
@ -676,6 +677,10 @@ spec:
value: preview value: preview
- name: pr-number - name: pr-number
value: $(params.pr-number) value: $(params.pr-number)
- name: app-url
value: {{ quote .AppRepoURL }}
- name: app-revision
value: $(params.git-revision)
- name: update-staging - name: update-staging
runAfter: [build-layer] runAfter: [build-layer]
when: when:

View file

@ -632,11 +632,64 @@ type appDeliveryTemplateConfig struct {
ManifestsURL string ManifestsURL string
ManifestsRepo string ManifestsRepo string
ManifestsBranch string ManifestsBranch string
ClusterDomain string
} }
var deliveryAppName = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,45}[a-z0-9])?$`) var deliveryAppName = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,45}[a-z0-9])?$`)
// GenerateAppDelivery writes the source-owned Tekton delivery contract for an app checkout.
func GenerateAppDelivery(dir string, cfg config.Config) error {
if err := config.ValidateDelivery(cfg); err != nil {
return err
}
content, err := renderAppDelivery(cfg)
if err != nil {
return err
}
files := map[string][]byte{
"kustomization.yaml": []byte("apiVersion: kustomize.config.k8s.io/v1beta1\nkind: Kustomization\nresources:\n - pipeline.yaml\n"),
"pipeline.yaml": content,
}
target := filepath.Join(dir, ".tekton")
info, err := os.Lstat(target)
if err == nil {
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
return errors.New("app delivery .tekton path must be a directory")
}
entries, err := os.ReadDir(target)
if err != nil {
return err
}
if len(entries) != len(files) {
return errors.New("app delivery .tekton contains unmanaged files")
}
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
}
if !os.IsNotExist(err) {
return err
}
temporary, err := os.MkdirTemp(dir, ".maidn-tekton-")
if err != nil {
return err
}
defer os.RemoveAll(temporary)
for name, content := range files {
if err := os.WriteFile(filepath.Join(temporary, name), content, 0644); err != nil {
return err
}
}
return os.Rename(temporary, target)
}
func renderAppDelivery(cfg config.Config) ([]byte, error) { func renderAppDelivery(cfg config.Config) ([]byte, error) {
if !deliveryAppName.MatchString(cfg.Delivery.AppName) { if !deliveryAppName.MatchString(cfg.Delivery.AppName) {
return nil, errors.New("delivery appName must be a lowercase DNS label") return nil, errors.New("delivery appName must be a lowercase DNS label")
@ -656,9 +709,9 @@ func renderAppDelivery(cfg config.Config) ([]byte, error) {
AppName: cfg.Delivery.AppName, AppRepository: appRepository, AppRepoURL: cfg.Delivery.AppRepoURL, AppName: cfg.Delivery.AppName, AppRepository: appRepository, AppRepoURL: cfg.Delivery.AppRepoURL,
AppRepoRef: cfg.Delivery.AppRepoRef, ProductionBranch: cfg.Delivery.ProductionBranch, ImageRepository: cfg.Delivery.ImageRepository, AppRepoRef: cfg.Delivery.AppRepoRef, ProductionBranch: cfg.Delivery.ProductionBranch, ImageRepository: cfg.Delivery.ImageRepository,
BuildStrategy: cfg.Delivery.BuildStrategy, BuildOutputDirectory: cfg.Delivery.BuildOutputDirectory, BuildConfiguration: cfg.Delivery.BuildConfiguration, BuildStrategy: cfg.Delivery.BuildStrategy, BuildOutputDirectory: cfg.Delivery.BuildOutputDirectory, BuildConfiguration: cfg.Delivery.BuildConfiguration,
ForgejoBaseURL: origin, ForgejoOwner: cfg.Git.Owner, ManifestsURL: forgejo.CloneURL(origin, cfg.Git.Owner, cfg.Flux.ManifestsRepo), ManifestsRepo: cfg.Flux.ManifestsRepo, ManifestsBranch: cfg.Flux.Branch, ClusterDomain: cfg.Flux.ClusterDomain, ForgejoBaseURL: origin, ForgejoOwner: cfg.Git.Owner, ManifestsURL: forgejo.CloneURL(origin, cfg.Git.Owner, cfg.Flux.ManifestsRepo), ManifestsRepo: cfg.Flux.ManifestsRepo, ManifestsBranch: cfg.Flux.Branch,
} }
for name, value := range map[string]string{"appRepository": values.AppRepository, "appRepoUrl": values.AppRepoURL, "appRepoRef": values.AppRepoRef, "productionBranch": values.ProductionBranch, "imageRepository": values.ImageRepository, "buildOutputDirectory": values.BuildOutputDirectory, "buildConfiguration": values.BuildConfiguration, "forgejoBaseUrl": values.ForgejoBaseURL, "forgejoOwner": values.ForgejoOwner, "manifestsUrl": values.ManifestsURL, "manifestsRepo": values.ManifestsRepo, "manifestsBranch": values.ManifestsBranch, "clusterDomain": values.ClusterDomain} { for name, value := range map[string]string{"appRepository": values.AppRepository, "appRepoUrl": values.AppRepoURL, "appRepoRef": values.AppRepoRef, "productionBranch": values.ProductionBranch, "imageRepository": values.ImageRepository, "buildOutputDirectory": values.BuildOutputDirectory, "buildConfiguration": values.BuildConfiguration, "forgejoBaseUrl": values.ForgejoBaseURL, "forgejoOwner": values.ForgejoOwner, "manifestsUrl": values.ManifestsURL, "manifestsRepo": values.ManifestsRepo, "manifestsBranch": values.ManifestsBranch} {
if value == "" || strings.ContainsAny(value, "\r\n") || config.RedactURL(value) != value { if value == "" || strings.ContainsAny(value, "\r\n") || config.RedactURL(value) != value {
return nil, fmt.Errorf("delivery %s cannot be empty or contain credentials", name) return nil, fmt.Errorf("delivery %s cannot be empty or contain credentials", name)
} }

View file

@ -111,7 +111,7 @@ func TestRemoveDuplicateAppDeliverySource(t *testing.T) {
func TestGeneratedDeliveryUsesCombinedStaticBuildArtifactContract(t *testing.T) { func TestGeneratedDeliveryUsesCombinedStaticBuildArtifactContract(t *testing.T) {
cfg := config.Config{ cfg := config.Config{
Git: config.GitConfig{BaseURL: "https://git.example.test", Owner: "platform"}, Git: config.GitConfig{BaseURL: "https://git.example.test", Owner: "platform"},
Flux: config.FluxConfig{Branch: "main", ClusterDomain: "example.test", ManifestsRepo: "manifests"}, Flux: config.FluxConfig{Branch: "main", ManifestsRepo: "manifests"},
Delivery: config.DeliveryConfig{AppName: "web-ui", AppRepoURL: "https://git.example.test/apps/web-ui.git", AppRepoRef: "main", ProductionBranch: "production", ImageRepository: "registry.example.test/apps/web-ui", BuildStrategy: "static", BuildOutputDirectory: "dist/web-ui", BuildConfiguration: "production"}, Delivery: config.DeliveryConfig{AppName: "web-ui", AppRepoURL: "https://git.example.test/apps/web-ui.git", AppRepoRef: "main", ProductionBranch: "production", ImageRepository: "registry.example.test/apps/web-ui", BuildStrategy: "static", BuildOutputDirectory: "dist/web-ui", BuildConfiguration: "production"},
} }
content, err := renderAppDelivery(cfg) content, err := renderAppDelivery(cfg)
@ -123,12 +123,7 @@ func TestGeneratedDeliveryUsesCombinedStaticBuildArtifactContract(t *testing.T)
t.Fatalf("generated delivery does not contain %q", expected) t.Fatalf("generated delivery does not contain %q", expected)
} }
} }
for _, expected := range []string{"reconcileStrategy: Revision", "valuesFiles:\n - ./charts/$APP_NAME/values.yaml\n - ./preview/values.yaml", "valuesFiles:\n - ./charts/$APP_NAME/values.yaml\n - ./staging/values.yaml", "valuesFiles:\n - ./charts/$APP_NAME/values.yaml\n - ./production/values.yaml", "hostname: $APP_NAME-pr-$PR_NUMBER.example.test"} { for _, unexpected := range []string{"maidn-git-clone", "maidn-node-static-build", "maidn-node-static-push", "runAfter: [clone]", "name: source", "workspace: source", "workspace: artifact", "easycsr", "test-org", "git rm -r"} {
if !strings.Contains(string(content), expected) {
t.Fatalf("generated delivery does not contain protected platform values %q", expected)
}
}
for _, unexpected := range []string{"maidn-git-clone", "maidn-node-static-build", "maidn-node-static-push", "runAfter: [clone]", "name: source", "workspace: source", "workspace: artifact", "easycsr", "test-org", "git rm -r", "$APP_URL", "$APP_REVISION", "platform-url", "platform-revision", "/tmp/platform", "/tmp/app/preview/values.yaml"} {
if strings.Contains(string(content), unexpected) { if strings.Contains(string(content), unexpected) {
t.Fatalf("generated delivery contains unexpected %q", unexpected) t.Fatalf("generated delivery contains unexpected %q", unexpected)
} }
@ -137,8 +132,8 @@ func TestGeneratedDeliveryUsesCombinedStaticBuildArtifactContract(t *testing.T)
func TestGeneratedDeliveryUsesRuntimeImageWithoutStaticParameters(t *testing.T) { func TestGeneratedDeliveryUsesRuntimeImageWithoutStaticParameters(t *testing.T) {
cfg := config.Config{ cfg := config.Config{
Git: config.GitConfig{BaseURL: "https://git.example.test", Owner: "platform"}, Git: config.GitConfig{BaseURL: "https://git.example.test", Owner: "platform"},
Flux: config.FluxConfig{Branch: "main", ClusterDomain: "example.test", ManifestsRepo: "manifests"}, Flux: config.FluxConfig{Branch: "main", ManifestsRepo: "manifests"},
Delivery: config.DeliveryConfig{AppName: "web-ui", AppRepoURL: "https://git.example.test/apps/web-ui.git", AppRepoRef: "main", ProductionBranch: "production", ImageRepository: "registry.example.test/apps/web-ui", BuildStrategy: "runtime", BuildOutputDirectory: "dist/web-ui", BuildConfiguration: "production"}, Delivery: config.DeliveryConfig{AppName: "web-ui", AppRepoURL: "https://git.example.test/apps/web-ui.git", AppRepoRef: "main", ProductionBranch: "production", ImageRepository: "registry.example.test/apps/web-ui", BuildStrategy: "runtime", BuildOutputDirectory: "dist/web-ui", BuildConfiguration: "production"},
} }
content, err := renderAppDelivery(cfg) content, err := renderAppDelivery(cfg)
@ -160,7 +155,7 @@ func TestGeneratedDeliveryUsesRuntimeImageWithoutStaticParameters(t *testing.T)
func TestGeneratedDeliveryInitializesStagingAndPromotesByPullRequest(t *testing.T) { func TestGeneratedDeliveryInitializesStagingAndPromotesByPullRequest(t *testing.T) {
cfg := config.Config{ cfg := config.Config{
Git: config.GitConfig{BaseURL: "https://git.example.test", Owner: "platform"}, Git: config.GitConfig{BaseURL: "https://git.example.test", Owner: "platform"},
Flux: config.FluxConfig{Branch: "main", ClusterDomain: "example.test", ManifestsRepo: "manifests"}, Flux: config.FluxConfig{Branch: "main", ManifestsRepo: "manifests"},
Delivery: config.DeliveryConfig{AppName: "web-ui", AppRepoURL: "https://git.example.test/apps/web-ui.git", AppRepoRef: "main", ProductionBranch: "production", ImageRepository: "registry.example.test/apps/web-ui", BuildStrategy: "static", BuildOutputDirectory: "dist", BuildConfiguration: "production"}, Delivery: config.DeliveryConfig{AppName: "web-ui", AppRepoURL: "https://git.example.test/apps/web-ui.git", AppRepoRef: "main", ProductionBranch: "production", ImageRepository: "registry.example.test/apps/web-ui", BuildStrategy: "static", BuildOutputDirectory: "dist", BuildConfiguration: "production"},
} }
content, err := renderAppDelivery(cfg) content, err := renderAppDelivery(cfg)
@ -190,10 +185,14 @@ func TestGeneratedDeliveryInitializesStagingAndPromotesByPullRequest(t *testing.
} }
} }
func TestRenderAppDeliveryRequiresCompleteConfig(t *testing.T) { func TestGenerateAppDeliveryRequiresCompleteConfig(t *testing.T) {
_, err := renderAppDelivery(config.Config{Git: config.GitConfig{BaseURL: "https://git.example.test"}, Delivery: config.DeliveryConfig{AppName: "legacy-app"}}) dir := t.TempDir()
if err == nil || !strings.Contains(err.Error(), "delivery buildStrategy") { err := GenerateAppDelivery(dir, config.Config{Git: config.GitConfig{BaseURL: "https://git.example.test"}, Delivery: config.DeliveryConfig{AppName: "legacy-app"}})
t.Fatalf("renderAppDelivery() error = %v, want incomplete delivery error", err) if err == nil || !strings.Contains(err.Error(), "delivery appName") {
t.Fatalf("GenerateAppDelivery() error = %v, want incomplete delivery error", err)
}
if _, statErr := os.Stat(filepath.Join(dir, ".tekton")); !os.IsNotExist(statErr) {
t.Fatal("GenerateAppDelivery() wrote delivery files before rejecting incomplete config")
} }
} }

View file

@ -7,27 +7,14 @@ import (
"io" "io"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"github.com/Pingu-Studio/MaidnCLI/internal/config" "github.com/Pingu-Studio/MaidnCLI/internal/config"
"github.com/Pingu-Studio/MaidnCLI/internal/forgejo" "github.com/Pingu-Studio/MaidnCLI/internal/forgejo"
"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) // publishes its delivery branch, registers it with Flux, then adds its webhook.
RemoteBranchRevision(string, string) (string, error)
PushRef(string, string, string, string) error
EnsureProtectedBranch(string, string) error
PublishRepositoryPullRequest(string, string, string, string, func(string) error) (bool, error)
EnsureWebhook(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 opens a reviewed central cluster-registration pull request.
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)
if err != nil { if err != nil {
@ -43,35 +30,56 @@ func OnboardApp(cfg config.Config, sourceDir string) error {
if sourceBranch != resolved.Delivery.AppRepoRef { if sourceBranch != resolved.Delivery.AppRepoRef {
return errors.New("--from branch must match delivery appRepoRef") return errors.New("--from branch must match delivery appRepoRef")
} }
sourceRevision, err := forgejo.BranchRevision(sourceDir, sourceBranch)
if err != nil {
return err
}
owner, repository, err := forgejo.RepositoryFromURL(resolved.Delivery.AppRepoURL) owner, repository, err := forgejo.RepositoryFromURL(resolved.Delivery.AppRepoURL)
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, "") if owner != resolved.Git.Owner {
if _, err := sourceManager.EnsureRepository(repository, "Application build input for Maidn CI/CD"); err != nil { 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 return err
} }
if err := publishInitialAppBranches(sourceManager, sourceDir, resolved.Delivery.AppRepoURL, sourceRevision, resolved.Delivery.AppRepoRef, resolved.Delivery.ProductionBranch); err != nil { 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 return err
} }
if err := sourceManager.EnsureProtectedBranch(repository, resolved.Delivery.ProductionBranch); err != nil { if err := publishInitialAppBranches(manager, 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 {
return fmt.Errorf("protect Forgejo production branch: %w", err) return fmt.Errorf("protect Forgejo production branch: %w", err)
} }
if err := ensurePlatformBranch(sourceManager, repository, resolved.Delivery.AppRepoURL, resolved.Delivery.AppName); err != nil { changed, err := manager.PublishDeliveryBranch(sourceDir, sourceBranch, resolved.Delivery.AppRepoURL, deliveryBranch, func(dir string) error {
if err := GenerateAppDelivery(dir, resolved); err != nil {
return err
}
return GenerateAppSecretAccess(dir, resolved)
})
if err != nil {
return err return err
} }
registrationBranch := registrationBranch(resolved.Delivery.AppName, sourceRevision) if changed {
clusterManager := newOnboardingRepoManager(resolved.Git.BaseURL, resolved.Git.Token, resolved.Git.Owner, resolved.Git.Username, "", "", resolved.Flux.Branch, "") if err := manager.EnsurePullRequest(repository, "feat: migrate delivery to Tekton", deliveryBranch, resolved.Delivery.AppRepoRef); err != nil {
if _, err := clusterManager.PublishRepositoryPullRequest(resolved.Flux.RepoName, "feat: register "+resolved.Delivery.AppName+" delivery", registrationBranch, resolved.Flux.Branch, func(dir string) error { return err
return registerAppInCluster(dir, resolved) }
}
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 { }); 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)
} }
@ -84,37 +92,21 @@ 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 := manager.TriggerWebhookTest(repository, webhookURL, resolved.Delivery.AppRepoRef); err != nil {
return fmt.Errorf("trigger Forgejo webhook test: %w", err)
}
return nil return nil
} }
func registrationBranch(appName, revision string) string { // publishInitialAppBranches establishes the immutable source baseline before delivery setup.
return "maidn/register-" + appName + "-" + revision[:12] func publishInitialAppBranches(manager *forgejo.RepoManager, sourceDir, targetURL, sourceBranch, targetBranch, productionBranch string) error {
} sourceRevision, err := forgejo.BranchRevision(sourceDir, sourceBranch)
func platformBranch(appName string) string {
return "maidn/platform-" + appName
}
func ensurePlatformBranch(manager onboardingRepoManager, repository, repositoryURL, appName string) error {
branch := platformBranch(appName)
revision, err := manager.RemoteBranchRevision(repositoryURL, branch)
if err != nil { if err != nil {
return fmt.Errorf("read Forgejo platform branch: %w", err) return err
} }
if revision == "" {
return fmt.Errorf("approved Forgejo platform branch %q must exist before central registration", branch)
}
if err := manager.EnsureProtectedBranch(repository, branch); err != nil {
return fmt.Errorf("protect Forgejo platform branch: %w", err)
}
return nil
}
// publishInitialAppBranches establishes the immutable source baseline before central registration.
func publishInitialAppBranches(manager onboardingRepoManager, sourceDir, targetURL, sourceRevision, targetBranch, productionBranch string) error {
mainRevision, err := manager.RemoteBranchRevision(targetURL, targetBranch) mainRevision, err := manager.RemoteBranchRevision(targetURL, targetBranch)
if err != nil { if err != nil {
return fmt.Errorf("read target base branch: %w", err) return fmt.Errorf("read target base branch: %w", err)
@ -151,10 +143,6 @@ func publishInitialAppBranches(manager onboardingRepoManager, sourceDir, targetU
// RegisterAppInCluster writes only the managed Flux registration for one app. // RegisterAppInCluster writes only the managed Flux registration for one app.
func RegisterAppInCluster(dir string, cfg config.Config) error { func RegisterAppInCluster(dir string, cfg config.Config) error {
return registerAppInCluster(dir, cfg)
}
func registerAppInCluster(dir string, cfg config.Config) error {
content, err := renderAppRegistration(cfg) content, err := renderAppRegistration(cfg)
if err != nil { if err != nil {
return err return err
@ -216,11 +204,7 @@ func renderAppRegistration(cfg config.Config) ([]byte, error) {
if err := config.ValidateDelivery(cfg); err != nil { if err := config.ValidateDelivery(cfg); err != nil {
return nil, err return nil, err
} }
secretAccess, err := renderAppSecretAccess(cfg) branch, err := forgejo.DeliveryBranch(cfg.Delivery.AppName, cfg.Delivery.AppRepoRef)
if err != nil {
return nil, err
}
delivery, err := renderAppDelivery(cfg)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -236,25 +220,67 @@ spec:
name: forgejo-flux-credentials name: forgejo-flux-credentials
ref: ref:
branch: %s branch: %s
`, cfg.Delivery.AppName, cfg.Delivery.AppRepoURL, platformBranch(cfg.Delivery.AppName)) ---
if len(secretAccess) != 0 { apiVersion: kustomize.toolkit.fluxcd.io/v1
content += "---\n" + string(secretAccess) 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)
if hasRuntimeSecretGrant(cfg) {
content += fmt.Sprintf(`---
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: %s-secrets
namespace: flux-system
spec:
interval: 5m
path: ./.maidn
prune: true
wait: true
dependsOn:
- name: external-secrets-config
sourceRef:
kind: GitRepository
name: %s
`, cfg.Delivery.AppName, cfg.Delivery.AppName)
} }
return append([]byte(content+"---\n"), delivery...), nil return []byte(content), nil
} }
// renderAppSecretAccess renders only central OpenBao references, never secret values. func hasRuntimeSecretGrant(cfg config.Config) bool {
func renderAppSecretAccess(cfg config.Config) ([]byte, error) { for _, grant := range cfg.SecretGrants {
if err := config.ValidateSecretGrants(cfg.SecretGrants); err != nil { if grant.Application == cfg.Delivery.AppName && grant.Consumer == "runtime" {
return nil, err return true
}
} }
var manifests []string return false
}
// GenerateAppSecretAccess renders only OpenBao references, never secret values.
func GenerateAppSecretAccess(dir string, cfg config.Config) error {
if err := config.ValidateSecretGrants(cfg.SecretGrants); err != nil {
return err
}
var manifests string
for _, grant := range cfg.SecretGrants { for _, grant := range cfg.SecretGrants {
if grant.Application != cfg.Delivery.AppName || grant.Consumer != "runtime" { if grant.Application != cfg.Delivery.AppName || grant.Consumer != "runtime" {
continue continue
} }
name := cfg.Delivery.AppName + "-runtime-" + grant.Environment name := cfg.Delivery.AppName + "-runtime-" + grant.Environment
manifest := fmt.Sprintf(`apiVersion: v1 manifests += fmt.Sprintf(`apiVersion: v1
kind: ServiceAccount kind: ServiceAccount
metadata: metadata:
name: maidn-%s name: maidn-%s
@ -294,11 +320,21 @@ spec:
data: data:
`, name, grant.Environment, name, grant.Environment, name, name, name, grant.Environment, name, name) `, name, grant.Environment, name, grant.Environment, name, name, name, grant.Environment, name, name)
for _, secret := range grant.Secrets { for _, secret := range grant.Secrets {
manifest += fmt.Sprintf(" - secretKey: %s\n remoteRef:\n key: apps/%s/%s\n property: value\n", secret, grant.Application, secret) manifests += fmt.Sprintf(" - secretKey: %s\n remoteRef:\n key: apps/%s/%s\n property: value\n", secret, grant.Application, secret)
} }
manifests = append(manifests, manifest) manifests += "---\n"
} }
return []byte(strings.Join(manifests, "---\n")), nil if manifests == "" {
return nil
}
maidnDir := filepath.Join(dir, ".maidn")
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 {
return err
}
return os.WriteFile(filepath.Join(maidnDir, "secret-access.yaml"), []byte(manifests), 0644)
} }
func readRegularFile(path string) ([]byte, error) { func readRegularFile(path string) ([]byte, error) {

View file

@ -2,7 +2,6 @@ package bootstrap
import ( import (
"bytes" "bytes"
"errors"
"io" "io"
"os" "os"
"os/exec" "os/exec"
@ -38,7 +37,45 @@ func onboardingConfig() config.Config {
} }
} }
func TestRegisterAppInClusterRendersCentralDeliveryResources(t *testing.T) { 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"))
normalized := strings.ReplaceAll(string(pipeline), "\r\n", "\n")
if err != nil || !strings.Contains(normalized, "https://git.example.test/test-org-2/web-ui.git") || !strings.Contains(normalized, "name: HOME\n value: /tekton/home") || !strings.Contains(normalized, "grep -qxF \" namespace: staging\"") || !strings.Contains(normalized, "namespace: production") || !strings.Contains(normalized, "name: web-ui-wait-delivery") || !strings.Contains(normalized, "name: web-ui-report-delivery") || !strings.Contains(normalized, "forgejo-delivery-status") || !strings.Contains(normalized, "$(context.pipelineRun.name)") || !strings.Contains(normalized, "Promotion PR opened or updated") || strings.Contains(normalized, "taskRunTemplate:") {
t.Fatalf("target-specific pipeline = %q, %v", pipeline, err)
}
decoder := yaml.NewDecoder(bytes.NewReader(pipeline))
for {
var document yaml.Node
err := decoder.Decode(&document)
if err == io.EOF {
break
}
if err != nil {
t.Fatalf("generated pipeline YAML: %v", 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() dir := t.TempDir()
tektonDir := filepath.Join(dir, "base", "tekton") tektonDir := filepath.Join(dir, "base", "tekton")
if err := os.MkdirAll(tektonDir, 0755); err != nil { if err := os.MkdirAll(tektonDir, 0755); err != nil {
@ -51,7 +88,7 @@ func TestRegisterAppInClusterRendersCentralDeliveryResources(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
registration, err := os.ReadFile(filepath.Join(tektonDir, "apps", "web-ui.yaml")) registration, err := os.ReadFile(filepath.Join(tektonDir, "apps", "web-ui.yaml"))
if err != nil || !strings.Contains(string(registration), "branch: maidn/platform-web-ui") || !strings.Contains(string(registration), "secretRef:\n name: forgejo-flux-credentials") || !strings.Contains(string(registration), "kind: Task") || !strings.Contains(string(registration), "name: web-ui-update-manifest") || !strings.Contains(string(registration), "kind: Pipeline") || !strings.Contains(string(registration), "name: web-ui\n") || strings.Contains(string(registration), "maidn/delivery-") || strings.Contains(string(registration), "path: ./.tekton") || strings.Contains(string(registration), "apiVersion: kustomize.toolkit.fluxcd.io") { 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) 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"} { for path, resource := range map[string]string{filepath.Join(tektonDir, "kustomization.yaml"): "apps", filepath.Join(tektonDir, "apps", "kustomization.yaml"): "web-ui.yaml"} {
@ -60,17 +97,6 @@ func TestRegisterAppInClusterRendersCentralDeliveryResources(t *testing.T) {
t.Fatalf("Kustomization %s does not include %s: %q, %v", path, resource, content, err) t.Fatalf("Kustomization %s does not include %s: %q, %v", path, resource, content, err)
} }
} }
decoder := yaml.NewDecoder(bytes.NewReader(registration))
for {
var document yaml.Node
err := decoder.Decode(&document)
if err == io.EOF {
break
}
if err != nil {
t.Fatalf("registration YAML: %v", err)
}
}
if err := os.WriteFile(filepath.Join(tektonDir, "apps", "web-ui.yaml"), bytes.ReplaceAll(registration, []byte("\n"), []byte("\r\n")), 0644); err != nil { if err := os.WriteFile(filepath.Join(tektonDir, "apps", "web-ui.yaml"), bytes.ReplaceAll(registration, []byte("\n"), []byte("\r\n")), 0644); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@ -85,11 +111,19 @@ func TestRegisterAppInClusterRendersCentralDeliveryResources(t *testing.T) {
} }
} }
func TestRenderAppRegistrationIncludesOnlyDeclaredRuntimeSecretAccess(t *testing.T) { func TestGenerateAppSecretAccessRendersOnlyDeclaredRuntimeSecrets(t *testing.T) {
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 := GenerateAppSecretAccess(dir, cfg); err != nil {
t.Fatal(err)
}
access, err := os.ReadFile(filepath.Join(dir, ".maidn", "secret-access.yaml"))
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)
}
registration, err := renderAppRegistration(cfg) registration, err := renderAppRegistration(cfg)
if err != nil || !strings.Contains(string(registration), "namespace: staging") || !strings.Contains(string(registration), "key: apps/web-ui/api-key") || strings.Contains(string(registration), "shared/payments") || strings.Contains(string(registration), ".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)
} }
} }
@ -112,7 +146,7 @@ func TestPublishInitialAppBranchesCreatesAndPreservesProduction(t *testing.T) {
onboardingGit(t, "", "init", "--bare", target) onboardingGit(t, "", "init", "--bare", target)
manager := forgejo.NewRepoManager("https://git.example.test", "", "owner", "", "", "", "main", "") manager := forgejo.NewRepoManager("https://git.example.test", "", "owner", "", "", "", "main", "")
if err := publishInitialAppBranches(manager, source, target, sourceRevision, "main", "production"); err != nil { if err := publishInitialAppBranches(manager, source, target, "source", "main", "production"); err != nil {
t.Fatal(err) t.Fatal(err)
} }
for _, branch := range []string{"main", "production"} { for _, branch := range []string{"main", "production"} {
@ -139,7 +173,7 @@ func TestPublishInitialAppBranchesCreatesAndPreservesProduction(t *testing.T) {
existingProduction := onboardingGit(t, production, "rev-parse", "production") existingProduction := onboardingGit(t, production, "rev-parse", "production")
onboardingGit(t, production, "push", preservedTarget, "production:production") onboardingGit(t, production, "push", preservedTarget, "production:production")
if err := publishInitialAppBranches(manager, source, preservedTarget, sourceRevision, "main", "production"); err != nil { if err := publishInitialAppBranches(manager, source, preservedTarget, "source", "main", "production"); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if got := onboardingGit(t, "", "--git-dir", preservedTarget, "rev-parse", "refs/heads/production"); got != existingProduction { if got := onboardingGit(t, "", "--git-dir", preservedTarget, "rev-parse", "refs/heads/production"); got != existingProduction {
@ -149,139 +183,10 @@ func TestPublishInitialAppBranchesCreatesAndPreservesProduction(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
onboardingGit(t, source, "commit", "-am", "updated source") onboardingGit(t, source, "commit", "-am", "updated source")
if err := publishInitialAppBranches(manager, source, preservedTarget, sourceRevision, "main", "production"); err != nil { if err := publishInitialAppBranches(manager, source, preservedTarget, "source", "main", "production"); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if got := onboardingGit(t, "", "--git-dir", preservedTarget, "rev-parse", "refs/heads/main"); got != sourceRevision { if got := onboardingGit(t, "", "--git-dir", preservedTarget, "rev-parse", "refs/heads/main"); got != sourceRevision {
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
remoteRevisions map[string]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)
if m.remoteRevisions != nil {
return m.remoteRevisions[branch], nil
}
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) 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 TestOnboardAppUsesCanonicalSourceAndCentralClusterManagers(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",
"remote https://git.example.test/Maidn/maidn-e2e-web.git:maidn/platform-web-ui",
"protect Maidn/maidn-e2e-web:maidn/platform-web-ui",
} {
if !strings.Contains(sourceCalls, want) {
t.Fatalf("source manager calls = %q, missing %q", sourceCalls, want)
}
}
if strings.Contains(sourceCalls, "delivery") || strings.Contains(sourceCalls, "ensure-pr") || strings.Contains(sourceCalls, "merge-pr") {
t.Fatalf("source manager published a delivery change: %q", sourceCalls)
}
sourceRevision := onboardingGit(t, source, "rev-parse", "main")
if got := strings.Join(clusterManager.calls, "\n"); got != "register test-org-2/cluster:maidn/register-web-ui-"+sourceRevision[:12] {
t.Fatalf("cluster manager calls = %q", got)
}
}
func TestEnsurePlatformBranchRequiresExistingBranch(t *testing.T) {
manager := &onboardingManagerFake{owner: "Maidn", remoteRevisions: map[string]string{}}
err := ensurePlatformBranch(manager, "maidn-e2e-web", "https://git.example.test/Maidn/maidn-e2e-web.git", "web-ui")
if err == nil || !strings.Contains(err.Error(), "maidn/platform-web-ui") {
t.Fatalf("ensurePlatformBranch() error = %v", err)
}
if strings.Contains(strings.Join(manager.calls, "\n"), "protect") {
t.Fatalf("missing platform branch was protected: %q", manager.calls)
}
}

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

@ -114,26 +114,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

@ -97,7 +97,6 @@ type accessToken struct {
} }
var copyGit = runGit var copyGit = runGit
var hasRemoteBranch = (*RepoManager).HasRemoteBranch
func (e *APIError) Error() string { func (e *APIError) Error() string {
return fmt.Sprintf("forgejo returned %s", e.Status) return fmt.Sprintf("forgejo returned %s", e.Status)
@ -420,7 +419,7 @@ func (rm *RepoManager) CreatePullRequest(repo, title, head, base string) error {
if err != nil { if err != nil {
return err return err
} }
if status != http.StatusCreated && status != http.StatusUnprocessableEntity && status != http.StatusConflict { if status != http.StatusCreated && status != http.StatusUnprocessableEntity {
return fmt.Errorf("unexpected Forgejo pull request status %d", status) return fmt.Errorf("unexpected Forgejo pull request status %d", status)
} }
return nil return nil
@ -509,6 +508,70 @@ func (rm *RepoManager) PushRef(dir, repoURL, sourceRef, targetBranch string) err
return runGit(dir, environment, "push", repoURL, sourceRef+":refs/heads/"+targetBranch) return runGit(dir, environment, "push", repoURL, sourceRef+":refs/heads/"+targetBranch)
} }
// DeliveryBranch returns the dedicated branch that carries generated delivery content.
func DeliveryBranch(appName, baseBranch string) (string, error) {
branch := "maidn/delivery-" + appName
if appName == "" || branch == baseBranch {
return "", fmt.Errorf("delivery branch and configured base branch must differ")
}
return branch, nil
}
// PublishDeliveryBranch generates and commits delivery content in a temporary clone.
func (rm *RepoManager) PublishDeliveryBranch(sourceDir, sourceBranch, repoURL, deliveryBranch string, generate func(string) error) (bool, error) {
if sourceDir == "" || sourceBranch == "" || deliveryBranch == "" || deliveryBranch == rm.Branch {
return false, fmt.Errorf("delivery source branch and dedicated delivery branch are required and must differ from the base branch")
}
hasBranch, err := rm.HasRemoteBranch(repoURL, deliveryBranch)
if err != nil {
return false, err
}
temporary, err := os.MkdirTemp("", "maidn-delivery-*")
if err != nil {
return false, err
}
defer os.RemoveAll(temporary)
cleanupAskPass, environment, err := rm.gitEnvironment()
if err != nil {
return false, err
}
defer cleanupAskPass()
if hasBranch {
if err := runGit("", environment, "clone", "--branch", deliveryBranch, repoURL, temporary); err != nil {
return false, err
}
} else if err := runGit("", os.Environ(), "clone", "--no-local", "--branch", sourceBranch, sourceDir, temporary); err != nil {
return false, err
}
if err := runGit(temporary, environment, "checkout", "-B", deliveryBranch); err != nil {
return false, err
}
if err := generate(temporary); err != nil {
return false, err
}
if err := runGit(temporary, environment, "add", ".tekton"); err != nil {
return false, err
}
changed, err := gitDiffQuiet(temporary, environment, "--cached")
if err != nil {
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 false, err
}
}
}
if !changed {
return false, nil
}
if err := rm.PushBranch(temporary, repoURL, deliveryBranch); err != nil {
return false, err
}
return true, nil
}
// PublishRepositoryPullRequest applies a managed change on a dedicated branch. // PublishRepositoryPullRequest applies a managed change on a dedicated branch.
// An existing branch is accepted only when it has exactly one open pull request. // 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) { func (rm *RepoManager) PublishRepositoryPullRequest(repo, title, branch, base string, change func(string) error) (bool, error) {
@ -516,19 +579,10 @@ func (rm *RepoManager) PublishRepositoryPullRequest(repo, title, branch, base st
return false, errors.New("repository pull request requires distinct non-empty branches") return false, errors.New("repository pull request requires distinct non-empty branches")
} }
repoURL := CloneURL(rm.BaseURL, rm.Owner, repo) repoURL := CloneURL(rm.BaseURL, rm.Owner, repo)
hasBranch, err := hasRemoteBranch(rm, repoURL, branch) hasBranch, err := rm.HasRemoteBranch(repoURL, branch)
if err != nil { if err != nil {
return false, err return false, err
} }
if hasBranch {
open, err := rm.HasOpenPullRequest(repo, branch)
if err != nil {
return false, err
}
if !open {
return false, fmt.Errorf("Forgejo branch %q exists without exactly one open pull request; refusing to reuse it", branch)
}
}
temporary, err := os.MkdirTemp("", "maidn-registration-*") temporary, err := os.MkdirTemp("", "maidn-registration-*")
if err != nil { if err != nil {
return false, err return false, err
@ -569,11 +623,14 @@ func (rm *RepoManager) PublishRepositoryPullRequest(repo, title, branch, base st
if !open { if !open {
return false, nil return false, nil
} }
return true, nil return true, rm.MergePullRequest(repo, branch)
} }
if err := rm.EnsurePullRequest(repo, title, branch, base); err != nil { if err := rm.EnsurePullRequest(repo, title, branch, base); err != nil {
return false, err return false, err
} }
if err := rm.MergePullRequest(repo, branch); err != nil {
return false, err
}
return true, nil return true, nil
} }
@ -790,10 +847,10 @@ func (rm *RepoManager) TriggerWebhookTest(repo, webhookURL, branch string) error
return nil return nil
} }
// EnsureProtectedBranch disables direct pushes to a managed branch. // EnsureProtectedBranch disables direct pushes to the configured production branch.
func (rm *RepoManager) EnsureProtectedBranch(repo, branch string) error { func (rm *RepoManager) EnsureProtectedBranch(repo, branch string) error {
if repo == "" || branch == "" { if repo == "" || branch == "" {
return errors.New("Forgejo repository and branch are required") return errors.New("Forgejo repository and production branch are required")
} }
endpoint := fmt.Sprintf("%s/api/v1/repos/%s/%s/branch_protections", rm.BaseURL, rm.Owner, repo) endpoint := fmt.Sprintf("%s/api/v1/repos/%s/%s/branch_protections", rm.BaseURL, rm.Owner, repo)
var protections []branchProtection var protections []branchProtection
@ -811,11 +868,11 @@ func (rm *RepoManager) EnsureProtectedBranch(repo, branch string) error {
} }
} }
if len(matching) > 1 { if len(matching) > 1 {
return fmt.Errorf("multiple Forgejo branch protections match branch %q", branch) return fmt.Errorf("multiple Forgejo branch protections match production branch %q", branch)
} }
if len(matching) == 1 { if len(matching) == 1 {
if matching[0].EnablePush || matching[0].EnablePushWhitelist { if matching[0].EnablePush || matching[0].EnablePushWhitelist {
return fmt.Errorf("Forgejo branch %q permits direct pushes", branch) return fmt.Errorf("Forgejo production branch %q permits direct pushes", branch)
} }
return nil return nil
} }

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 {
@ -400,34 +378,6 @@ func TestHasRemoteBranchReturnsFalseForMissingBranch(t *testing.T) {
} }
} }
func TestPublishRepositoryPullRequestRejectsExistingBranchWithoutOpenPullRequest(t *testing.T) {
original := hasRemoteBranch
t.Cleanup(func() { hasRemoteBranch = original })
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
if request.Method != http.MethodGet || request.URL.Path != "/api/v1/repos/owner/cluster/pulls" || request.URL.Query().Get("state") != "open" || request.URL.Query().Get("head") != "maidn/register-web-ui-deadbeefcafe" {
t.Fatalf("unexpected pull request lookup: %s %s", request.Method, request.URL.String())
}
_ = json.NewEncoder(writer).Encode([]pullRequest{})
}))
defer server.Close()
manager := NewRepoManager(server.URL, "test-token", "owner", "user", "", "", "main", "")
manager.HTTPClient = server.Client()
hasRemoteBranch = func(got *RepoManager, repoURL, branch string) (bool, error) {
if got != manager || repoURL != CloneURL(server.URL, "owner", "cluster") || branch != "maidn/register-web-ui-deadbeefcafe" {
t.Fatalf("unexpected remote branch lookup: %q %q", repoURL, branch)
}
return true, nil
}
_, err := manager.PublishRepositoryPullRequest("cluster", "register web-ui", "maidn/register-web-ui-deadbeefcafe", "main", func(string) error {
t.Fatal("change ran for a stale registration branch")
return nil
})
if err == nil || !strings.Contains(err.Error(), "without exactly one open pull request") {
t.Fatalf("PublishRepositoryPullRequest() error = %v", err)
}
}
func TestMergePullRequest(t *testing.T) { func TestMergePullRequest(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) {
switch request.Method { switch request.Method {
@ -454,21 +404,6 @@ func TestMergePullRequest(t *testing.T) {
} }
} }
func TestCreatePullRequestAcceptsExistingConflict(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
if request.Method != http.MethodPost || request.URL.Path != "/api/v1/repos/owner/cluster/pulls" {
t.Fatalf("unexpected pull request request: %s %s", request.Method, request.URL.Path)
}
writer.WriteHeader(http.StatusConflict)
}))
defer server.Close()
manager := NewRepoManager(server.URL, "token", "owner", "user", "manifests", "cluster", "main", "maidn/bootstrap-test")
manager.HTTPClient = server.Client()
if err := manager.CreatePullRequest("cluster", "title", "maidn/bootstrap-test", "main"); err != nil {
t.Fatal(err)
}
}
func TestEnsurePullRequestChecksExactOpenBranchBeforeCreating(t *testing.T) { func TestEnsurePullRequestChecksExactOpenBranchBeforeCreating(t *testing.T) {
requests := 0 requests := 0
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {