diff --git a/.github/workflows/configurator-release.yml b/.github/workflows/configurator-release.yml index c5c38a1..7818524 100644 --- a/.github/workflows/configurator-release.yml +++ b/.github/workflows/configurator-release.yml @@ -79,6 +79,7 @@ jobs: node --test ../infra/ci/prepare-release.test.mjs node --test ../infra/ci/buildpack-source.test.mjs node --test ../infra/ci/image-staging.test.mjs + node --test ../infra/ci/release-lifecycle.test.mjs - name: Test first preparations and stateful platform Plan/Apply broker run: npm run test:platform-database - name: Test database migrations and tenant isolation @@ -96,10 +97,6 @@ jobs: go run . -check go run . -variables-source ../../../src/variables.tf -output ../../docs/accelerator-inputs.json -check go run . -variables-source ../../../src/variables.tf -output ../../app/packages/domain/src/accelerator-inputs.json -check - - name: Verify browser flow before packaging - run: | - npx playwright install --with-deps chromium - npm run test:e2e - name: Package production application and pinned Node runtime run: | mkdir -p ../.local/release/apps/api ../.local/release/packages/contracts ../.local/release/packages/domain ../.local/release/public ../.local/release/runtime @@ -120,13 +117,14 @@ jobs: with: tofu_version: '1.12.6' tofu_wrapper: false - - name: Test isolated native Plan hold and interruption - run: LZC_TEST_TOFU_BIN="$(command -v tofu)" npx vitest run tests/worker-recovery-hold.test.ts - - name: Test real OpenTofu contract and package isolated runner + - name: Verify browser flow and package native runner in parallel run: | + node ../infra/ci/run-parallel.mjs browser 'npx playwright install --with-deps chromium && npm run test:e2e' native ' + LZC_TEST_TOFU_BIN="$(command -v tofu)" npx vitest run tests/worker-recovery-hold.test.ts npm run test:common-contract LZC_TEST_TOFU_BIN="$(command -v tofu)" npm run test:plan LZC_HOSTED_EXECUTION_PACKAGE=true LZC_PACKAGE_APPLICATION_ROOT=true LZC_PACKAGE_PLATFORM_UPGRADE_ROOT=true bash ../deploy/runner/package.sh + ' cp -R ../.local/runner ../.local/release/inspection tar --exclude='./inspection/accelerator/firewall-image.qcow2' -czf ../.local/release.tar.gz -C ../.local/release . (cd ../.local && sha256sum release.tar.gz > release.sha256) @@ -264,6 +262,14 @@ jobs: github-token: ${{ github.token }} merge-multiple: true path: landing-zone-configurator/.local + - name: Detect source-bound parallel release support + id: lifecycle + run: | + if [ -f infra/ci/stage-release-application.sh ] && [ -f infra/ci/package-migration.mjs ]; then + echo 'parallel=true' >> "$GITHUB_OUTPUT" + else + echo 'parallel=false' >> "$GITHUB_OUTPUT" + fi - name: Unpack application env: LZC_RELEASE_REVISION: ${{ steps.source.outputs.release_revision }} @@ -279,6 +285,9 @@ jobs: mkdir -p .local/runner tar -xzf .local/runner.tar.gz -C .local/runner diff -qr .local/release/inspection .local/runner + if [ "${{ steps.lifecycle.outputs.parallel }}" = true ]; then + node infra/ci/package-migration.mjs .local/release .local/migration + fi - name: Prepare encrypted state read access run: node infra/ci/prepare-release.mjs backend env: @@ -314,8 +323,54 @@ jobs: LZC_DEPLOYMENT_ARTIFACT_KEY: ${{ secrets.LZC_DEPLOYMENT_ARTIFACT_KEY }} LZC_GITHUB_CLIENT_ID: ${{ secrets.LZC_GITHUB_CLIENT_ID }} LZC_GITHUB_CLIENT_SECRET: ${{ secrets.LZC_GITHUB_CLIENT_SECRET }} - - name: Stage isolated runner template in its own organisation + - name: CF login and target + if: steps.lifecycle.outputs.parallel == 'true' + run: | + cf version + cf api "$CF_API_URL" + cf auth + cf target -o lzc-dev -s configurator + test "$(cf space configurator --guid)" = "$LZC_CF_SPACE_ID" + - name: Stage runner, migration and application in parallel + if: steps.lifecycle.outputs.parallel == 'true' id: stage_runner + run: | + rm -f "$LZC_PRIVATE/app-stage.json" + node infra/ci/run-parallel.mjs runner 'bash infra/ci/stage-release-runner.sh' migration 'bash infra/ci/stage-release-migration.sh' application 'bash infra/ci/stage-release-application.sh' + - name: Runner staging failure diagnostics + if: failure() && steps.stage_runner.outcome == 'failure' + run: | + export CF_HOME="${LZC_PRIVATE:-/tmp}/cf-runner" + app_id="$(cf app lzc-plan-template --guid)" + build="$(cf curl "/v3/builds?app_guids=$app_id&order_by=-created_at&per_page=1" | jq -ce '.resources | if length == 1 then .[0] else error("Expected one latest build") end')" + printf '%s\n' "$build" | jq '{guid, state, error, created_at, updated_at, staging_disk_in_mb, staging_memory_in_mb}' + droplet_id="$(printf '%s\n' "$build" | jq -r '.droplet.guid // empty')" + if [ -n "$droplet_id" ]; then + cf curl "/v3/droplets/$droplet_id" | jq '{guid, state, error, created_at, updated_at}' + else + echo "Latest build has no associated droplet; no droplet diagnostics available." >&2 + fi + cf logs lzc-plan-template --recent + - name: Sanitized application staging failure diagnostics + if: failure() && steps.stage_runner.outcome == 'failure' + run: | + app_id="$(cf app landing-zone-configurator --guid)" + build="$(cf curl "/v3/builds?app_guids=$app_id&order_by=-created_at&per_page=1" | jq -ce '.resources | if length == 1 then .[0] else error("Expected one latest build") end')" + printf '%s\n' "$build" | jq '{guid, state, error, created_at, updated_at, staging_disk_in_mb, staging_memory_in_mb}' + droplet_id="$(printf '%s\n' "$build" | jq -r '.droplet.guid // empty')" + if [ -n "$droplet_id" ]; then + cf curl "/v3/droplets/$droplet_id" | jq '{guid, state, error, created_at, updated_at}' + else + echo "Latest build has no associated droplet; no droplet diagnostics available." >&2 + fi + cf logs landing-zone-configurator --recent | awk '/\[STG\/[0-9]+\]/ { print }' + - name: Activate application after migration and runner validation + if: steps.lifecycle.outputs.parallel == 'true' + id: push + run: node infra/ci/activate-release.mjs + - name: Legacy source — Stage isolated runner template in its own organisation + if: steps.lifecycle.outputs.parallel != 'true' + id: legacy_stage_runner run: | cf api "$CF_API_URL" CF_USERNAME="$LZC_RUNNER_CF_USERNAME" CF_PASSWORD="$LZC_RUNNER_CF_PASSWORD" cf auth @@ -329,8 +384,8 @@ jobs: cf run-task lzc-plan-template --command "./runtime/bin/node --input-type=module -e 'import { platformFirewallImage } from \"@lzc/contracts\"; console.log(platformFirewallImage.sha256 + \" accelerator/firewall-image.qcow2\");' | sha256sum --check" --name "image-$GITHUB_RUN_ID" -m 128M -k 4096M --wait cf run-task lzc-plan-template --command 'TF_CLI_CONFIG_FILE=/home/vcap/app/runner.tfrc ./tools/tofu -chdir=accelerator init -backend=false -input=false -lockfile=readonly -no-color && ./tools/tofu -chdir=accelerator validate -no-color' --name "engine-$GITHUB_RUN_ID" -m 1024M -k 4096M --wait cf run-task lzc-plan-template --command 'TF_CLI_CONFIG_FILE=/home/vcap/app/runner.tfrc ./tools/tofu -chdir=application-src/application init -backend=false -input=false -lockfile=readonly -no-color && ./tools/tofu -chdir=application-src/application validate -no-color' --name "application-engine-$GITHUB_RUN_ID" -m 1024M -k 4096M --wait - - name: Runner staging failure diagnostics - if: failure() && steps.stage_runner.outcome == 'failure' + - name: Legacy source — Runner staging failure diagnostics + if: steps.lifecycle.outputs.parallel != 'true' && failure() && steps.legacy_stage_runner.outcome == 'failure' run: | app_id="$(cf app lzc-plan-template --guid)" build="$(cf curl "/v3/builds?app_guids=$app_id&order_by=-created_at&per_page=1" | jq -ce '.resources | if length == 1 then .[0] else error("Expected one latest build") end')" @@ -342,22 +397,25 @@ jobs: echo "Latest build has no associated droplet; no droplet diagnostics available." >&2 fi cf logs lzc-plan-template --recent - - name: CF login and target + - name: Legacy source — CF login and target + if: steps.lifecycle.outputs.parallel != 'true' run: | cf version cf api "$CF_API_URL" cf auth cf target -o lzc-dev -s configurator test "$(cf space configurator --guid)" = "$LZC_CF_SPACE_ID" - - name: Stage isolated database migration task - id: migration + - name: Legacy source — Stage isolated database migration task + if: steps.lifecycle.outputs.parallel != 'true' + id: legacy_migration run: cf push --task -f deploy/cloud-foundry/migration-manifest.yml -p .local/release --vars-file "$LZC_PRIVATE/migration-vars.json" --redact-env - - name: Apply versioned database migrations - id: database_migrations + - name: Legacy source — Apply versioned database migrations + if: steps.lifecycle.outputs.parallel != 'true' + id: legacy_database_migrations timeout-minutes: 5 run: cf run-task landing-zone-configurator-migrate --command "./runtime/bin/node apps/api/dist/migrate.js $GITHUB_RUN_ID" --name "migrate-$GITHUB_RUN_ID" -m 128M -k 4096M --wait - - name: Sanitized database migration diagnostics - if: always() && (steps.database_migrations.outcome == 'success' || steps.database_migrations.outcome == 'failure') + - name: Legacy source — Sanitized database migration diagnostics + if: steps.lifecycle.outputs.parallel != 'true' && always() && (steps.legacy_database_migrations.outcome == 'success' || steps.legacy_database_migrations.outcome == 'failure') run: | for attempt in 1 2 3; do if cf logs landing-zone-configurator-migrate --recent | node infra/ci/migration-evidence.mjs "$GITHUB_RUN_ID" > "$LZC_PRIVATE/migration-evidence.json"; then @@ -371,13 +429,15 @@ jobs: if [ "$attempt" -lt 3 ]; then sleep 5; fi done exit 1 - - name: Remove privileged migration application + - name: Legacy source — Remove privileged migration application + if: steps.lifecycle.outputs.parallel != 'true' run: cf delete landing-zone-configurator-migrate -f - - name: CF push application - id: push + - name: Legacy source — CF push application + if: steps.lifecycle.outputs.parallel != 'true' + id: legacy_push run: cf push -f deploy/cloud-foundry/manifest.yml -p .local/release --vars-file "$LZC_PRIVATE/app-vars.json" --redact-env - - name: Sanitized application staging failure diagnostics - if: failure() && steps.push.outcome == 'failure' + - name: Legacy source — Sanitized application staging failure diagnostics + if: steps.lifecycle.outputs.parallel != 'true' && failure() && steps.legacy_push.outcome == 'failure' run: | app_id="$(cf app landing-zone-configurator --guid)" build="$(cf curl "/v3/builds?app_guids=$app_id&order_by=-created_at&per_page=1" | jq -ce '.resources | if length == 1 then .[0] else error("Expected one latest build") end')" @@ -407,10 +467,10 @@ jobs: exit 1 # Router logs may contain OAuth callback codes. Do not export raw logs to CI. - name: CF application status - if: always() && steps.push.outcome == 'success' + if: always() && (steps.push.outcome == 'success' || steps.legacy_push.outcome == 'success') run: cf app landing-zone-configurator - name: Public route checks - if: always() && steps.push.outcome == 'success' + if: always() && (steps.push.outcome == 'success' || steps.legacy_push.outcome == 'success') env: LZC_URL: https://lzc-dev-configurator.apps.01.cf.eu01.stackit.cloud LZC_AUTH_ENABLED: ${{ vars.LZC_AUTH_ENABLED }} @@ -441,8 +501,10 @@ jobs: cf unmap-route landing-zone-configurator apps.01.cf.eu01.stackit.cloud --hostname lzc-dev-configurator-7dbff805 fi - name: Clean up migration application after failure or cancellation - if: (failure() || cancelled()) && (steps.migration.outcome == 'success' || steps.migration.outcome == 'failure' || steps.migration.outcome == 'cancelled') - run: cf delete landing-zone-configurator-migrate -f + if: failure() || cancelled() + run: | + if [ "${{ steps.lifecycle.outputs.parallel }}" = true ]; then export CF_HOME="$LZC_PRIVATE/cf-migration"; fi + cf delete landing-zone-configurator-migrate -f - name: Remove private deployment inputs if: always() run: rm -rf -- "$RUNNER_TEMP/lzc-release" diff --git a/.github/workflows/validate-configurator.yml b/.github/workflows/validate-configurator.yml index 4b41c61..d92b0e7 100644 --- a/.github/workflows/validate-configurator.yml +++ b/.github/workflows/validate-configurator.yml @@ -97,6 +97,8 @@ jobs: run: npm run check - name: Check release configuration with the built API validators run: node --test ../infra/ci/prepare-release.test.mjs + - name: Check parallel release lifecycle and slim migration payload + run: node --test ../infra/ci/release-lifecycle.test.mjs - name: Check release configuration under the explicit Dev exception run: node --test ../infra/ci/prepare-release.test.mjs env: diff --git a/docs/workflows.de.md b/docs/workflows.de.md index 3262528..0ad829a 100644 --- a/docs/workflows.de.md +++ b/docs/workflows.de.md @@ -117,6 +117,13 @@ Pflichtprüfung, ohne sicherzustellen, dass er für jeden relevanten PR ein Erge | Configurator Runtime | `root=runtime`; standardmäßig `apply=false`. Benötigt den obigen Laufzeit-Operatorschalter; Apply verwendet `lzc-dev-runtime-apply`. | | Configurator Release | Standardmäßig `release_mode=qualified` und `build_only=false`; leere `release_run_id` bedeutet einen neuen Build im gewählten Modus. Setzen Sie `build_only=true` für einen Build ohne CF-Bereitstellung; die Veröffentlichung des geschützten Tags erfolgt trotzdem; echte Modellaufrufe laufen im Modus `qualified`. Um einen bestehenden qualifizierten Build zu übernehmen, lassen Sie Build-only deaktiviert und geben Sie dessen numerische Run-ID an; das aufbewahrte Artefakt/Tag muss weiterhin gültig sein. | +Browser-Abnahme und native Paketierung laufen im Release-Build parallel; die +Bereitstellung bereitet Runner, minimales Migrationspaket und Web/API mit getrennten +CF-Homes parallel vor. Die App-Aktivierung wartet auf alle Prüfungen und die +vollständige Runner-Bindung. Der Release bleibt eine unveränderliche Quelle; +Komponenten-Wiederverwendung folgt später in +[Issue #167](https://github.com/stackitcloud/stackit-landing-zone/issues/167). + Für die normale Configurator-Bereitstellung: Wählen Sie **Configurator Release**, **runner_pool_size=1** für einen vorbereiteten Einmal-Runner in Dev (`0` deaktiviert/baut den Pool ab; Standard `0`, Maximum `4`, 1 GiB RAM je Reserve), diff --git a/docs/workflows.md b/docs/workflows.md index b6ff956..2e92b5a 100644 --- a/docs/workflows.md +++ b/docs/workflows.md @@ -106,6 +106,12 @@ it reports a result for every relevant PR. | Configurator Runtime | `root=runtime`; `apply=false` by default. Requires the runtime operator switch above; Apply uses `lzc-dev-runtime-apply`. | | Configurator Release | `release_mode=qualified` and `build_only=false` by default; `release_run_id` empty means a fresh build under the selected mode. Set `build_only=true` to build without CF deployment; protected tag publication still occurs; live-model calls run in `qualified` mode. To promote an existing qualified build, leave build-only unchecked and enter its numeric run ID; the retained artifact/tag must remain valid. | +Release build browser acceptance and native packaging run concurrently; deployment +stages Runner, the minimal migration payload and Web/API concurrently with isolated +CF homes. App activation waits for all checks and the completed Runner binding. +The release remains one immutable source; component reuse is deferred to +[issue #167](https://github.com/stackitcloud/stackit-landing-zone/issues/167). + For the normal Configurator deployment: select **Configurator Release**, `main`, choose **runner_pool_size=1** to maintain one prepared one-shot Dev Runner (`0` disables/drains the pool; default `0`, maximum `4`, 1 GiB RAM per reserve), diff --git a/landing-zone-configurator/deploy/cloud-foundry/README.de.md b/landing-zone-configurator/deploy/cloud-foundry/README.de.md index 6035270..0933233 100644 --- a/landing-zone-configurator/deploy/cloud-foundry/README.de.md +++ b/landing-zone-configurator/deploy/cloud-foundry/README.de.md @@ -238,12 +238,40 @@ Release in `landing-zone-configurator-migrate`, einer separaten Task-only-App oh Route. Nur diese kurzlebige App erhält die Datenbank-Owner-Zugangsdaten; sie wird nach der Migration und bei Fehlern gelöscht. Migrationsprüfsummen und ein Advisory Lock sichern wiederholte Ausführung ab. Die Web-App verwendet ihre eingeschränkte Runtime-Rolle und RLS. -Migrations-App und ihr Task benötigen beide 4096 MB Festplattenquota, weil sie -den vollständigen unveränderlichen Web-Release einschließlich der gepinnten Inspektionsruntime entpacken. -Das frühere Limit von 512 MB scheiterte beim Extrahieren des Containers, bevor Node oder irgendein -Migrationscode startete. Der Task behält sein Speicherlimit von 128 MB. -Der exakte qualifizierte Release enthält allein 505.65 MiB Nutzlast regulärer Dateien; -Dateisystemblöcke, Verzeichnisse und Container verursachen zusätzlichen Overhead. +Das Migrationspaket wird auf dem Deployment-Runner aus dem per Prüfsumme verifizierten +unveränderlichen Release abgeleitet. Es enthält nur die gepinnte Node-Binärdatei samt +Lizenz, den Migrationseinstieg, Datenbankkonfiguration/Migrations-/Fehlerhilfen, das +vollständige SQL-Verzeichnis und die installierten `pg`-Abhängigkeiten. Es findet keine +neue Abhängigkeitsauflösung statt. Web-Assets, weitere API-Abhängigkeiten, Provider und +Inspektions-Images sind ausgeschlossen. Der Task behält seine Limits von 128 MB +Speicher und 4096 MB Festplatte; Letzteres ist eine Obergrenze und kein Grund, das +vollständige Web-/Inspektionspaket hochzuladen. + +Browser-Abnahme und native Vertragsprüfungen/Runner-Paketierung laufen nach dem +normalen Anwendungs-Build parallel. Beide müssen erfolgreich sein, bevor das +Release-Archiv zusammengestellt wird. Bei der Bereitstellung laufen Runner-Staging +und -Validierung, der kleine Migrationstask und Web/API-Staging parallel; Runner- und +Migrationsidentität verwenden getrennte CF-Homes. Der Anwendungszweig verwendet eine +private Kopie mit deaktivierter Ausführung und ohne Runner-Bindung. `cf push --no-start` +lädt hoch und wendet das Manifest an; `cf stage-package` erstellt aus dem exakt +bestimmten bereiten Paket ein Droplet, ohne es zuzuweisen oder einen neuen +Anwendungsprozess zu starten. Bestehende Prozesse behalten ihre bisherige Umgebung. +Erst nach Erfolg aller drei Zweige wendet die Aktivierung die vollständige Runner-Bindung +an, prüft die protokollierte Paket-/Build-/Droplet-Zuordnung, stoppt die alte App, +weist das neue Droplet zu und startet es. Es bleibt eine Bereitstellung mit einer +Instanz und kurzer Aktivierungsunterbrechung, kein Rolling Release. Ein fehlgeschlagener +Zweig beendet die übrigen und verhindert die Aktivierung; die privilegierte Migrations-App +wird bei Erfolg und Fehler gelöscht, ergänzt durch Workflow-Bereinigung bei Abbruch. +Die Laufzeiten der Zweige werden protokolliert. + +Ältere qualifizierte Artefakte, deren exakte Quelle die neuen Skripte noch nicht enthält, +behalten bei einer Promotion den bisherigen sequenziellen Ablauf. Das Artefakt wird +weder neu gebaut noch stillschweigend mit einem anderen Quellbaum ausgeführt. + +Alle Komponenten gehören weiterhin zu einem quellgebundenen Release. Ein älterer +Runner wird nicht wiederverwendet; Promotion, Qualifikation, Prüfsummen und Freigaben +bleiben erhalten. Getrennte Komponenten-Releases verfolgt +[Issue #167](https://github.com/stackitcloud/stackit-landing-zone/issues/167). Die nachfolgenden Tasks für Netzwerk, Dienstverbindung und Runner-Isolation entpacken dieses Droplet ebenfalls und verwenden dieselbe Festplattenquota von 4096 MB. Diagnosen des Migrationstasks melden nur Release-Run-ID, Verbindungs-/Migrations- diff --git a/landing-zone-configurator/deploy/cloud-foundry/README.md b/landing-zone-configurator/deploy/cloud-foundry/README.md index 64676ed..35ded54 100644 --- a/landing-zone-configurator/deploy/cloud-foundry/README.md +++ b/landing-zone-configurator/deploy/cloud-foundry/README.md @@ -229,12 +229,36 @@ release in `landing-zone-configurator-migrate`, a separate task-only app with no route. Only this short-lived app receives the database-owner credentials; it is deleted after migration and on failure. Migration checksums and an advisory lock protect repeat execution. The web app uses its restricted runtime role and RLS. -The migration app and its task both need 4096 MB disk quota because they unpack -the complete immutable web release, including the pinned inspection runtime. -The former 512 MB limit failed during container extraction, before Node or any -migration code started. The task retains its 128 MB memory limit. -The exact qualified release contains 505.65 MiB of regular file payload alone; -filesystem blocks, directories and the container add overhead beyond that. +Migration packaging is derived on the deployment runner from the checksum-verified +immutable release. It copies only the pinned Node binary/license, migration entrypoint, +database configuration/migration/error helpers, the complete SQL directory and the +installed `pg` dependency closure. It performs no new dependency resolution. Web +assets, unrelated API dependencies, providers and inspection images are excluded. +The task retains its 128 MB memory and 4096 MB disk limits; the latter is a ceiling, +not a reason to upload the complete web/inspection payload. + +Browser acceptance and native contract/Runner packaging run concurrently after the +ordinary application build. Both must succeed before the release archive is assembled. +During deployment, Runner staging/validation, the small migration task and Web/API +staging run concurrently, with separate CF homes for the Runner and migration +identities. The application lane uses a private snapshot with execution disabled +and no Runner binding. `cf push --no-start` uploads/applies the manifest; +`cf stage-package` stages the exact ready package without assigning its droplet or +starting a new application process. Existing processes retain their previous environment. +Only after all three lanes succeed does activation apply the completed Runner binding, +verify the recorded package/build/droplet relationship, stop the old app, assign the +new droplet and start it. This remains a single-instance deployment with a short +activation interruption, not a rolling release. A failed lane terminates the others +and prevents activation; the privileged migration app is removed on success/failure, +with workflow cleanup also covering cancellation. Lane durations are logged. + +Older qualified artifacts whose exact source lacks the new scripts retain the original +sequential deployment path when promoted; the artifact is never rebuilt or silently +run with a different source tree. + +All components still belong to one source-bound release: this does not reuse an +older Runner or relax promotion, qualification, checksum or approval gates. +Independent component releases are tracked in [issue #167](https://github.com/stackitcloud/stackit-landing-zone/issues/167). The subsequent network, service-connection and runner-isolation tasks also unpack this droplet and use the same 4096 MB disk quota. Migration task diagnostics report only the release run ID, connection/migration diff --git a/landing-zone-configurator/infra/ci/activate-release.mjs b/landing-zone-configurator/infra/ci/activate-release.mjs new file mode 100644 index 0000000..5695dfe --- /dev/null +++ b/landing-zone-configurator/infra/ci/activate-release.mjs @@ -0,0 +1,24 @@ +import { readFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { join } from "node:path"; + +const receipt = JSON.parse(readFileSync(join(process.env.LZC_PRIVATE, "app-stage.json"), "utf8")); +const uuid = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/; +if (![receipt.app, receipt.package, receipt.build, receipt.droplet].every(x => typeof x === "string" && uuid.test(x))) + throw new Error("Invalid staged application binding"); +function cf(args, json = false) { + const result = spawnSync("cf", args, { encoding: "utf8", ...(json ? {} : { stdio: "inherit" }) }); + if (result.status !== 0) throw new Error("CF release activation failed"); + return json ? JSON.parse(result.stdout) : undefined; +} +const app = cf(["curl", `/v3/apps/${receipt.app}`], true); +const build = cf(["curl", `/v3/builds/${receipt.build}`], true); +const droplet = cf(["curl", `/v3/droplets/${receipt.droplet}`], true); +if (app.name !== "landing-zone-configurator" || app.relationships.space.data.guid !== process.env.LZC_CF_SPACE_ID || + build.state !== "STAGED" || build.package.guid !== receipt.package || build.droplet.guid !== receipt.droplet || + droplet.state !== "STAGED" || droplet.relationships.app.data.guid !== receipt.app) + throw new Error("Staged application no longer matches the verified release"); +cf(["apply-manifest", "-f", "deploy/cloud-foundry/manifest.yml", "--vars-file", join(process.env.LZC_PRIVATE, "app-vars.json"), "--redact-env"]); +cf(["stop", "landing-zone-configurator"]); +cf(["set-droplet", "landing-zone-configurator", receipt.droplet]); +cf(["start", "landing-zone-configurator"]); diff --git a/landing-zone-configurator/infra/ci/image-staging.test.mjs b/landing-zone-configurator/infra/ci/image-staging.test.mjs index a992324..b9bb65c 100644 --- a/landing-zone-configurator/infra/ci/image-staging.test.mjs +++ b/landing-zone-configurator/infra/ci/image-staging.test.mjs @@ -274,7 +274,7 @@ test("release pins the final buildpack to the approved source and excludes image assert.match(runnerManifest, /disk_quota: 6144M/); assert.match(runnerManifest, /instances: 0/); assert.ok( - workflow.includes("if: failure() && steps.push.outcome == 'failure'"), + workflow.includes("if: failure() && steps.stage_runner.outcome == 'failure'"), ); assert.ok(workflow.includes("awk '/\\[STG\\/[0-9]+\\]/ { print }'")); assert.ok(pack.includes('tar -czf "$runner_dir.tar.gz" -C "$runner_dir" .')); diff --git a/landing-zone-configurator/infra/ci/package-migration.mjs b/landing-zone-configurator/infra/ci/package-migration.mjs new file mode 100644 index 0000000..e63451c --- /dev/null +++ b/landing-zone-configurator/infra/ci/package-migration.mjs @@ -0,0 +1,69 @@ +import { chmodSync, cpSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, statSync, writeFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { dirname, join, resolve, sep } from "node:path"; +import { fileURLToPath } from "node:url"; + +export function copyTree(source, destination) { + source = realpathSync(source); + const stat = statSync(source); + if (stat.isDirectory()) { + mkdirSync(destination, { recursive: true }); + for (const name of readdirSync(source)) copyTree(join(source, name), join(destination, name)); + } else if (stat.isFile()) { + mkdirSync(dirname(destination), { recursive: true }); + cpSync(source, destination); + chmodSync(destination, stat.mode & 0o777); + } else throw new Error("Unsupported migration file type"); +} + +// Copy the installed, lockfile-resolved pg dependency closure, without another +// dependency resolution or any API/Runner/inspection payload. +export function packageMigration(release, destination) { + release = realpathSync(release); + destination = resolve(destination); + if (existsSync(destination)) throw new Error("Migration destination already exists"); + mkdirSync(destination, { recursive: true }); + writeFileSync(join(destination, "package.json"), JSON.stringify({ private: true, type: "module" })); + for (const file of ["migrate.js", "storage/database.js", "storage/migrations.js", "connectivity.js"]) + copy(`apps/api/dist/${file}`); + copy("apps/api/db"); + copy("runtime/bin/node"); + copy("runtime/LICENSE"); + const seen = new Map(); + function copy(path) { + mkdirSync(dirname(join(destination, path)), { recursive: true }); + copyTree(join(release, path), join(destination, path)); + } + function dependency(name, parent, optional = false) { + let entry; + try { entry = createRequire(join(parent, "package.json")).resolve(name); } + catch (error) { if (optional && error.code === "MODULE_NOT_FOUND") return; throw error; } + let directory = dirname(entry), metadata; + while (directory.startsWith(release + sep)) { + const path = join(directory, "package.json"); + if (existsSync(path)) { + metadata = JSON.parse(readFileSync(path, "utf8")); + if (metadata.name === name) break; + } + directory = dirname(directory); + } + if (!directory.startsWith(join(release, "node_modules") + sep) || metadata?.name !== name) + throw new Error(`Migration dependency outside release: ${name} (${directory}, expected ${release})`); + directory = realpathSync(directory); + if (seen.has(name)) { + if (seen.get(name) !== directory) throw new Error("Conflicting migration dependency versions"); + return; + } + seen.set(name, directory); + const target = join(destination, "node_modules", name); + mkdirSync(dirname(target), { recursive: true }); + copyTree(directory, target); + for (const dep of Object.keys(metadata.dependencies ?? {})) dependency(dep, directory); + for (const dep of Object.keys(metadata.optionalDependencies ?? {})) dependency(dep, directory, true); + } + dependency("pg", release); + return [...seen.keys()].sort(); +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) + console.log("Migration dependency closure:", packageMigration(process.argv[2], process.argv[3]).join(", ")); diff --git a/landing-zone-configurator/infra/ci/prepare-release.mjs b/landing-zone-configurator/infra/ci/prepare-release.mjs index a2fd630..7925183 100644 --- a/landing-zone-configurator/infra/ci/prepare-release.mjs +++ b/landing-zone-configurator/infra/ci/prepare-release.mjs @@ -91,6 +91,7 @@ if(mode==="backend") { vars.LZC_IMAGE_BUILDPACK=`https://github.com/stackitcloud/stackit-landing-zone.git#${buildpackTag(revision)}`; writeFileSync(resolve(dir,"runner-vars.json"),JSON.stringify({LZC_IMAGE_BUILDPACK:vars.LZC_IMAGE_BUILDPACK}),{mode:0o600}); writeFileSync(resolve(dir,"app-vars.json"),JSON.stringify(vars),{mode:0o600}); + writeFileSync(resolve(dir,"app-stage-vars.json"),JSON.stringify({...vars,LZC_EXECUTION_ENABLED:"false",LZC_APPLICATION_EXECUTION_ENABLED:"false",LZC_RUNNER_POOL_SIZE:"0"}),{mode:0o600}); publish("CF_API_URL",cf.api_url);publish("CF_USERNAME",cf.username,true);publish("CF_PASSWORD",cf.password,true);publish("LZC_CF_SPACE_ID",space.id); } publish("LZC_PRIVATE",dir); diff --git a/landing-zone-configurator/infra/ci/prepare-release.test.mjs b/landing-zone-configurator/infra/ci/prepare-release.test.mjs index 94867c3..eaa6acb 100644 --- a/landing-zone-configurator/infra/ci/prepare-release.test.mjs +++ b/landing-zone-configurator/infra/ci/prepare-release.test.mjs @@ -7,13 +7,14 @@ import { spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; import { approvedReleaseSource, createReleaseManifest, verifyReleaseManifest, releaseMode } from "./release-artifact.mjs"; const script=fileURLToPath(new URL("./prepare-release.mjs",import.meta.url)); -test("migration task can unpack the full release and web routes match the registered callback origin",()=>{ +test("migration task retains its disk budget and web routes match the registered callback origin",()=>{ const web=readFileSync(new URL("../../deploy/cloud-foundry/manifest.yml",import.meta.url),"utf8"); const migration=readFileSync(new URL("../../deploy/cloud-foundry/migration-manifest.yml",import.meta.url),"utf8"); const workflow=readFileSync(new URL("../../../.github/workflows/configurator-release.yml",import.meta.url),"utf8"); assert.match(web,/\n disk_quota: 6144M\n/); assert.match(migration,/\n disk_quota: 4096M\n/); - assert.match(workflow,/cf run-task landing-zone-configurator-migrate [^\n]+ -m 128M -k 4096M --wait/); + const migrationLane=readFileSync(new URL("./stage-release-migration.sh",import.meta.url),"utf8"); + assert.match(migrationLane,/cf run-task landing-zone-configurator-migrate [^\n]+ -m 128M -k 4096M --wait/); const applicationTasks=workflow.split("\n").filter(line=>line.trim().startsWith("cf run-task landing-zone-configurator ")||line.trim().startsWith("run: cf run-task landing-zone-configurator ")); assert.equal(applicationTasks.length,3); for(const task of applicationTasks)assert.match(task,/ -m 128M -k 4096M --wait$/); diff --git a/landing-zone-configurator/infra/ci/release-lifecycle.test.mjs b/landing-zone-configurator/infra/ci/release-lifecycle.test.mjs new file mode 100644 index 0000000..fe9710d --- /dev/null +++ b/landing-zone-configurator/infra/ci/release-lifecycle.test.mjs @@ -0,0 +1,130 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync, cpSync, readdirSync, statSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { spawnSync } from "node:child_process"; +import { copyTree, packageMigration } from "./package-migration.mjs"; + +const root = resolve(import.meta.dirname, "../.."); +const parallel = join(import.meta.dirname, "run-parallel.mjs"); +const env = { ...process.env, PATH: `${resolve(process.execPath, "..")}:/usr/bin:/bin` }; + +test("parallel lanes overlap and any failed lane prevents continuation", () => { + const dir = mkdtempSync(join(tmpdir(), "lzc-parallel-")); + try { + const a = `touch '${dir}/a'; for i in {1..100}; do [ ! -f '${dir}/b' ] || exit 0; sleep .02; done; exit 1`; + const b = `touch '${dir}/b'; for i in {1..100}; do [ ! -f '${dir}/a' ] || exit 0; sleep .02; done; exit 1`; + assert.equal(spawnSync(process.execPath, [parallel, "a", a, "b", b], { env }).status, 0); + const failure = spawnSync(process.execPath, [parallel, "failed", "sleep .05; exit 7", "slow", `trap "touch '${dir}/cleanup'" EXIT; sleep 10; touch '${dir}/unsafe'`], { env, timeout: 8000 }); + assert.equal(failure.status, 1); + assert.equal(existsSync(join(dir, "unsafe")), false); + assert.equal(existsSync(join(dir, "cleanup")), true); + } finally { rmSync(dir, { recursive: true, force: true }); } +}); + +test("migration bundle preserves SQL and executable imports but excludes inspection, web and unrelated API dependencies", () => { + const dir = mkdtempSync(join(tmpdir(), "lzc-migration-")); + const source = join(dir, "release"), destination = join(dir, "migration"); + try { + mkdirSync(source); + writeFileSync(join(source, "package.json"), '{"type":"module"}'); + for (const file of ["migrate.js", "storage/database.js", "storage/migrations.js", "connectivity.js"]) { + const target = join(source, "apps/api/dist", file); + mkdirSync(resolve(target, ".."), { recursive: true }); + cpSync(join(root, "app/apps/api/dist", file), target); + } + cpSync(join(root, "app/apps/api/db"), join(source, "apps/api/db"), { recursive: true }); + mkdirSync(join(source, "runtime/bin"), { recursive: true }); + cpSync(process.execPath, join(source, "runtime/bin/node")); + writeFileSync(join(source, "runtime/LICENSE"), "fixture Node license"); + // Install exactly the existing pg closure into the fixture, with no resolution/network. + const modules = join(root, "app/node_modules"); + const todo = ["pg"], copied = new Set(); + while (todo.length) { + const name = todo.pop(); + if (copied.has(name)) continue; + copied.add(name); + const module = join(modules, name); + const meta = JSON.parse(readFileSync(join(module, "package.json"))); + copyTree(module, join(source, "node_modules", name)); + todo.push(...Object.keys(meta.dependencies ?? {})); + for (const dep of Object.keys(meta.optionalDependencies ?? {})) if (existsSync(join(modules, dep))) todo.push(dep); + } + mkdirSync(join(source, "inspection")); + writeFileSync(join(source, "inspection/private-sentinel"), "must not copy"); + const copiedNames = packageMigration(source, destination); + assert.ok(copiedNames.includes("pg")); + assert.equal(copiedNames.includes("@aws-sdk/client-s3"), false); + assert.equal(existsSync(join(destination, "inspection")), false); + assert.equal(existsSync(join(destination, "public")), false); + for (const file of readdirSync(join(source, "apps/api/db"))) + assert.deepEqual(readFileSync(join(destination, "apps/api/db", file)), readFileSync(join(source, "apps/api/db", file))); + const smoke = spawnSync(join(destination, "runtime/bin/node"), ["--input-type=module", "-e", "await import('./apps/api/dist/storage/migrations.js'); await import('./apps/api/dist/storage/database.js');"], { cwd: destination, encoding: "utf8" }); + assert.equal(smoke.status, 0, smoke.stderr); + assert.ok(statSync(join(destination, "runtime/bin/node")).mode & 0o100); + assert.throws(() => packageMigration(source, destination), /already exists/); + } finally { rmSync(dir, { recursive: true, force: true }); } +}); + +test("activation rejects a mismatched droplet before manifest mutation or restart", () => { + const dir = mkdtempSync(join(tmpdir(), "lzc-activation-")); + try { + const id = "11111111-2222-4333-8444-555555555555"; + writeFileSync(join(dir, "app-stage.json"), JSON.stringify({ app: id, package: id, build: id, droplet: id })); + writeFileSync(join(dir, "cf"), `#!/bin/bash\nif [ "$1" != curl ]; then touch '${dir}/unsafe'; exit 0; fi\nprintf '%s' '{"name":"wrong-app"}'\n`, { mode: 0o700 }); + const result = spawnSync(process.execPath, [join(import.meta.dirname, "activate-release.mjs")], { env: { ...env, PATH: `${dir}:${env.PATH}`, LZC_PRIVATE: dir }, encoding: "utf8" }); + assert.notEqual(result.status, 0); + assert.equal(existsSync(join(dir, "unsafe")), false); + } finally { rmSync(dir, { recursive: true, force: true }); } +}); + +test("workflow activation is downstream of all staging lanes, while browser success gates artifact creation", () => { + const workflow = readFileSync(join(root, "../.github/workflows/configurator-release.yml"), "utf8"); + const app = readFileSync(join(import.meta.dirname, "stage-release-application.sh"), "utf8"); + assert.match(app, /cf push --no-start/); + assert.match(app, /cf stage-package .*--package-guid/); + assert.doesNotMatch(app, /^cf (?:start|restart|set-droplet)/m); + assert.ok(workflow.indexOf("stage-release-migration.sh") < workflow.indexOf("run: node infra/ci/activate-release.mjs")); + assert.ok(workflow.indexOf("run-parallel.mjs browser") < workflow.indexOf("release-artifact.mjs create")); + assert.match(readFileSync(join(import.meta.dirname, "prepare-release.mjs"), "utf8"), /app-stage-vars.json.*LZC_EXECUTION_ENABLED:"false".*LZC_APPLICATION_EXECUTION_ENABLED:"false".*LZC_RUNNER_POOL_SIZE:"0"/); + assert.match(readFileSync(join(import.meta.dirname, "stage-release-runner.sh"), "utf8"), /CF_HOME="\$LZC_PRIVATE\/cf-runner"/); + assert.match(readFileSync(join(import.meta.dirname, "stage-release-migration.sh"), "utf8"), /CF_HOME="\$LZC_PRIVATE\/cf-migration"/); +}); + +test("successful activation applies final variables then assigns only the verified droplet before start", () => { + const dir = mkdtempSync(join(tmpdir(), "lzc-activation-success-")); + try { + const id = "11111111-2222-4333-8444-555555555555"; + writeFileSync(join(dir, "app-stage.json"), JSON.stringify({ app: id, package: id, build: id, droplet: id })); + const replies = { + [`/v3/apps/${id}`]: { name: "landing-zone-configurator", relationships: { space: { data: { guid: id } } } }, + [`/v3/builds/${id}`]: { state: "STAGED", package: { guid: id }, droplet: { guid: id } }, + [`/v3/droplets/${id}`]: { state: "STAGED", relationships: { app: { data: { guid: id } } } }, + }; + writeFileSync(join(dir, "cf"), `#!/usr/bin/env node +const fs = require('node:fs'); +const args = process.argv.slice(2); +fs.appendFileSync(${JSON.stringify(join(dir, "calls"))}, JSON.stringify(args)+'\\n'); +if (args[0] === 'curl') console.log(JSON.stringify(${JSON.stringify(replies)}[args[1]])); +`, { mode: 0o700 }); + const result = spawnSync(process.execPath, [join(import.meta.dirname, "activate-release.mjs")], { env: { ...env, PATH: `${dir}:${env.PATH}`, LZC_PRIVATE: dir, LZC_CF_SPACE_ID: id }, encoding: "utf8" }); + assert.equal(result.status, 0, result.stderr); + const calls = readFileSync(join(dir, "calls"), "utf8").trim().split("\n").map(JSON.parse); + assert.deepEqual(calls.map(x => x[0]), ["curl", "curl", "curl", "apply-manifest", "stop", "set-droplet", "start"]); + assert.deepEqual(calls[5], ["set-droplet", "landing-zone-configurator", id]); + assert.ok(calls[3].includes(join(dir, "app-vars.json"))); + assert.ok(calls[3].includes("--redact-env")); + } finally { rmSync(dir, { recursive: true, force: true }); } +}); + + test("older exact-source promotions retain the sequential lifecycle without requiring new scripts", () => { + const workflow = readFileSync(join(root, "../.github/workflows/configurator-release.yml"), "utf8"); + assert.match(workflow, /id: lifecycle/); + assert.match(workflow, /parallel=false/); + const blocks = workflow.split(" - name: Legacy source — ").slice(1); + assert.ok(blocks.length >= 8); + for (const block of blocks) assert.match(block.split("\n - name:")[0], /if: steps.lifecycle.outputs.parallel != 'true'/); + assert.match(workflow, /steps.legacy_push.outcome == 'success'/); + assert.match(workflow, /if: steps.lifecycle.outputs.parallel == 'true'\n id: stage_runner/); + }); diff --git a/landing-zone-configurator/infra/ci/run-parallel.mjs b/landing-zone-configurator/infra/ci/run-parallel.mjs new file mode 100644 index 0000000..78b35fa --- /dev/null +++ b/landing-zone-configurator/infra/ci/run-parallel.mjs @@ -0,0 +1,56 @@ +import { spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +// Only repository-owned commands are accepted by the workflow, never user input. +export async function runParallel(lanes) { + if (!lanes.length || lanes.some(([name, command]) => !name || !command)) + throw new Error("Expected named parallel commands"); + const children = new Set(); + let failed = false; + let killTimer; + const stop = () => { + if (failed) return; + failed = true; + killTimer = setTimeout(() => { + for (const child of children) { + try { process.kill(-child.pid, "SIGKILL"); } catch { /* Already exited. */ } + } + }, 5000); + killTimer.unref(); + for (const child of children) { + try { process.kill(-child.pid, "SIGTERM"); } catch { /* Already exited. */ } + } + }; + process.on("SIGTERM", stop); + process.on("SIGINT", stop); + try { + const results = await Promise.all(lanes.map(([name, command]) => new Promise(resolve => { + const started = Date.now(); + console.log(`Starting release lane: ${name}`); + const child = spawn("bash", ["-e", "-o", "pipefail", "-c", command], { + stdio: "inherit", detached: true, + }); + children.add(child); + child.on("error", () => { children.delete(child); stop(); resolve(false); }); + child.on("exit", (code) => { + children.delete(child); + console.log(`Release lane ${name}: ${code === 0 ? "passed" : "failed"} (${Math.round((Date.now() - started) / 1000)}s)`); + if (code !== 0) stop(); + resolve(code === 0); + }); + }))); + return !failed && results.every(Boolean); + } finally { + clearTimeout(killTimer); + process.off("SIGTERM", stop); + process.off("SIGINT", stop); + } +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + const args = process.argv.slice(2); + if (args.length % 2) throw new Error("Expected name/command pairs"); + const lanes = []; + for (let i = 0; i < args.length; i += 2) lanes.push(args.slice(i, i + 2)); + if (!await runParallel(lanes)) process.exitCode = 1; +} diff --git a/landing-zone-configurator/infra/ci/runner-evidence.test.mjs b/landing-zone-configurator/infra/ci/runner-evidence.test.mjs index bad271f..02aaf45 100644 --- a/landing-zone-configurator/infra/ci/runner-evidence.test.mjs +++ b/landing-zone-configurator/infra/ci/runner-evidence.test.mjs @@ -80,16 +80,17 @@ test('qualifies initial platform capabilities against source identity and lock b } }); -test('verifies the staged runner image before migrating or replacing the web application',()=>{ +test('verifies the staged runner image before activating the web application',()=>{ const workflow=readFileSync(fileURLToPath(new URL('../../../.github/workflows/configurator-release.yml',import.meta.url)),'utf8'); - const imageTask=workflow.indexOf('--name "image-$GITHUB_RUN_ID"'); - assert.ok(imageTask>workflow.indexOf('cf push --task -f deploy/runner/manifest.yml')); - assert.ok(imageTaskrunner.indexOf('cf push --task -f deploy/runner/manifest.yml')); + assert.ok(workflow.indexOf('stage-release-runner.sh'){ diff --git a/landing-zone-configurator/infra/ci/stage-release-application.sh b/landing-zone-configurator/infra/ci/stage-release-application.sh new file mode 100644 index 0000000..2833c14 --- /dev/null +++ b/landing-zone-configurator/infra/ci/stage-release-application.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail +# Staging uses a private snapshot with execution disabled and no Runner binding. +# Existing processes keep their previous environment; no new process starts. +cf push --no-start -f deploy/cloud-foundry/manifest.yml -p .local/release --vars-file "$LZC_PRIVATE/app-stage-vars.json" --redact-env +app_id="$(cf app landing-zone-configurator --guid)" +package_id="$(cf curl "/v3/packages?app_guids=$app_id&order_by=-created_at&per_page=1" | jq -er '.resources | if length == 1 and .[0].state == "READY" then .[0].guid else error("Expected a ready application package") end')" +cf stage-package landing-zone-configurator --package-guid "$package_id" +cf curl "/v3/builds?app_guids=$app_id&order_by=-created_at&per_page=1" | jq -ce --arg package "$package_id" --arg app "$app_id" '.resources | if length == 1 and .[0].state == "STAGED" and .[0].package.guid == $package then {app: $app, package: $package, build: .[0].guid, droplet: .[0].droplet.guid} else error("Application build does not match the release package") end' > "$LZC_PRIVATE/app-stage.json" +# No set-droplet/start/restart here. Activation is gated on ALL lane results. diff --git a/landing-zone-configurator/infra/ci/stage-release-migration.sh b/landing-zone-configurator/infra/ci/stage-release-migration.sh new file mode 100644 index 0000000..5b5dbaa --- /dev/null +++ b/landing-zone-configurator/infra/ci/stage-release-migration.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +set -euo pipefail +export CF_HOME="$LZC_PRIVATE/cf-migration" +cf api "$CF_API_URL" +cf auth +cf target -o lzc-dev -s configurator +test "$(cf space configurator --guid)" = "$LZC_CF_SPACE_ID" +trap 'cf delete landing-zone-configurator-migrate -f' EXIT +cf push --task -f deploy/cloud-foundry/migration-manifest.yml -p .local/migration --vars-file "$LZC_PRIVATE/migration-vars.json" --redact-env +migration_status=0 +timeout --signal=TERM --kill-after=15s 300 cf run-task landing-zone-configurator-migrate --command "./runtime/bin/node apps/api/dist/migrate.js $GITHUB_RUN_ID" --name "migrate-$GITHUB_RUN_ID" -m 128M -k 4096M --wait || migration_status=$? +evidence_status=1 +for attempt in 1 2 3; do + if cf logs landing-zone-configurator-migrate --recent | node infra/ci/migration-evidence.mjs "$GITHUB_RUN_ID" > "$LZC_PRIVATE/migration-evidence.json"; then + cat "$LZC_PRIVATE/migration-evidence.json" + evidence_status=0 + break + fi + if [ -s "$LZC_PRIVATE/migration-evidence.json" ]; then + cat "$LZC_PRIVATE/migration-evidence.json" + break + fi + if [ "$attempt" -lt 3 ]; then sleep 5; fi +done +test "$migration_status" = 0 +test "$evidence_status" = 0 +# Explicit cleanup failure is a failed lane; EXIT remains a second cleanup attempt. +cf delete landing-zone-configurator-migrate -f +trap - EXIT diff --git a/landing-zone-configurator/infra/ci/stage-release-runner.sh b/landing-zone-configurator/infra/ci/stage-release-runner.sh new file mode 100644 index 0000000..a5673d5 --- /dev/null +++ b/landing-zone-configurator/infra/ci/stage-release-runner.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +set -euo pipefail +export CF_HOME="$LZC_PRIVATE/cf-runner" +cf api "$CF_API_URL" +CF_USERNAME="$LZC_RUNNER_CF_USERNAME" CF_PASSWORD="$LZC_RUNNER_CF_PASSWORD" cf auth +cf target -o lzc-dev-runners -s plans +test "$(cf org lzc-dev-runners --guid)" = "$LZC_RUNNER_ORG_ID" +test "$(cf space plans --guid)" = "$LZC_RUNNER_SPACE_ID" +cf push --task -f deploy/runner/manifest.yml -p .local/runner --vars-file "$LZC_PRIVATE/runner-vars.json" --redact-env +export LZC_RUNNER_TEMPLATE_ID="$(cf app lzc-plan-template --guid)" +export LZC_RUNNER_DROPLET_ID="$(cf curl "/v3/apps/$LZC_RUNNER_TEMPLATE_ID/droplets/current" | jq -er '.guid')" +node infra/ci/prepare-release.mjs runner +cf run-task lzc-plan-template --command "./runtime/bin/node --input-type=module -e 'import { platformFirewallImage } from \"@lzc/contracts\"; console.log(platformFirewallImage.sha256 + \" accelerator/firewall-image.qcow2\");' | sha256sum --check" --name "image-$GITHUB_RUN_ID" -m 128M -k 4096M --wait +cf run-task lzc-plan-template --command 'TF_CLI_CONFIG_FILE=/home/vcap/app/runner.tfrc ./tools/tofu -chdir=accelerator init -backend=false -input=false -lockfile=readonly -no-color && ./tools/tofu -chdir=accelerator validate -no-color' --name "engine-$GITHUB_RUN_ID" -m 1024M -k 4096M --wait +cf run-task lzc-plan-template --command 'TF_CLI_CONFIG_FILE=/home/vcap/app/runner.tfrc ./tools/tofu -chdir=application-src/application init -backend=false -input=false -lockfile=readonly -no-color && ./tools/tofu -chdir=application-src/application validate -no-color' --name "application-engine-$GITHUB_RUN_ID" -m 1024M -k 4096M --wait