Compare commits
No commits in common. "main" and "fix/publish-runtime-secret-access" have entirely different histories.
main
...
fix/publis
|
|
@ -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`.
|
||||||
|
|
|
||||||
21
README.md
21
README.md
|
|
@ -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>` opens reviewed source-delivery and cluster-registration PRs; see `docs/operations.md`.
|
||||||
- `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
|
||||||
|
|
|
||||||
|
|
@ -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,7 +50,7 @@ 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")
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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.
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
|
||||||
|
|
|
||||||
|
|
@ -43,19 +43,10 @@ creates the Gateway, Tekton, and Tekton Triggers platform resources using
|
||||||
go run . bootstrap init --config <private-bootstrap-config> --organization <owner> --create-organization
|
go run . bootstrap init --config <private-bootstrap-config> --organization <owner> --create-organization
|
||||||
```
|
```
|
||||||
|
|
||||||
The source-owned onboarding implementation is retired. Central onboarding
|
Use a clean checkout on `delivery.appRepoRef`. The app repository URL must be
|
||||||
creates a reviewed cluster registration only; it never writes `.tekton` or
|
the canonical source owner, such as `Maidn/<app>.git`; `test-org-2` is only the
|
||||||
`.maidn` resources to an application repository. Before merging that
|
execution owner. Set per-app static build values with command-line overrides,
|
||||||
registration, a platform operator must create the corresponding
|
not by rewriting the private default:
|
||||||
`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> `
|
go run . app onboard --config <private-bootstrap-config> --from <clean-checkout> `
|
||||||
|
|
@ -65,13 +56,16 @@ go run . app onboard --config <private-bootstrap-config> --from <clean-checkout>
|
||||||
```
|
```
|
||||||
|
|
||||||
For a runtime build, set `--build-strategy runtime`; static-only output options
|
For a runtime build, set `--build-strategy runtime`; static-only output options
|
||||||
remain harmless. Central onboarding creates the reviewed cluster-registration
|
remain harmless. Onboarding opens, but never merges, a source delivery PR and a
|
||||||
change after the platform package exists. The cluster repository owns
|
cluster registration PR. It registers the Forgejo hook but does not emit a test
|
||||||
`base/tekton/apps/<app>.yaml`; the application repository remains a build input
|
delivery. Review and merge the source delivery PR first, then the cluster
|
||||||
only. The generic EventListener dispatches by Forgejo repository name.
|
registration PR. The registration is stored in
|
||||||
|
`base/tekton/apps/<app>.yaml`; Flux loads the source-owned `.tekton` path and
|
||||||
|
the generic EventListener dispatches by Forgejo repository name.
|
||||||
|
|
||||||
Existing source-owned registrations are migrated in separate reviewed cluster
|
Existing registrations are never overwritten. Move a legacy source registration
|
||||||
repository PRs. Never overwrite an unmanaged registration.
|
to its canonical source in a separate reviewed cluster-repository PR before
|
||||||
|
onboarding that application.
|
||||||
|
|
||||||
After Flux reports the app Kustomization Ready, use Forgejo's hook test endpoint
|
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
|
against a non-`main` ref and inspect the resulting PipelineRun. The command and
|
||||||
|
|
@ -86,11 +80,14 @@ by default. Shared credentials are appropriate only for the shared environment
|
||||||
database. Use a dedicated service only when isolation, lifecycle, or storage
|
database. Use a dedicated service only when isolation, lifecycle, or storage
|
||||||
requirements demand it.
|
requirements demand it.
|
||||||
|
|
||||||
The cluster repository owns reviewed dedicated resources and runtime secret
|
An application can carry reviewed dedicated resources in
|
||||||
access. Application repositories must not carry active `.maidn` resources.
|
`.maidn/kustomization.yaml`. Onboarding registers that path as the app's
|
||||||
Runtime references never contain credential values. Preview namespaces do not
|
`<app>-addons` Flux Kustomization without changing the generated `.tekton`
|
||||||
receive staging or production runtime credentials; preview-safe configuration is
|
files. Runtime secret grants also generate the app's `.maidn/secret-access.yaml`
|
||||||
defined in the protected platform chart branch.
|
there. Add-ons must declare their namespace explicitly and contain references,
|
||||||
|
never credential values. Preview namespaces do not receive staging or production
|
||||||
|
runtime credentials; preview-safe configuration is the application chart's
|
||||||
|
responsibility.
|
||||||
|
|
||||||
Declare runtime secret access in the private configuration and run normal
|
Declare runtime secret access in the private configuration and run normal
|
||||||
bootstrap reconciliation to create its policy and role. Set values only with
|
bootstrap reconciliation to create its policy and role. Set values only with
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ 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"
|
||||||
|
|
@ -19,6 +18,8 @@ type onboardingRepoManager interface {
|
||||||
RemoteBranchRevision(string, string) (string, error)
|
RemoteBranchRevision(string, string) (string, error)
|
||||||
PushRef(string, string, string, string) error
|
PushRef(string, string, string, string) error
|
||||||
EnsureProtectedBranch(string, string) error
|
EnsureProtectedBranch(string, string) error
|
||||||
|
PublishDeliveryBranch(string, string, string, string, func(string) error) (bool, error)
|
||||||
|
EnsurePullRequest(string, string, string, string) error
|
||||||
PublishRepositoryPullRequest(string, string, string, string, func(string) error) (bool, error)
|
PublishRepositoryPullRequest(string, string, string, string, func(string) error) (bool, error)
|
||||||
EnsureWebhook(string, string, string) error
|
EnsureWebhook(string, string, string) error
|
||||||
}
|
}
|
||||||
|
|
@ -27,7 +28,7 @@ var newOnboardingRepoManager = func(baseURL, token, owner, username, manifestsRe
|
||||||
return forgejo.NewRepoManager(baseURL, token, owner, username, manifestsRepo, fluxRepo, branch, migrationBranch)
|
return forgejo.NewRepoManager(baseURL, token, owner, username, manifestsRepo, fluxRepo, branch, migrationBranch)
|
||||||
}
|
}
|
||||||
|
|
||||||
// OnboardApp opens a reviewed central cluster-registration pull request.
|
// OnboardApp opens reviewed source-delivery and cluster-registration pull requests.
|
||||||
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,7 +44,7 @@ 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)
|
hasAddons, err := hasMaidnAddons(sourceDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -51,23 +52,39 @@ func OnboardApp(cfg config.Config, sourceDir string) error {
|
||||||
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, "")
|
deliveryBranch, err := forgejo.DeliveryBranch(resolved.Delivery.AppName, resolved.Delivery.AppRepoRef)
|
||||||
if _, err := sourceManager.EnsureRepository(repository, "Application build input for Maidn CI/CD"); err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := publishInitialAppBranches(sourceManager, sourceDir, resolved.Delivery.AppRepoURL, sourceRevision, resolved.Delivery.AppRepoRef, resolved.Delivery.ProductionBranch); err != nil {
|
sourceManager := newOnboardingRepoManager(resolved.Git.BaseURL, resolved.Git.Token, owner, resolved.Git.Username, "", "", resolved.Delivery.AppRepoRef, "")
|
||||||
|
if _, err := sourceManager.EnsureRepository(repository, "Application source for Maidn CI/CD delivery"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := publishInitialAppBranches(sourceManager, sourceDir, resolved.Delivery.AppRepoURL, sourceBranch, resolved.Delivery.AppRepoRef, resolved.Delivery.ProductionBranch); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := sourceManager.EnsureProtectedBranch(repository, resolved.Delivery.ProductionBranch); err != nil {
|
if err := sourceManager.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 := sourceManager.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 {
|
||||||
|
if err := sourceManager.EnsurePullRequest(repository, "feat: migrate delivery to Tekton", deliveryBranch, resolved.Delivery.AppRepoRef); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
registrationBranch := "maidn/register-" + resolved.Delivery.AppName
|
||||||
clusterManager := newOnboardingRepoManager(resolved.Git.BaseURL, resolved.Git.Token, resolved.Git.Owner, resolved.Git.Username, "", "", resolved.Flux.Branch, "")
|
clusterManager := newOnboardingRepoManager(resolved.Git.BaseURL, resolved.Git.Token, resolved.Git.Owner, resolved.Git.Username, "", "", resolved.Flux.Branch, "")
|
||||||
if _, err := clusterManager.PublishRepositoryPullRequest(resolved.Flux.RepoName, "feat: register "+resolved.Delivery.AppName+" delivery", registrationBranch, resolved.Flux.Branch, func(dir string) error {
|
if _, err := clusterManager.PublishRepositoryPullRequest(resolved.Flux.RepoName, "feat: register "+resolved.Delivery.AppName+" delivery", registrationBranch, resolved.Flux.Branch, func(dir string) error {
|
||||||
return registerAppInCluster(dir, resolved)
|
return registerAppInCluster(dir, resolved, hasAddons || hasRuntimeSecretGrant(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)
|
||||||
}
|
}
|
||||||
|
|
@ -90,31 +107,20 @@ func OnboardApp(cfg config.Config, sourceDir string) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func registrationBranch(appName, revision string) string {
|
func hasMaidnAddons(dir string) (bool, error) {
|
||||||
return "maidn/register-" + appName + "-" + revision[:12]
|
_, err := readRegularFile(filepath.Join(dir, ".maidn", "kustomization.yaml"))
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return err == nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func platformBranch(appName string) string {
|
// publishInitialAppBranches establishes the immutable source baseline before delivery setup.
|
||||||
return "maidn/platform-" + appName
|
func publishInitialAppBranches(manager onboardingRepoManager, sourceDir, targetURL, sourceBranch, targetBranch, productionBranch string) error {
|
||||||
}
|
sourceRevision, err := forgejo.BranchRevision(sourceDir, sourceBranch)
|
||||||
|
|
||||||
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,11 +157,11 @@ 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)
|
return registerAppInCluster(dir, cfg, hasRuntimeSecretGrant(cfg))
|
||||||
}
|
}
|
||||||
|
|
||||||
func registerAppInCluster(dir string, cfg config.Config) error {
|
func registerAppInCluster(dir string, cfg config.Config, hasAddons bool) error {
|
||||||
content, err := renderAppRegistration(cfg)
|
content, err := renderAppRegistrationWithAddons(cfg, hasAddons)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -213,14 +219,14 @@ func registerAppInCluster(dir string, cfg config.Config) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderAppRegistration(cfg config.Config) ([]byte, error) {
|
func renderAppRegistration(cfg config.Config) ([]byte, error) {
|
||||||
|
return renderAppRegistrationWithAddons(cfg, hasRuntimeSecretGrant(cfg))
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderAppRegistrationWithAddons(cfg config.Config, hasAddons bool) ([]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 +242,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 hasAddons {
|
||||||
|
content += fmt.Sprintf(`---
|
||||||
|
apiVersion: kustomize.toolkit.fluxcd.io/v1
|
||||||
|
kind: Kustomization
|
||||||
|
metadata:
|
||||||
|
name: %s-addons
|
||||||
|
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 +342,35 @@ 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
|
||||||
|
}
|
||||||
|
kustomization := filepath.Join(maidnDir, "kustomization.yaml")
|
||||||
|
content := []byte("apiVersion: kustomize.config.k8s.io/v1beta1\nkind: Kustomization\nresources:\n - secret-access.yaml\n")
|
||||||
|
if _, err := os.Lstat(kustomization); err == nil {
|
||||||
|
content, err = readRegularFile(kustomization)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
content, err = addKustomizationResource(content, "secret-access.yaml")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else if !os.IsNotExist(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(kustomization, content, 0644); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(filepath.Join(maidnDir, "secret-access.yaml"), []byte(manifests), 0644)
|
||||||
}
|
}
|
||||||
|
|
||||||
func readRegularFile(path string) ([]byte, error) {
|
func readRegularFile(path string) ([]byte, error) {
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,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 +89,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"} {
|
||||||
|
|
@ -85,15 +123,56 @@ 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 := os.Mkdir(filepath.Join(dir, ".maidn"), 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, ".maidn", "kustomization.yaml"), []byte("apiVersion: kustomize.config.k8s.io/v1beta1\nkind: Kustomization\nresources:\n - database.yaml\n"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := GenerateAppSecretAccess(dir, cfg); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
kustomization, err := os.ReadFile(filepath.Join(dir, ".maidn", "kustomization.yaml"))
|
||||||
|
if err != nil || !strings.Contains(string(kustomization), "database.yaml") || !strings.Contains(string(kustomization), "secret-access.yaml") {
|
||||||
|
t.Fatalf("secret Kustomization = %q, %v", kustomization, err)
|
||||||
|
}
|
||||||
registration, err := renderAppRegistration(cfg)
|
registration, err := renderAppRegistration(cfg)
|
||||||
if err != nil || !strings.Contains(string(registration), "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-addons") || !strings.Contains(string(registration), "path: ./.maidn") {
|
||||||
t.Fatalf("secret registration = %q, %v", registration, err)
|
t.Fatalf("secret registration = %q, %v", registration, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRenderAppRegistrationIncludesSourceAddons(t *testing.T) {
|
||||||
|
registration, err := renderAppRegistrationWithAddons(onboardingConfig(), true)
|
||||||
|
if err != nil || !strings.Contains(string(registration), "name: web-ui-addons") || !strings.Contains(string(registration), "path: ./.maidn") || !strings.Contains(string(registration), "external-secrets-config") {
|
||||||
|
t.Fatalf("addon registration = %q, %v", registration, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHasMaidnAddons(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
if hasAddons, err := hasMaidnAddons(dir); err != nil || hasAddons {
|
||||||
|
t.Fatalf("absent add-ons = %t, %v", hasAddons, err)
|
||||||
|
}
|
||||||
|
if err := os.Mkdir(filepath.Join(dir, ".maidn"), 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, ".maidn", "kustomization.yaml"), []byte("apiVersion: kustomize.config.k8s.io/v1beta1\nkind: Kustomization\nresources:\n"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if hasAddons, err := hasMaidnAddons(dir); err != nil || !hasAddons {
|
||||||
|
t.Fatalf("present add-ons = %t, %v", hasAddons, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestPublishInitialAppBranchesCreatesAndPreservesProduction(t *testing.T) {
|
func TestPublishInitialAppBranchesCreatesAndPreservesProduction(t *testing.T) {
|
||||||
source := filepath.Join(t.TempDir(), "source")
|
source := filepath.Join(t.TempDir(), "source")
|
||||||
target := filepath.Join(t.TempDir(), "target.git")
|
target := filepath.Join(t.TempDir(), "target.git")
|
||||||
|
|
@ -112,7 +191,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 +218,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,7 +228,7 @@ 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 {
|
||||||
|
|
@ -158,9 +237,8 @@ func TestPublishInitialAppBranchesCreatesAndPreservesProduction(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
type onboardingManagerFake struct {
|
type onboardingManagerFake struct {
|
||||||
owner string
|
owner string
|
||||||
calls []string
|
calls []string
|
||||||
remoteRevisions map[string]string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *onboardingManagerFake) EnsureRepository(repo, _ string) (bool, error) {
|
func (m *onboardingManagerFake) EnsureRepository(repo, _ string) (bool, error) {
|
||||||
|
|
@ -170,9 +248,6 @@ func (m *onboardingManagerFake) EnsureRepository(repo, _ string) (bool, error) {
|
||||||
|
|
||||||
func (m *onboardingManagerFake) RemoteBranchRevision(targetURL, branch string) (string, error) {
|
func (m *onboardingManagerFake) RemoteBranchRevision(targetURL, branch string) (string, error) {
|
||||||
m.calls = append(m.calls, "remote "+targetURL+":"+branch)
|
m.calls = append(m.calls, "remote "+targetURL+":"+branch)
|
||||||
if m.remoteRevisions != nil {
|
|
||||||
return m.remoteRevisions[branch], nil
|
|
||||||
}
|
|
||||||
return "existing", nil
|
return "existing", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -186,6 +261,16 @@ func (m *onboardingManagerFake) EnsureProtectedBranch(repo, branch string) error
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *onboardingManagerFake) PublishDeliveryBranch(_, _, targetURL, branch string, _ func(string) error) (bool, error) {
|
||||||
|
m.calls = append(m.calls, "delivery "+targetURL+":"+branch)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *onboardingManagerFake) EnsurePullRequest(repo, _, branch, _ string) error {
|
||||||
|
m.calls = append(m.calls, "ensure-pr "+m.owner+"/"+repo+":"+branch)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (m *onboardingManagerFake) HasOpenPullRequest(repo, branch string) (bool, error) {
|
func (m *onboardingManagerFake) HasOpenPullRequest(repo, branch string) (bool, error) {
|
||||||
m.calls = append(m.calls, "open-pr "+m.owner+"/"+repo+":"+branch)
|
m.calls = append(m.calls, "open-pr "+m.owner+"/"+repo+":"+branch)
|
||||||
return false, nil
|
return false, nil
|
||||||
|
|
@ -211,7 +296,7 @@ func (m *onboardingManagerFake) TriggerWebhookTest(repo, _, branch string) error
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestOnboardAppUsesCanonicalSourceAndCentralClusterManagers(t *testing.T) {
|
func TestOnboardAppUsesCanonicalSourceAndExecutionClusterManagers(t *testing.T) {
|
||||||
source := filepath.Join(t.TempDir(), "source")
|
source := filepath.Join(t.TempDir(), "source")
|
||||||
if err := os.Mkdir(source, 0755); err != nil {
|
if err := os.Mkdir(source, 0755); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
|
|
@ -259,29 +344,16 @@ func TestOnboardAppUsesCanonicalSourceAndCentralClusterManagers(t *testing.T) {
|
||||||
"remote https://git.example.test/Maidn/maidn-e2e-web.git:main",
|
"remote https://git.example.test/Maidn/maidn-e2e-web.git:main",
|
||||||
"remote https://git.example.test/Maidn/maidn-e2e-web.git:production",
|
"remote https://git.example.test/Maidn/maidn-e2e-web.git:production",
|
||||||
"protect Maidn/maidn-e2e-web:production",
|
"protect Maidn/maidn-e2e-web:production",
|
||||||
"remote https://git.example.test/Maidn/maidn-e2e-web.git:maidn/platform-web-ui",
|
"delivery https://git.example.test/Maidn/maidn-e2e-web.git:maidn/delivery-web-ui",
|
||||||
"protect Maidn/maidn-e2e-web:maidn/platform-web-ui",
|
|
||||||
} {
|
} {
|
||||||
if !strings.Contains(sourceCalls, want) {
|
if !strings.Contains(sourceCalls, want) {
|
||||||
t.Fatalf("source manager calls = %q, missing %q", 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") {
|
if strings.Contains(sourceCalls, "merge-pr") {
|
||||||
t.Fatalf("source manager published a delivery change: %q", sourceCalls)
|
t.Fatalf("source delivery PR was merged without review: %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" {
|
||||||
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)
|
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -509,6 +508,77 @@ 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
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(temporary, ".maidn")); err == nil {
|
||||||
|
if err := runGit(temporary, environment, "add", ".maidn"); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
} else if !os.IsNotExist(err) {
|
||||||
|
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 +586,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
|
||||||
|
|
@ -790,10 +851,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 +872,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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -400,34 +400,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 {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue