From 8fcbc36c89307cd1fcfafb325574a84096c837f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Weberru=C3=9F?= Date: Tue, 29 Sep 2026 18:36:33 +0200 Subject: [PATCH 001/175] feat(configurator): add isolated application and infrastructure foundation --- .github/workflows/configurator-bootstrap.yml | 105 + .github/workflows/validate-configurator.yml | 82 + .gitignore | 4 + CODEOWNERS | 6 + README.md | 2 + landing-zone-configurator/.gitignore | 20 + landing-zone-configurator/README.md | 54 + landing-zone-configurator/app/.node-version | 1 + landing-zone-configurator/app/.npmrc | 2 + landing-zone-configurator/app/README.md | 61 + .../app/apps/api/README.md | 3 + .../app/apps/api/package.json | 14 + .../app/apps/api/src/app.ts | 36 + .../app/apps/api/src/main.ts | 20 + .../app/apps/api/tsconfig.json | 13 + .../app/apps/web/README.md | 3 + .../app/apps/web/index.html | 12 + .../app/apps/web/package.json | 22 + .../app/apps/web/src/main.tsx | 45 + .../app/apps/web/src/style.css | 30 + .../app/apps/web/tsconfig.json | 16 + .../app/apps/web/vite.config.ts | 14 + .../app/apps/worker/README.md | 5 + .../app/apps/worker/package.json | 11 + .../app/apps/worker/src/main.ts | 5 + .../app/apps/worker/tsconfig.json | 8 + landing-zone-configurator/app/biome.json | 23 + .../app/package-lock.json | 2981 +++++++++++++++++ landing-zone-configurator/app/package.json | 35 + .../app/packages/contracts/README.md | 3 + .../app/packages/contracts/package.json | 18 + .../app/packages/contracts/src/index.ts | 8 + .../app/packages/contracts/tsconfig.json | 8 + .../app/packages/domain/README.md | 3 + .../app/packages/domain/package.json | 18 + .../app/packages/domain/src/index.ts | 33 + .../app/packages/domain/tsconfig.json | 8 + .../app/scripts/platform-check.mjs | 170 + .../app/tests/api.test.ts | 33 + .../app/tests/tenant-policy.test.ts | 37 + .../app/tsconfig.base.json | 17 + landing-zone-configurator/app/tsconfig.json | 17 + .../app/tsconfig.tests.json | 9 + .../deploy/cloud-foundry/README.md | 5 + landing-zone-configurator/docs/cicd.md | 104 + .../docs/decisions/0001-application-stack.md | 48 + landing-zone-configurator/docs/planning.md | 596 ++++ .../docs/platform-check.md | 74 + landing-zone-configurator/infra/README.md | 125 + .../infra/backend/.terraform.lock.hcl | 39 + .../infra/backend/1-versioning.tf | 10 + .../infra/backend/README.md | 9 + .../infra/backend/outputs.tf | 9 + .../infra/backend/providers.tf | 14 + .../infra/backend/terraform.tf | 28 + .../infra/backend/tests/backend.tftest.hcl | 16 + .../infra/backend/variables.tf | 18 + .../infra/bootstrap/.terraform.lock.hcl | 36 + .../infra/bootstrap/1-state-storage.tf | 20 + .../infra/bootstrap/README.md | 11 + .../infra/bootstrap/outputs.tf | 17 + .../infra/bootstrap/providers.tf | 3 + .../infra/bootstrap/terraform.tf | 28 + .../bootstrap/tests/bootstrap.tftest.hcl | 31 + .../infra/bootstrap/variables.tf | 35 + .../infra/check-management-backend.mjs | 62 + .../infra/check-state-lock.mjs | 91 + landing-zone-configurator/infra/ci/README.md | 44 + .../infra/ci/backend-safety.json | 8 + .../infra/ci/configure-github.mjs | 58 + .../infra/ci/context.mjs | 43 + .../infra/ci/context.test.mjs | 40 + .../infra/ci/github-environments.json | 10 + landing-zone-configurator/infra/ci/run.mjs | 96 + .../infra/environments/README.md | 3 + .../environments/development.tfvars.example | 16 + .../infra/modules/README.md | 3 + .../infra/plan-guard.mjs | 6 + .../infra/plan-guard.test.mjs | 23 + .../infra/platform/.terraform.lock.hcl | 36 + .../infra/platform/1-cloud-foundry.tf | 19 + .../infra/platform/2-database.tf | 39 + .../infra/platform/3-secrets.tf | 19 + .../infra/platform/4-artifacts.tf | 7 + .../infra/platform/5-model-serving.tf | 9 + .../infra/platform/README.md | 9 + .../infra/platform/outputs.tf | 25 + .../infra/platform/providers.tf | 3 + .../infra/platform/terraform.tf | 26 + .../infra/platform/tests/platform.tftest.hcl | 70 + .../infra/platform/variables.tf | 69 + .../infra/seed-protection/.terraform.lock.hcl | 39 + .../infra/seed-protection/1-versioning.tf | 10 + .../infra/seed-protection/README.md | 3 + .../infra/seed-protection/outputs.tf | 9 + .../infra/seed-protection/providers.tf | 12 + .../infra/seed-protection/terraform.tf | 28 + .../seed-protection/tests/backend.tftest.hcl | 14 + .../infra/seed-protection/variables.tf | 9 + .../infra/seed/.terraform.lock.hcl | 36 + .../infra/seed/1-state-storage.tf | 20 + .../infra/seed/README.md | 3 + .../infra/seed/outputs.tf | 17 + .../infra/seed/providers.tf | 3 + .../infra/seed/terraform.tf | 28 + .../infra/seed/tests/bootstrap.tftest.hcl | 31 + .../infra/seed/variables.tf | 35 + landing-zone-configurator/infra/state-key.mjs | 23 + .../infra/state-key.test.mjs | 33 + landing-zone-configurator/infra/tofu.mjs | 181 + landing-zone-configurator/mise.toml | 6 + landing-zone-configurator/runner/README.md | 5 + .../tools/hcl-adapter/README.md | 3 + 113 files changed, 6673 insertions(+) create mode 100644 .github/workflows/configurator-bootstrap.yml create mode 100644 .github/workflows/validate-configurator.yml create mode 100644 landing-zone-configurator/.gitignore create mode 100644 landing-zone-configurator/README.md create mode 100644 landing-zone-configurator/app/.node-version create mode 100644 landing-zone-configurator/app/.npmrc create mode 100644 landing-zone-configurator/app/README.md create mode 100644 landing-zone-configurator/app/apps/api/README.md create mode 100644 landing-zone-configurator/app/apps/api/package.json create mode 100644 landing-zone-configurator/app/apps/api/src/app.ts create mode 100644 landing-zone-configurator/app/apps/api/src/main.ts create mode 100644 landing-zone-configurator/app/apps/api/tsconfig.json create mode 100644 landing-zone-configurator/app/apps/web/README.md create mode 100644 landing-zone-configurator/app/apps/web/index.html create mode 100644 landing-zone-configurator/app/apps/web/package.json create mode 100644 landing-zone-configurator/app/apps/web/src/main.tsx create mode 100644 landing-zone-configurator/app/apps/web/src/style.css create mode 100644 landing-zone-configurator/app/apps/web/tsconfig.json create mode 100644 landing-zone-configurator/app/apps/web/vite.config.ts create mode 100644 landing-zone-configurator/app/apps/worker/README.md create mode 100644 landing-zone-configurator/app/apps/worker/package.json create mode 100644 landing-zone-configurator/app/apps/worker/src/main.ts create mode 100644 landing-zone-configurator/app/apps/worker/tsconfig.json create mode 100644 landing-zone-configurator/app/biome.json create mode 100644 landing-zone-configurator/app/package-lock.json create mode 100644 landing-zone-configurator/app/package.json create mode 100644 landing-zone-configurator/app/packages/contracts/README.md create mode 100644 landing-zone-configurator/app/packages/contracts/package.json create mode 100644 landing-zone-configurator/app/packages/contracts/src/index.ts create mode 100644 landing-zone-configurator/app/packages/contracts/tsconfig.json create mode 100644 landing-zone-configurator/app/packages/domain/README.md create mode 100644 landing-zone-configurator/app/packages/domain/package.json create mode 100644 landing-zone-configurator/app/packages/domain/src/index.ts create mode 100644 landing-zone-configurator/app/packages/domain/tsconfig.json create mode 100644 landing-zone-configurator/app/scripts/platform-check.mjs create mode 100644 landing-zone-configurator/app/tests/api.test.ts create mode 100644 landing-zone-configurator/app/tests/tenant-policy.test.ts create mode 100644 landing-zone-configurator/app/tsconfig.base.json create mode 100644 landing-zone-configurator/app/tsconfig.json create mode 100644 landing-zone-configurator/app/tsconfig.tests.json create mode 100644 landing-zone-configurator/deploy/cloud-foundry/README.md create mode 100644 landing-zone-configurator/docs/cicd.md create mode 100644 landing-zone-configurator/docs/decisions/0001-application-stack.md create mode 100644 landing-zone-configurator/docs/planning.md create mode 100644 landing-zone-configurator/docs/platform-check.md create mode 100644 landing-zone-configurator/infra/README.md create mode 100644 landing-zone-configurator/infra/backend/.terraform.lock.hcl create mode 100644 landing-zone-configurator/infra/backend/1-versioning.tf create mode 100644 landing-zone-configurator/infra/backend/README.md create mode 100644 landing-zone-configurator/infra/backend/outputs.tf create mode 100644 landing-zone-configurator/infra/backend/providers.tf create mode 100644 landing-zone-configurator/infra/backend/terraform.tf create mode 100644 landing-zone-configurator/infra/backend/tests/backend.tftest.hcl create mode 100644 landing-zone-configurator/infra/backend/variables.tf create mode 100644 landing-zone-configurator/infra/bootstrap/.terraform.lock.hcl create mode 100644 landing-zone-configurator/infra/bootstrap/1-state-storage.tf create mode 100644 landing-zone-configurator/infra/bootstrap/README.md create mode 100644 landing-zone-configurator/infra/bootstrap/outputs.tf create mode 100644 landing-zone-configurator/infra/bootstrap/providers.tf create mode 100644 landing-zone-configurator/infra/bootstrap/terraform.tf create mode 100644 landing-zone-configurator/infra/bootstrap/tests/bootstrap.tftest.hcl create mode 100644 landing-zone-configurator/infra/bootstrap/variables.tf create mode 100644 landing-zone-configurator/infra/check-management-backend.mjs create mode 100644 landing-zone-configurator/infra/check-state-lock.mjs create mode 100644 landing-zone-configurator/infra/ci/README.md create mode 100644 landing-zone-configurator/infra/ci/backend-safety.json create mode 100644 landing-zone-configurator/infra/ci/configure-github.mjs create mode 100644 landing-zone-configurator/infra/ci/context.mjs create mode 100644 landing-zone-configurator/infra/ci/context.test.mjs create mode 100644 landing-zone-configurator/infra/ci/github-environments.json create mode 100644 landing-zone-configurator/infra/ci/run.mjs create mode 100644 landing-zone-configurator/infra/environments/README.md create mode 100644 landing-zone-configurator/infra/environments/development.tfvars.example create mode 100644 landing-zone-configurator/infra/modules/README.md create mode 100644 landing-zone-configurator/infra/plan-guard.mjs create mode 100644 landing-zone-configurator/infra/plan-guard.test.mjs create mode 100644 landing-zone-configurator/infra/platform/.terraform.lock.hcl create mode 100644 landing-zone-configurator/infra/platform/1-cloud-foundry.tf create mode 100644 landing-zone-configurator/infra/platform/2-database.tf create mode 100644 landing-zone-configurator/infra/platform/3-secrets.tf create mode 100644 landing-zone-configurator/infra/platform/4-artifacts.tf create mode 100644 landing-zone-configurator/infra/platform/5-model-serving.tf create mode 100644 landing-zone-configurator/infra/platform/README.md create mode 100644 landing-zone-configurator/infra/platform/outputs.tf create mode 100644 landing-zone-configurator/infra/platform/providers.tf create mode 100644 landing-zone-configurator/infra/platform/terraform.tf create mode 100644 landing-zone-configurator/infra/platform/tests/platform.tftest.hcl create mode 100644 landing-zone-configurator/infra/platform/variables.tf create mode 100644 landing-zone-configurator/infra/seed-protection/.terraform.lock.hcl create mode 100644 landing-zone-configurator/infra/seed-protection/1-versioning.tf create mode 100644 landing-zone-configurator/infra/seed-protection/README.md create mode 100644 landing-zone-configurator/infra/seed-protection/outputs.tf create mode 100644 landing-zone-configurator/infra/seed-protection/providers.tf create mode 100644 landing-zone-configurator/infra/seed-protection/terraform.tf create mode 100644 landing-zone-configurator/infra/seed-protection/tests/backend.tftest.hcl create mode 100644 landing-zone-configurator/infra/seed-protection/variables.tf create mode 100644 landing-zone-configurator/infra/seed/.terraform.lock.hcl create mode 100644 landing-zone-configurator/infra/seed/1-state-storage.tf create mode 100644 landing-zone-configurator/infra/seed/README.md create mode 100644 landing-zone-configurator/infra/seed/outputs.tf create mode 100644 landing-zone-configurator/infra/seed/providers.tf create mode 100644 landing-zone-configurator/infra/seed/terraform.tf create mode 100644 landing-zone-configurator/infra/seed/tests/bootstrap.tftest.hcl create mode 100644 landing-zone-configurator/infra/seed/variables.tf create mode 100644 landing-zone-configurator/infra/state-key.mjs create mode 100644 landing-zone-configurator/infra/state-key.test.mjs create mode 100644 landing-zone-configurator/infra/tofu.mjs create mode 100644 landing-zone-configurator/mise.toml create mode 100644 landing-zone-configurator/runner/README.md create mode 100644 landing-zone-configurator/tools/hcl-adapter/README.md diff --git a/.github/workflows/configurator-bootstrap.yml b/.github/workflows/configurator-bootstrap.yml new file mode 100644 index 0000000..3dc18ab --- /dev/null +++ b/.github/workflows/configurator-bootstrap.yml @@ -0,0 +1,105 @@ +name: Configurator Bootstrap + +on: + workflow_dispatch: + inputs: + root: + description: 'First bootstrap, then backend (bucket versioning)' + type: choice + options: [bootstrap, backend] + default: bootstrap + apply: + description: 'Apply the reviewed plan after environment approval' + type: boolean + default: false + +permissions: + contents: read + +concurrency: + group: configurator-lzc-dev-mutation + cancel-in-progress: false + +env: + LZC_ENVIRONMENT: lzc-dev + LZC_ROOT: ${{ inputs.root }} + +jobs: + plan: + if: github.ref == 'refs/heads/main' && github.run_attempt == 1 + runs-on: ubuntu-latest + timeout-minutes: 20 + environment: lzc-dev-bootstrap-plan + defaults: + run: + working-directory: landing-zone-configurator + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version-file: landing-zone-configurator/app/.node-version + - uses: opentofu/setup-opentofu@a1320f892987e89d278cc92dc5adc984fb93aca4 # v2.0.2 + with: + tofu_version: '1.12.6' + tofu_wrapper: false + - name: Create encrypted review plan + run: node infra/ci/run.mjs plan + env: + LZC_PROJECT_ID: ${{ vars.LZC_PROJECT_ID }} + LZC_REGION: ${{ vars.LZC_REGION }} + LZC_NAME_PREFIX: ${{ vars.LZC_NAME_PREFIX }} + LZC_MANAGEMENT_BUCKET: ${{ vars.LZC_MANAGEMENT_BUCKET }} + LZC_CREDENTIAL_EXPIRATION: ${{ vars.LZC_CREDENTIAL_EXPIRATION }} + LZC_MANAGEMENT_ACCESS_KEY: ${{ secrets.LZC_MANAGEMENT_ACCESS_KEY }} + LZC_MANAGEMENT_SECRET_KEY: ${{ secrets.LZC_MANAGEMENT_SECRET_KEY }} + LZC_SERVICE_ACCOUNT_KEY: ${{ secrets.LZC_SERVICE_ACCOUNT_KEY }} + LZC_STATE_KEY_BOOTSTRAP: ${{ secrets.LZC_STATE_KEY_BOOTSTRAP }} + LZC_STATE_KEY_BACKEND: ${{ secrets.LZC_STATE_KEY_BACKEND }} + - name: Upload encrypted plan and review metadata + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: bootstrap-plan-${{ github.run_id }} + path: landing-zone-configurator/.local/ci-plan/* + include-hidden-files: true + if-no-files-found: error + retention-days: 1 + + apply: + needs: plan + if: inputs.apply && github.run_attempt == 1 + runs-on: ubuntu-latest + timeout-minutes: 20 + environment: lzc-dev-bootstrap-apply + defaults: + run: + working-directory: landing-zone-configurator + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version-file: landing-zone-configurator/app/.node-version + - uses: opentofu/setup-opentofu@a1320f892987e89d278cc92dc5adc984fb93aca4 # v2.0.2 + with: + tofu_version: '1.12.6' + tofu_wrapper: false + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: bootstrap-plan-${{ github.run_id }} + path: landing-zone-configurator/.local/ci-plan + - name: Apply exactly the reviewed plan + run: node infra/ci/run.mjs apply + env: + LZC_PROJECT_ID: ${{ vars.LZC_PROJECT_ID }} + LZC_REGION: ${{ vars.LZC_REGION }} + LZC_NAME_PREFIX: ${{ vars.LZC_NAME_PREFIX }} + LZC_MANAGEMENT_BUCKET: ${{ vars.LZC_MANAGEMENT_BUCKET }} + LZC_CREDENTIAL_EXPIRATION: ${{ vars.LZC_CREDENTIAL_EXPIRATION }} + LZC_MANAGEMENT_ACCESS_KEY: ${{ secrets.LZC_MANAGEMENT_ACCESS_KEY }} + LZC_MANAGEMENT_SECRET_KEY: ${{ secrets.LZC_MANAGEMENT_SECRET_KEY }} + LZC_SERVICE_ACCOUNT_KEY: ${{ secrets.LZC_SERVICE_ACCOUNT_KEY }} + LZC_STATE_KEY_BOOTSTRAP: ${{ secrets.LZC_STATE_KEY_BOOTSTRAP }} + LZC_STATE_KEY_BACKEND: ${{ secrets.LZC_STATE_KEY_BACKEND }} diff --git a/.github/workflows/validate-configurator.yml b/.github/workflows/validate-configurator.yml new file mode 100644 index 0000000..8066754 --- /dev/null +++ b/.github/workflows/validate-configurator.yml @@ -0,0 +1,82 @@ +name: Validate Configurator + +on: + pull_request: + paths: + - 'landing-zone-configurator/**' + - '.github/workflows/validate-configurator.yml' + - '.github/workflows/configurator-bootstrap.yml' + push: + branches: [main] + paths: + - 'landing-zone-configurator/**' + - '.github/workflows/validate-configurator.yml' + - '.github/workflows/configurator-bootstrap.yml' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: configurator-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + application: + runs-on: ubuntu-latest + timeout-minutes: 15 + defaults: + run: + working-directory: landing-zone-configurator/app + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Setup Node.js + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version-file: landing-zone-configurator/app/.node-version + cache: npm + cache-dependency-path: landing-zone-configurator/app/package-lock.json + - name: Pin npm + run: npm install --global npm@11.19.0 + - name: Install dependencies + run: npm ci + - name: Lint, typecheck, build and test + run: npm run check + + infrastructure: + name: OpenTofu (${{ matrix.root }}) + runs-on: ubuntu-latest + timeout-minutes: 20 + strategy: + matrix: + root: [seed, seed-protection, bootstrap, backend, platform] + defaults: + run: + working-directory: landing-zone-configurator + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Setup Node.js + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version-file: landing-zone-configurator/app/.node-version + - name: Setup OpenTofu + uses: opentofu/setup-opentofu@a1320f892987e89d278cc92dc5adc984fb93aca4 # v2.0.2 + with: + tofu_version: '1.12.6' + tofu_wrapper: false + - name: Check operator plan guard + run: node --test infra/plan-guard.test.mjs infra/state-key.test.mjs infra/ci/context.test.mjs + - name: Check formatting + run: tofu -chdir=infra fmt -check -recursive + - name: Initialize without backend + run: node infra/tofu.mjs ${{ matrix.root }} init -backend=false -lockfile=readonly -input=false + - name: Validate + run: node infra/tofu.mjs ${{ matrix.root }} validate -no-color + - name: Test plans with mock providers + run: node infra/tofu.mjs ${{ matrix.root }} test -no-color diff --git a/.gitignore b/.gitignore index 86556fc..7ab4943 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,7 @@ src/.firewall-api-credentials.json .DS_Store src/backend_ci.tf + +/landing-zone-configurator.env + +/landing-zone-configurator-credentials.json diff --git a/CODEOWNERS b/CODEOWNERS index d89e207..118bba1 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1 +1,7 @@ * @lweberru @JoshSTACKIT @dweezl @LembDo + +# Landing Zone Configurator: maintained independently by @lweberru. +# Keep these overrides below the repository-wide default (last match wins). +/landing-zone-configurator/ @lweberru +/.github/workflows/*configurator*.yml @lweberru +/.github/workflows/*configurator*.yaml @lweberru diff --git a/README.md b/README.md index 323f1bf..da5afcb 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,8 @@ The STACKIT Landing Zone Accelerator provides a comprehensive Terraform-based fr - [Getting Started](docs/getting-started.md) - [Architecture/Modules](docs/architecture.md) +- [Landing Zone Configurator](landing-zone-configurator/README.md) +- [Configurator Architecture and Planning](landing-zone-configurator/docs/planning.md) ## 🤝 Contributing diff --git a/landing-zone-configurator/.gitignore b/landing-zone-configurator/.gitignore new file mode 100644 index 0000000..2832a8d --- /dev/null +++ b/landing-zone-configurator/.gitignore @@ -0,0 +1,20 @@ +# Application output and local configuration +node_modules/ +dist/ +coverage/ +.env +.env.* +!.env.example +*.tsbuildinfo + +# Commit provider lockfiles for executable OpenTofu roots +!infra/bootstrap/.terraform.lock.hcl +!infra/platform/.terraform.lock.hcl + +# Local operator state encryption material and plans +.local/ + +!infra/backend/.terraform.lock.hcl + +!infra/seed/.terraform.lock.hcl +!infra/seed-protection/.terraform.lock.hcl diff --git a/landing-zone-configurator/README.md b/landing-zone-configurator/README.md new file mode 100644 index 0000000..c976b9c --- /dev/null +++ b/landing-zone-configurator/README.md @@ -0,0 +1,54 @@ +# Landing Zone Configurator + +Zentral gehostete, mandantenfähige Anwendung zum Erstellen, Bearbeiten und Deployen von Konfigurationen des STACKIT Landing Zone Accelerators. + +Status: lokale Entwicklungsbasis mit React-Startseite, Fastify-API, Tests und getrennter CI. Bootstrap-, Backend- und Plattform-IaC sind implementiert und lokal geprüft; Der separate Verwaltungs-Bucket samt Zugang und Versionierung ist per IaC provisioniert; die übrigen Plattformdienste stehen noch aus. + +Start und Prüfungen: [Entwicklungsanleitung](app/README.md). Ergebnisse der lesenden Bestandsaufnahme: [Plattformprüfung](docs/platform-check.md). + +## Dokumentation + +- [Architektur, Roadmap und Checklisten](docs/planning.md) +- [Sprach- und Technologieempfehlung](docs/decisions/0001-application-stack.md) + +## Struktur + +```text +landing-zone-configurator/ + app/ + apps/ + web/ # Browser-Oberfläche + api/ # Backend/BFF + worker/ # Queue, Scheduler und Dispatcher + packages/ + domain/ # Fachmodell, Schema, Validierung, Compiler + contracts/ # API- und Job-Verträge ohne Secrets + tools/ + hcl-adapter/ # Begrenzter Importadapter für bestehende tfvars + runner/ # Isolierte OpenTofu-Ausführung + infra/ + bootstrap/ # State-/Betriebsgrundlage vor Plattformaufbau + backend/ # S3-Versionierung nach dem State-Bootstrap + platform/ # Eigener OpenTofu-Root für Configurator-Dienste + modules/ # Nur Configurator-Infrastrukturmodule + environments/ # Secretfreie Umgebungsparameter + deploy/ + cloud-foundry/ # App-Manifeste und Release-Konfiguration + docs/ + decisions/ # Architekturentscheidungen +``` + +Die Accelerator-Quellen bleiben unter `../src/`. Der Configurator bekommt eigene Abhängigkeiten, Build-Konfigurationen, Tests, Toolversionen und IaC-States. Keine App-Dateien in `../src/` und keine Vermischung der Plattform-IaC mit Kunden-Landing-Zones. Laufende Deployments verwenden eine freigegebene Accelerator-Revision; der benachbarte Ordner ist keine unversionierte Produktionsabhängigkeit. + +Der App-Workspace verwendet npm mit gepinnten Abhängigkeiten und Lockfile. `validate-configurator.yml` prüft Configurator-Änderungen getrennt. Sobald der HCL-/Template-Import implementiert ist, zusätzlich durch Accelerator-Vertragsänderungen ausgelöste Kompatibilitätstests ergänzen. + +## Lokale Plattform-Zugangsdaten + +Vom Benutzer bereitgestellt und am bisherigen Ort belassen: + +- `../landing-zone-configurator.env`: Angaben zum vorgesehenen Betreiberprojekt im `NAME=WERT`-Format; Variablen-Mapping für die IaC vor Nutzung festlegen. +- `../landing-zone-configurator-credentials.json`: Service-Account-Datei für dieses Projekt. + +Beide Dateien sind im Root-`.gitignore` ausgeschlossen. Inhalte nicht in Git, Build-Kontexte, CF-Manifeste oder Planungsdokumente übernehmen. Künftige Deployment-Skripte lösen die Pfade explizit relativ zum Repository auf; sie laden die Dateien nicht implizit aus dem Arbeitsverzeichnis. Die vorhandene `.env` wird nicht als Shell-Skript ausgeführt. + +Diese Identität ist für die Configurator-Plattform vorgesehen, nicht als Ersatz für persönliche Kunden-Deployment-Credentials. Projekt-Lesezugriff und ausgewählte Metadatenabfragen wurden geprüft; Erstellrechte für Object Storage und S3-Versionierung sind durch den Seed-Apply nachgewiesen. Erst später, nach Definition der Eingaben, den absoluten Credential-Pfad im Plattform-Deployment-Prozess an `STACKIT_SERVICE_ACCOUNT_KEY_PATH` übergeben; die Accelerator-Konfiguration in `../mise.toml` bleibt davon unabhängig. diff --git a/landing-zone-configurator/app/.node-version b/landing-zone-configurator/app/.node-version new file mode 100644 index 0000000..df6ae33 --- /dev/null +++ b/landing-zone-configurator/app/.node-version @@ -0,0 +1 @@ +24.21.0 diff --git a/landing-zone-configurator/app/.npmrc b/landing-zone-configurator/app/.npmrc new file mode 100644 index 0000000..145d3fa --- /dev/null +++ b/landing-zone-configurator/app/.npmrc @@ -0,0 +1,2 @@ +save-exact=true +engine-strict=true diff --git a/landing-zone-configurator/app/README.md b/landing-zone-configurator/app/README.md new file mode 100644 index 0000000..7e63026 --- /dev/null +++ b/landing-zone-configurator/app/README.md @@ -0,0 +1,61 @@ +# Configurator-Entwicklung + +TypeScript-Workspace mit React/Vite, Fastify, Fachmodell und API-Verträgen. Abhängigkeiten sind exakt gepinnt, das npm-Lockfile wird versioniert. Voraussetzungen: Node.js **24.21.0**, npm **11.19.0**. `../mise.toml` und `.node-version` dokumentieren die Runtime unabhängig vom Accelerator. + +## Installieren und prüfen + +Im Verzeichnis `landing-zone-configurator/app`: + +```sh +npm ci +npm run check +``` + +`check` führt Biome, TypeScript-Prüfung einschließlich Tests, Produktionsbuild und Vitest aus. `npm run format` korrigiert Formatierung und Importreihenfolge. + +Falls die passende Runtime lokal noch fehlt, funktioniert ohne globale Installation beispielsweise: + +```sh +npm exec --yes --package=node@24.21.0 --package=npm@11.19.0 -- npm ci +npm exec --yes --package=node@24.21.0 --package=npm@11.19.0 -- npm run check +``` + +## Lokal starten + +Zwei Terminals im App-Verzeichnis: + +```sh +# Terminal 1: API auf http://127.0.0.1:3000 +npm run dev +``` + +```sh +# Terminal 2: UI auf http://127.0.0.1:5173 +npm run dev:web +``` + +Auch diese Befehle können bei abweichender lokaler Node-Version mit dem oben gezeigten `npm exec`-Präfix ausgeführt werden. Der Vite-Entwicklungsserver leitet `/healthz` und `/api` an die lokale API weiter. Gemeinsame Pakete vor UI-Start bauen (`npm run build:packages`, bereits Teil von `npm run dev`). Nach Änderungen an gemeinsamen Paketen erneut bauen; automatischer Workspace-Watch folgt später. + +`npm run build` erzeugt API-/Paket-Artefakte und `apps/web/dist`. `npm start` startet nur die gebaute API. Die Auslieferung der statischen UI auf CF gehört zum nächsten Deployment-Schritt; `vite preview` ist kein Produktionsserver. Bei CF später `HOST=0.0.0.0` und den von CF bereitgestellten `PORT` nutzen. + +## Aktueller Funktionsumfang + +- React-Startseite mit überprüftem API-Health-Vertrag. +- Öffentlicher `/healthz`-Endpunkt; er bestätigt nur Prozessgesundheit, keine vollständige Betriebsbereitschaft. +- `/api/v1/session` antwortet bis zur echten Session-Integration immer mit HTTP 401. +- Fachliche Tenant-/Rollen-Policy mit Negativtests. Mitgliedschaften müssen später aus authentisiertem serverseitigem Speicher kommen. +- Worker ist ein kompilierbarer Platzhalter und beendet sich beim Start ausdrücklich mit Fehler, bis dauerhafte Queue und Runner integriert sind. + +Noch keine GitHub-Anmeldung, Datenbank, RLS, Templates, Credential-Speicherung oder Deployments. Die getestete Policy allein ist kein Nachweis vollständiger Mehrmandantenfähigkeit. + +## Lesende Plattformprüfung + +```sh +npm run platform:check +``` + +Benötigt die STACKIT-CLI sowie die beiden ignorierten Dateien im Repository-Root. Liest `PROJECT_ID` und optional `REGION`; ohne Region wird ausschließlich für die Bestandsaufnahme `eu01` verwendet. `ORGANISATION_ID` bleibt für die spätere Organisationszuordnung reserviert. + +Das Skript führt die `.env` nicht als Shellcode aus. Es bezieht einen kurzlebigen Token ohne CLI-Login-Persistierung und fragt ausschließlich Metadaten per GET ab. Ausgabe enthält Statuscodes/Anzahlen, keine Tokens oder Rohantworten. Unerwartete Fehler führen zu Exitcode 1; der bekannte Object-Storage-Status 404 wird als `enabled: false` und anstehende IaC-Provisionierung gemeldet. Der Check provisioniert oder aktiviert nichts. Betriebssystem-Zertifikate sind für Unternehmensproxys aktiviert; TLS-Prüfung bleibt eingeschaltet. + +Der Modellkatalog beweist keine projektbezogene Inferenzaktivierung. Leserechte beweisen keine Erstell-/Änderungsrechte. CF-Plattformangebote ersetzen weder CF-Organisation noch Runtime-Zugang. Ergebnisse: [Plattformprüfung](../docs/platform-check.md). diff --git a/landing-zone-configurator/app/apps/api/README.md b/landing-zone-configurator/app/apps/api/README.md new file mode 100644 index 0000000..0ade11f --- /dev/null +++ b/landing-zone-configurator/app/apps/api/README.md @@ -0,0 +1,3 @@ +# Backend / BFF + +Geplant: Node.js/TypeScript mit Fastify als Framework-Kandidat. Sessions, Tenant-Autorisierung, GitHub, Konfigurationen und Model-Serving-Anbindung. Keine OpenTofu-Prozesse im Web-Request. diff --git a/landing-zone-configurator/app/apps/api/package.json b/landing-zone-configurator/app/apps/api/package.json new file mode 100644 index 0000000..8c6ecd4 --- /dev/null +++ b/landing-zone-configurator/app/apps/api/package.json @@ -0,0 +1,14 @@ +{ + "name": "@lzc/api", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "tsx watch src/main.ts", + "start": "node dist/main.js" + }, + "dependencies": { + "@lzc/contracts": "0.1.0", + "fastify": "5.12.5" + } +} diff --git a/landing-zone-configurator/app/apps/api/src/app.ts b/landing-zone-configurator/app/apps/api/src/app.ts new file mode 100644 index 0000000..9e1f923 --- /dev/null +++ b/landing-zone-configurator/app/apps/api/src/app.ts @@ -0,0 +1,36 @@ +import type { HealthResponse } from "@lzc/contracts"; +import Fastify from "fastify"; + +export function buildApp() { + const app = Fastify({ + logger: { + redact: { + paths: ["req.headers.authorization", "req.headers.cookie"], + censor: "[REDACTED]", + }, + }, + bodyLimit: 1024 * 1024, + }); + + app.get( + "/healthz", + async (): Promise => ({ + status: "ok", + service: "landing-zone-configurator", + authentication: "not-configured", + }), + ); + + // Until real sessions exist, no identity can be supplied through client headers. + app.register( + async (protectedApi) => { + protectedApi.addHook("onRequest", async (_request, reply) => { + return reply.code(401).send({ error: "authentication_required" }); + }); + protectedApi.get("/session", async () => ({})); + }, + { prefix: "/api/v1" }, + ); + + return app; +} diff --git a/landing-zone-configurator/app/apps/api/src/main.ts b/landing-zone-configurator/app/apps/api/src/main.ts new file mode 100644 index 0000000..d93b65d --- /dev/null +++ b/landing-zone-configurator/app/apps/api/src/main.ts @@ -0,0 +1,20 @@ +import { buildApp } from "./app.js"; + +const app = buildApp(); +const port = Number(process.env.PORT ?? "3000"); +if (!Number.isInteger(port) || port < 1 || port > 65535) { + throw new Error("PORT must be an integer between 1 and 65535"); +} +for (const signal of ["SIGTERM", "SIGINT"] as const) { + process.once(signal, () => { + void app.close().catch(() => { + process.exitCode = 1; + }); + }); +} +try { + await app.listen({ port, host: process.env.HOST ?? "127.0.0.1" }); +} catch (error) { + app.log.error(error); + process.exitCode = 1; +} diff --git a/landing-zone-configurator/app/apps/api/tsconfig.json b/landing-zone-configurator/app/apps/api/tsconfig.json new file mode 100644 index 0000000..29427d5 --- /dev/null +++ b/landing-zone-configurator/app/apps/api/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src/**/*.ts"], + "references": [ + { + "path": "../../packages/contracts" + } + ] +} diff --git a/landing-zone-configurator/app/apps/web/README.md b/landing-zone-configurator/app/apps/web/README.md new file mode 100644 index 0000000..d3857d4 --- /dev/null +++ b/landing-zone-configurator/app/apps/web/README.md @@ -0,0 +1,3 @@ +# Web-Oberfläche + +Geplant: React/TypeScript, Fachformulare, Topologie, Chat und Plan-/Run-Ansicht. Keine Cloud- oder GitHub-Tokens im Browser. diff --git a/landing-zone-configurator/app/apps/web/index.html b/landing-zone-configurator/app/apps/web/index.html new file mode 100644 index 0000000..2c7378c --- /dev/null +++ b/landing-zone-configurator/app/apps/web/index.html @@ -0,0 +1,12 @@ + + + + + + Landing Zone Configurator + + +
+ + + diff --git a/landing-zone-configurator/app/apps/web/package.json b/landing-zone-configurator/app/apps/web/package.json new file mode 100644 index 0000000..50ee3a0 --- /dev/null +++ b/landing-zone-configurator/app/apps/web/package.json @@ -0,0 +1,22 @@ +{ + "name": "@lzc/web", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --host 127.0.0.1", + "build": "vite build", + "preview": "vite preview --host 127.0.0.1" + }, + "dependencies": { + "@lzc/contracts": "0.1.0", + "react": "19.3.0", + "react-dom": "19.3.0" + }, + "devDependencies": { + "@types/react": "19.3.0", + "@types/react-dom": "19.3.0", + "@vitejs/plugin-react": "6.1.1", + "vite": "8.3.1" + } +} diff --git a/landing-zone-configurator/app/apps/web/src/main.tsx b/landing-zone-configurator/app/apps/web/src/main.tsx new file mode 100644 index 0000000..b6e29b0 --- /dev/null +++ b/landing-zone-configurator/app/apps/web/src/main.tsx @@ -0,0 +1,45 @@ +import { healthResponseSchema } from "@lzc/contracts"; +import { StrictMode, useEffect, useState } from "react"; +import { createRoot } from "react-dom/client"; +import "./style.css"; + +function App() { + const [status, setStatus] = useState("Verbindung wird geprüft …"); + useEffect(() => { + const controller = new AbortController(); + void fetch("/healthz", { signal: controller.signal }) + .then(async (response) => { + if (!response.ok) throw new Error("API unavailable"); + healthResponseSchema.parse(await response.json()); + setStatus("API erreichbar"); + }) + .catch(() => { + if (!controller.signal.aborted) setStatus("API nicht erreichbar"); + }); + return () => controller.abort(); + }, []); + + return ( +
+

STACKIT · Entwicklungsstand

+

Landing Zone Configurator

+

Landing Zones aus Vorlagen gestalten, überprüfen und bereitstellen.

+
+

Die Grundlage steht

+

{status}

+

+ Anmeldung, Template-Editor und Deployments werden als Nächstes + ergänzt. +

+
+
+ ); +} + +const root = document.getElementById("root"); +if (!root) throw new Error("Missing root element"); +createRoot(root).render( + + + , +); diff --git a/landing-zone-configurator/app/apps/web/src/style.css b/landing-zone-configurator/app/apps/web/src/style.css new file mode 100644 index 0000000..c0a7bed --- /dev/null +++ b/landing-zone-configurator/app/apps/web/src/style.css @@ -0,0 +1,30 @@ +:root { + font-family: system-ui, sans-serif; + color: #123943; + background: #f3f7f8; + font-synthesis: none; + line-height: 1.6; +} +body { + margin: 0; +} +main { + max-width: 60rem; + margin: 8vh auto; + padding: 2rem; +} +h1 { + font-size: clamp(2rem, 5vw, 3.5rem); + line-height: 1.15; +} +.eyebrow { + color: #33646f; + font-weight: 600; +} +section { + background: white; + border: 1px solid #cfdee2; + border-radius: 1rem; + padding: 1.5rem; + margin-top: 2rem; +} diff --git a/landing-zone-configurator/app/apps/web/tsconfig.json b/landing-zone-configurator/app/apps/web/tsconfig.json new file mode 100644 index 0000000..85724a9 --- /dev/null +++ b/landing-zone-configurator/app/apps/web/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2023", + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "Bundler", + "jsx": "react-jsx", + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "skipLibCheck": true, + "noEmit": true, + "types": ["vite/client"] + }, + "include": ["src", "vite.config.ts"] +} diff --git a/landing-zone-configurator/app/apps/web/vite.config.ts b/landing-zone-configurator/app/apps/web/vite.config.ts new file mode 100644 index 0000000..a2454cc --- /dev/null +++ b/landing-zone-configurator/app/apps/web/vite.config.ts @@ -0,0 +1,14 @@ +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [react()], + server: { + port: 5173, + strictPort: true, + proxy: { + "/healthz": "http://127.0.0.1:3000", + "/api": "http://127.0.0.1:3000", + }, + }, +}); diff --git a/landing-zone-configurator/app/apps/worker/README.md b/landing-zone-configurator/app/apps/worker/README.md new file mode 100644 index 0000000..33da672 --- /dev/null +++ b/landing-zone-configurator/app/apps/worker/README.md @@ -0,0 +1,5 @@ +# Worker + +Eigener Workspace und kompilierbarer Einstiegspunkt. Start schlägt bewusst mit Exitcode 1 fehl, solange Queue, Jobautorisierung und Runner-Anbindung fehlen. Keine Hintergrundaufträge und keine OpenTofu-Ausführung implementiert. + +Ziel: Queue, Scheduler und Dispatcher als eigener CF-Prozess; Terraform-Läufe ausschließlich in isolierten Runnern. diff --git a/landing-zone-configurator/app/apps/worker/package.json b/landing-zone-configurator/app/apps/worker/package.json new file mode 100644 index 0000000..0641f4f --- /dev/null +++ b/landing-zone-configurator/app/apps/worker/package.json @@ -0,0 +1,11 @@ +{ + "name": "@lzc/worker", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "tsx watch src/main.ts", + "start": "node dist/main.js" + }, + "dependencies": {} +} diff --git a/landing-zone-configurator/app/apps/worker/src/main.ts b/landing-zone-configurator/app/apps/worker/src/main.ts new file mode 100644 index 0000000..37d5006 --- /dev/null +++ b/landing-zone-configurator/app/apps/worker/src/main.ts @@ -0,0 +1,5 @@ +// Deliberately fails closed until a durable queue and run authorization exist. +console.error( + "Worker is not configured: durable queue and runner integration are pending.", +); +process.exitCode = 1; diff --git a/landing-zone-configurator/app/apps/worker/tsconfig.json b/landing-zone-configurator/app/apps/worker/tsconfig.json new file mode 100644 index 0000000..df59da5 --- /dev/null +++ b/landing-zone-configurator/app/apps/worker/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src/**/*.ts"] +} diff --git a/landing-zone-configurator/app/biome.json b/landing-zone-configurator/app/biome.json new file mode 100644 index 0000000..48e504d --- /dev/null +++ b/landing-zone-configurator/app/biome.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.5.14/schema.json", + "files": { + "includes": ["**", "!**/dist", "!**/node_modules", "!**/package-lock.json"] + }, + "formatter": { + "indentStyle": "space", + "indentWidth": 2 + }, + "linter": { + "enabled": true, + "rules": { + "preset": "recommended" + } + }, + "assist": { + "actions": { + "source": { + "organizeImports": "on" + } + } + } +} diff --git a/landing-zone-configurator/app/package-lock.json b/landing-zone-configurator/app/package-lock.json new file mode 100644 index 0000000..9183e03 --- /dev/null +++ b/landing-zone-configurator/app/package-lock.json @@ -0,0 +1,2981 @@ +{ + "name": "landing-zone-configurator", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "landing-zone-configurator", + "version": "0.1.0", + "workspaces": [ + "apps/*", + "packages/*" + ], + "devDependencies": { + "@biomejs/biome": "2.5.14", + "@types/node": "24.19.0", + "tsx": "4.23.15", + "typescript": "7.0.2", + "vitest": "5.0.2" + }, + "engines": { + "node": ">=24.21.0 <25", + "npm": ">=11.19.0 <12" + } + }, + "apps/api": { + "name": "@lzc/api", + "version": "0.1.0", + "dependencies": { + "@lzc/contracts": "0.1.0", + "fastify": "5.12.5" + } + }, + "apps/web": { + "name": "@lzc/web", + "version": "0.1.0", + "dependencies": { + "@lzc/contracts": "0.1.0", + "react": "19.3.0", + "react-dom": "19.3.0" + }, + "devDependencies": { + "@types/react": "19.3.0", + "@types/react-dom": "19.3.0", + "@vitejs/plugin-react": "6.1.1", + "vite": "8.3.1" + } + }, + "apps/worker": { + "name": "@lzc/worker", + "version": "0.1.0" + }, + "node_modules/@biomejs/biome": { + "version": "2.5.14", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.5.14.tgz", + "integrity": "sha512-0FabLIjd4M/dm8VFI86RMaLLdgepzbdfiAL2R8cr7V81OYYrP1w7Z73KfAwPK5h9SrEBXTzNG2y+mBwPo9xRnw==", + "dev": true, + "license": "MIT OR Apache-2.0", + "bin": { + "biome": "bin/biome" + }, + "engines": { + "node": ">=14.21.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/biome" + }, + "optionalDependencies": { + "@biomejs/cli-darwin-arm64": "2.5.14", + "@biomejs/cli-darwin-x64": "2.5.14", + "@biomejs/cli-linux-arm64": "2.5.14", + "@biomejs/cli-linux-arm64-musl": "2.5.14", + "@biomejs/cli-linux-x64": "2.5.14", + "@biomejs/cli-linux-x64-musl": "2.5.14", + "@biomejs/cli-win32-arm64": "2.5.14", + "@biomejs/cli-win32-x64": "2.5.14" + } + }, + "node_modules/@biomejs/cli-darwin-arm64": { + "version": "2.5.14", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.5.14.tgz", + "integrity": "sha512-UnzaXO65L4tsZimFITFP2M121GyhDcWFrT3pL5ZJ5U4XcS/0L5VHYytVohdCf/gnDUFHgl2I9xnt5bV/J1kxHQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-darwin-x64": { + "version": "2.5.14", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.5.14.tgz", + "integrity": "sha512-kiy8qA16K93J7uvFfWi4LgjqDpnRKyePAna6A0Y4jxyUga35SYpIZ2cNWlhy0lsfgqRenCpfymnJWEZ6mgpRgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64": { + "version": "2.5.14", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.5.14.tgz", + "integrity": "sha512-vO/9BaU1n30CiFNLx49gMTMtbCAAqlo/EqFoG0BU3deQInTbJrmXSmTQ9e3FoDZIKQnvSLDVNL4lx1ci7y1T1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64-musl": { + "version": "2.5.14", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.5.14.tgz", + "integrity": "sha512-SJ9PrZkBnnH9dHJDnxk34vKs0GB2dbsioaft6/hPhWJ7AHpwYH83Isnhj0FSRpFkGgpVfJ1lds23ApB2czUDLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64": { + "version": "2.5.14", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.5.14.tgz", + "integrity": "sha512-VHZRa7CCQBUWKxNwUvHJeuW03WFRgN5NWO//SDGOitc9QIeNyfA42V9zlKm+x82xFSG9Bzi5fi+hbhm9anO8yA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64-musl": { + "version": "2.5.14", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.5.14.tgz", + "integrity": "sha512-2kI5PrMgW5dcEZYrstLPmUmCwkUwZY39rP4BN93Vxbwcs2O57LDQOktUZZYPvvspN5i0mhGr3TJtF5sU26NUWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-arm64": { + "version": "2.5.14", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.5.14.tgz", + "integrity": "sha512-pHgAFffmZtaYoxavEsWEYNvcA7NwOIjLyqw2HXyLbt16xYryX0L4fMV2u0G9ag6LNYPIoqWaWqzUcnc6rLGGXg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-x64": { + "version": "2.5.14", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.5.14.tgz", + "integrity": "sha512-oJWmBhoHsnhUKIke+0gXDX0mltJrWHA1UyHsrTlXwX0TL64ilVZAo+TYZm97baecV0esBdpzy3k96q+09JaZUQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@fastify/ajv-compiler": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.6.tgz", + "integrity": "sha512-NtuzM0SfaMJbGlnjr9LWQUN5LzgSrbB8tf/wRZNas+4E1O/Nmzl53e7ruT61HDZyRCJGC6FxIogmNZO1c5ETBA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^4.0.0" + } + }, + "node_modules/@fastify/error": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz", + "integrity": "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/fast-json-stringify-compiler": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-5.1.0.tgz", + "integrity": "sha512-PxcYtKLbQ8Z+yApiqjK8FwxIwvEj38k2OiLc17u8dkJSlmfi2wHHPaSnaoqBPQqtvF8YVsDgDpP2snDCfFrpfw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fast-json-stringify": "^7.0.0" + } + }, + "node_modules/@fastify/forwarded": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@fastify/forwarded/-/forwarded-3.0.2.tgz", + "integrity": "sha512-NE8HgKLgYejV9lDpqkEFaDKMLYelJBVfHekhB0UKvX0ghagXRJqg68feg8er1NPXxG4N9i6vPxzt8E+3wHfcmA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/merge-json-schemas": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz", + "integrity": "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@fastify/proxy-addr": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.1.tgz", + "integrity": "sha512-zv07Y9GEuDsJPegZoDFd4SDWaZOW8N2pa0GSrYmKpId/tjt1Hgo3BjZBVjdVpfVrHaA+Qv5jawtS2O50J5xM9g==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/forwarded": "^3.0.0", + "ipaddr.js": "^2.1.0" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@lzc/api": { + "resolved": "apps/api", + "link": true + }, + "node_modules/@lzc/contracts": { + "resolved": "packages/contracts", + "link": true + }, + "node_modules/@lzc/domain": { + "resolved": "packages/domain", + "link": true + }, + "node_modules/@lzc/web": { + "resolved": "apps/web", + "link": true + }, + "node_modules/@lzc/worker": { + "resolved": "apps/worker", + "link": true + }, + "node_modules/@oxc-project/types": { + "version": "0.151.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.151.0.tgz", + "integrity": "sha512-J1yXrIlNDZVzE3ada310xeAw7nH8yCAyLPuUIsjKatFPmfn5bS1oW+cM+QsGOtVWd5nhSpbwZWx/rue+r5Z+PA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.11.tgz", + "integrity": "sha512-A5kXfGKvKWWZE0TtPrfsvT+q4Y5d1QG8gGUzpYjGydM+fARM9MuX90PrXYXe0XbsDVgyxxNzHo6giCj90bsFNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.11.tgz", + "integrity": "sha512-z6cTycz+iJ4PVkuL4HHW4DfTfoeU/2nqYYuSOrTmH7yHK5Y0LCOnA03V4ZNxavyVaU1oOqUgIg2klN/s+USGOA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.11.tgz", + "integrity": "sha512-jShvqNtP6vDC6/A5JOAzbVV+DkgHqhl/ScVCJEbt+TUY6QYz7YnXcrg3sLtFBniro0f/Ld50ZwCWA6f7KYD1nQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.11.tgz", + "integrity": "sha512-f2i2xiNWq1Z1l2++q2fuhZRdLAT3aqxD6vRNm1RAxpUoBcdqNB3C0s1Bt+K+PbEx2F5F4gQp6hqKkphCY/xF9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.11.tgz", + "integrity": "sha512-4Ir5FSOKIAMr4r0kExpt1s3bMgzJU3rA45AYOHtQpls0oNeqcYBKrWMlckrYH4KCfGLfkfn1tN1dmZPMVsdXow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.11.tgz", + "integrity": "sha512-/gnRDM+39BROzAN/k1OZjDPnDMcZxB/0EUxKjONO5yVkNEvlsoMDrxGNKgZi/ttFriS2gwlDNzB65pvNbFOXIQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.11.tgz", + "integrity": "sha512-PFaK8HwvAHbaKbBcDNQihjMKYvFnA5hiENx/l5tphTDz1E0WFp32l0A7aq7lyUwGsRw/xSrNIy/gIK4thrSCrw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.11.tgz", + "integrity": "sha512-AskzJUIKRLPxkruR1wLKewGbOw+EYfU/9lOrBFj4AFrEA8hPpKFnODWNu2WLaNs0QNkEb9QIJufmVZZIL/bJlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.11.tgz", + "integrity": "sha512-qlUGAheh2yh8afH7QBgx0PrRHN85hKnNd78x8MeMhXivuevgd8vgf6/CstOzmNKY/lLTHvNTrPy98cLnAugzJw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.11.tgz", + "integrity": "sha512-secpEad+0vCbSfn8upFySkDskv+bGPk3THSDS9Y89yc4rb4kzqHp8Dmyd9BkQW4SnhNXBZCl/6CrO//hZahNJQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.11.tgz", + "integrity": "sha512-mOVBT3dPpkWm8XBWPmU4bf+U6dYDLeMo/9ojUmis4N0L5uu10qra5vOyngZ7/PSdoE4G9KvRt4bloRxNjLas7A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.11.tgz", + "integrity": "sha512-Is78i9A8Ui4SqcxUwFJ9uMmjDn58IbVTjFWYdQestFEgeuEmHMLGNriXnVJKkwG2YiZjw8cP0zCTyDMdDGtOOg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.11.tgz", + "integrity": "sha512-dUCXneZ87INUMyQ0D+C0HrEBNUPNXHaPmU5GTjyKTJEiussw9Kaj5Ln8UztPe4epV/ffvgNBEadksdYhmW6xJA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.11.tgz", + "integrity": "sha512-jByxb6qfd+bH1xUd0qnfFnb17i9sWBPY2tOavJ0l3tdr3OTu+Kvtm8cd/JV5nFt657b1VqGltxg9olOEfofXWw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.11.tgz", + "integrity": "sha512-/PzKqzAJ03i19oy2ItPvyvaVjOjBCNnfaJs8yvUdGBKmiESgnrJSQ2awd81QzFbbnAmu7YO9ZnJrDCb9VSJPRA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.19.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.0.tgz", + "integrity": "sha512-zY+5tKxXdhGh1PYI0ac+7juvEu4OI6vWtVVoj5i2m42jxAY1U+zHGt6QCyOFwykdP62sM3MJ9stoYYUw5aCWew==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@types/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.3.0" + } + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz", + "integrity": "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "oxc-transform-react": "^0.145.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "oxc-transform-react": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.2.tgz", + "integrity": "sha512-Z5FS00Q1SJHkB35xATsmWGdQ5WA1/0MV3CDjqyv7GavHv1OfOj145MNfHOlHk7QLes21dKFDHr8EO2zvL+9WGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.2", + "estree-walker": "^3.0.3", + "magic-string": "^1.2.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/spy": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.2.tgz", + "integrity": "sha512-Ijc7T1nT9efNb5LxvjaBrEqw3f/QwUv5EE0nKqZxgqsaV/FxAAZ8baGylA8X/Z2oS4Lp+K74Jr6dTJsDKxJDeg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/abstract-logging": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", + "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", + "license": "MIT" + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv/node_modules/fast-uri": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/avvio": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/avvio/-/avvio-9.3.0.tgz", + "integrity": "sha512-g2tQ7LE7oOSqDfwEm3M+ZCMTJc7KiZCdJ4UwyZJb5ckTKyYu50OYmvv0mCFXPuYXoM4zkSt8zM9XQ9KCvxA74A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/error": "^4.0.0", + "fastq": "^1.17.1" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-decode-uri-component": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", + "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-json-stringify": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-7.0.1.tgz", + "integrity": "sha512-eRSayARSbbwlBjpP4vnTTIRD5QPcIrmihPxDeN1DtKnHPg66UuJLx+8hlK1kaFdjvzyQ/dzALoi4vwAQ+T+iZA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/merge-json-schemas": "^0.2.0", + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^4.0.0", + "json-schema-ref-resolver": "^3.0.0", + "rfdc": "^1.2.0" + } + }, + "node_modules/fast-querystring": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", + "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==", + "license": "MIT", + "dependencies": { + "fast-decode-uri-component": "^1.0.1" + } + }, + "node_modules/fast-uri": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.2.1.tgz", + "integrity": "sha512-TmHQgewjHtMq1E5QKA0tOE0yeYGQs25KZC/ziJpubRtWI15W92e6vPFydWOeZBVROSHBYw/QhD9d5OefdD6LDg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastify": { + "version": "5.12.5", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.12.5.tgz", + "integrity": "sha512-OB2k1dlxs5/NAABqeKV2FUHkSD2BbENsCak8yULVcymn3fHIPDVa9TI3SDnJSWYSllZmSYuZXy2gTnsT+Sut1A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/ajv-compiler": "^4.0.5", + "@fastify/error": "^4.0.0", + "@fastify/fast-json-stringify-compiler": "^5.0.0", + "@fastify/proxy-addr": "^5.0.0", + "abstract-logging": "^2.0.1", + "avvio": "^9.0.0", + "fast-json-stringify": "^7.0.0", + "find-my-way": "^9.6.0", + "light-my-request": "^6.0.0", + "pino": "^9.14.0 || ^10.1.0", + "process-warning": "^5.1.0", + "rfdc": "^1.3.1", + "secure-json-parse": "^4.0.0", + "semver": "^7.6.0", + "toad-cache": "^3.7.0" + } + }, + "node_modules/fastq": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.3.tgz", + "integrity": "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/find-my-way": { + "version": "9.9.0", + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.9.0.tgz", + "integrity": "sha512-sJsgZ1sQH2UDuowPuMKg8az7Qc8F0jnj+SKkFWU/+T0xcFlgV5skgXOGUqmQzOdmW6ALA7AhJINWx3qFBkbLHA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-querystring": "^1.0.0", + "safe-regex2": "^5.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/ipaddr.js": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.5.0.tgz", + "integrity": "sha512-aq+t5NAc+cS6rZQQVWC2x98CPqGtKKTMDd4Gaodv0wShnItdKg/51djkGJ1hqH+Oy0ivDftCbSLCQob8zso01w==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/json-schema-ref-resolver": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz", + "integrity": "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/light-my-request": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz", + "integrity": "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause", + "dependencies": { + "cookie": "^1.0.1", + "process-warning": "^4.0.0", + "set-cookie-parser": "^2.6.0" + } + }, + "node_modules/light-my-request/node_modules/process-warning": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.1.tgz", + "integrity": "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.2.tgz", + "integrity": "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pino": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", + "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/process-warning": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.1.0.tgz", + "integrity": "sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, + "node_modules/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", + "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.28.0" + }, + "peerDependencies": { + "react": "^19.3.0" + } + }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ret": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", + "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, + "node_modules/rolldown": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.11.tgz", + "integrity": "sha512-qpSwIyz0jHQq5qXBTNxFmE6664rJ7O+4TvPFOiOaBSrz8IOHc1koKKSqTM2H6u1UG1+TveuC6vaDHKXFOvb1Kw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.151.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.11", + "@rolldown/binding-android-arm64": "1.2.11", + "@rolldown/binding-darwin-arm64": "1.2.11", + "@rolldown/binding-darwin-x64": "1.2.11", + "@rolldown/binding-freebsd-x64": "1.2.11", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.11", + "@rolldown/binding-linux-arm64-gnu": "1.2.11", + "@rolldown/binding-linux-arm64-musl": "1.2.11", + "@rolldown/binding-linux-ppc64-gnu": "1.2.11", + "@rolldown/binding-linux-s390x-gnu": "1.2.11", + "@rolldown/binding-linux-x64-gnu": "1.2.11", + "@rolldown/binding-linux-x64-musl": "1.2.11", + "@rolldown/binding-openharmony-arm64": "1.2.11", + "@rolldown/binding-win32-arm64-msvc": "1.2.11", + "@rolldown/binding-win32-x64-msvc": "1.2.11" + } + }, + "node_modules/safe-regex2": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.1.tgz", + "integrity": "sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ret": "~0.5.0" + }, + "bin": { + "safe-regex2": "bin/safe-regex2.js" + } + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/scheduler": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "license": "MIT" + }, + "node_modules/secure-json-parse": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", + "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/std-env": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", + "dev": true, + "license": "MIT" + }, + "node_modules/thread-stream": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz", + "integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==", + "license": "MIT", + "dependencies": { + "real-require": "^1.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/thread-stream/node_modules/real-require": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz", + "integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==", + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.0.tgz", + "integrity": "sha512-78U2TlB2CnVenajOFzf3BKSm0J6oz5L0NV7g32LCPccvYc0lbWvys4d3uUUCS2B1N8PAf2+aekR8i1KbC3HO7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/tinyexec": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/toad-cache": { + "version": "3.7.4", + "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.4.tgz", + "integrity": "sha512-m1TdR/rvT7kgGJZhspNtXdsdYk0fddFpJJFlG5s+UkPFo6lkLoZ3YLOaovPYjq1R75NP5JfeTlSHaOsE09peCg==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/tsx": { + "version": "4.23.15", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.15.tgz", + "integrity": "sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.1.tgz", + "integrity": "sha512-/bvH9E9tmCXRGp2uXY3WbOldqpTwFkbha/8ANaEQ6VkxhH60KyqLwgZq6lG2y+4uT55x9+9eUHMpQ7uGnOCKjA==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.9", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.2.tgz", + "integrity": "sha512-7MQrx9pDv5aHiUcovIb/70Ys3tgtkUVgCtledvKdCmEO+/1Dicq5ZqoSxOW034m03oqC+oHOKui2dM6qtMLoJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.2", + "chai": "^6.2.2", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.2.3", + "obug": "^2.1.4", + "picomatch": "^4.0.7", + "std-env": "^4.2.0", + "tinybench": "^6.1.4", + "tinyexec": "^1.3.0", + "tinyglobby": "^0.2.17", + "why-is-node-running": "^3.2.1" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.2", + "@vitest/browser-preview": "5.0.2", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.2", + "@vitest/coverage-v8": "5.0.2", + "@vitest/ui": "5.0.2", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/why-is-node-running": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.2.tgz", + "integrity": "sha512-NKUzAelcoCXhXL4dJzKIwXeR8iEVqsA0Lq6Vnd0UXvgaKbzVo4ZTHROF2Jidrv+SgxOQ03fMinnNhzZATxOD3A==", + "dev": true, + "license": "MIT", + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=20.11" + } + }, + "node_modules/zod": { + "version": "4.6.5", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz", + "integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "packages/contracts": { + "name": "@lzc/contracts", + "version": "0.1.0", + "dependencies": { + "zod": "4.6.5" + } + }, + "packages/domain": { + "name": "@lzc/domain", + "version": "0.1.0", + "dependencies": { + "zod": "4.6.5" + } + } + } +} diff --git a/landing-zone-configurator/app/package.json b/landing-zone-configurator/app/package.json new file mode 100644 index 0000000..eef32b5 --- /dev/null +++ b/landing-zone-configurator/app/package.json @@ -0,0 +1,35 @@ +{ + "name": "landing-zone-configurator", + "version": "0.1.0", + "private": true, + "type": "module", + "packageManager": "npm@11.19.0", + "engines": { + "node": ">=24.21.0 <25", + "npm": ">=11.19.0 <12" + }, + "workspaces": [ + "apps/*", + "packages/*" + ], + "scripts": { + "dev": "npm run build:packages && npm run dev --workspace=@lzc/api", + "dev:web": "npm run dev --workspace=@lzc/web", + "build:packages": "tsc -b packages/contracts packages/domain", + "typecheck": "tsc -b && tsc --noEmit -p apps/web/tsconfig.json && tsc --noEmit -p tsconfig.tests.json", + "build": "npm run typecheck && npm run build --workspace=@lzc/web", + "lint": "biome check .", + "format": "biome check --write .", + "test": "npm run build:packages && vitest run", + "check": "npm run lint && npm run build && npm test", + "start": "npm run start --workspace=@lzc/api", + "platform:check": "node --use-system-ca scripts/platform-check.mjs" + }, + "devDependencies": { + "@biomejs/biome": "2.5.14", + "@types/node": "24.19.0", + "tsx": "4.23.15", + "typescript": "7.0.2", + "vitest": "5.0.2" + } +} diff --git a/landing-zone-configurator/app/packages/contracts/README.md b/landing-zone-configurator/app/packages/contracts/README.md new file mode 100644 index 0000000..d80d666 --- /dev/null +++ b/landing-zone-configurator/app/packages/contracts/README.md @@ -0,0 +1,3 @@ +# Schnittstellenverträge + +Versionierte API- und Job-Verträge mit Laufzeitvalidierung. Keine Browser-Exporte interner Credential-Strukturen. Mit dem Fachmodell abgestimmte, nicht doppelt gepflegte Schemas. diff --git a/landing-zone-configurator/app/packages/contracts/package.json b/landing-zone-configurator/app/packages/contracts/package.json new file mode 100644 index 0000000..afff4ca --- /dev/null +++ b/landing-zone-configurator/app/packages/contracts/package.json @@ -0,0 +1,18 @@ +{ + "name": "@lzc/contracts", + "version": "0.1.0", + "private": true, + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "dependencies": { + "zod": "4.6.5" + } +} diff --git a/landing-zone-configurator/app/packages/contracts/src/index.ts b/landing-zone-configurator/app/packages/contracts/src/index.ts new file mode 100644 index 0000000..8ae56e7 --- /dev/null +++ b/landing-zone-configurator/app/packages/contracts/src/index.ts @@ -0,0 +1,8 @@ +import { z } from "zod"; + +export const healthResponseSchema = z.object({ + status: z.literal("ok"), + service: z.literal("landing-zone-configurator"), + authentication: z.literal("not-configured"), +}); +export type HealthResponse = z.infer; diff --git a/landing-zone-configurator/app/packages/contracts/tsconfig.json b/landing-zone-configurator/app/packages/contracts/tsconfig.json new file mode 100644 index 0000000..df59da5 --- /dev/null +++ b/landing-zone-configurator/app/packages/contracts/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src/**/*.ts"] +} diff --git a/landing-zone-configurator/app/packages/domain/README.md b/landing-zone-configurator/app/packages/domain/README.md new file mode 100644 index 0000000..3e7d377 --- /dev/null +++ b/landing-zone-configurator/app/packages/domain/README.md @@ -0,0 +1,3 @@ +# Fachmodell + +Versioniertes Schema, Fachregeln, Konfigurationscompiler und Graph-Modell. Gemeinsame Logik für UI, API und Chat-Vorschläge. Laufzeitvalidierung ist auch mit TypeScript erforderlich. Keine Secret-Auflösung in diesem Paket. diff --git a/landing-zone-configurator/app/packages/domain/package.json b/landing-zone-configurator/app/packages/domain/package.json new file mode 100644 index 0000000..87c82c3 --- /dev/null +++ b/landing-zone-configurator/app/packages/domain/package.json @@ -0,0 +1,18 @@ +{ + "name": "@lzc/domain", + "version": "0.1.0", + "private": true, + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "dependencies": { + "zod": "4.6.5" + } +} diff --git a/landing-zone-configurator/app/packages/domain/src/index.ts b/landing-zone-configurator/app/packages/domain/src/index.ts new file mode 100644 index 0000000..bb888cf --- /dev/null +++ b/landing-zone-configurator/app/packages/domain/src/index.ts @@ -0,0 +1,33 @@ +import { z } from "zod"; + +const tenantIdSchema = z.uuid(); +export const membershipSchema = z.object({ + userId: z.uuid(), + tenantId: tenantIdSchema, + role: z.enum(["viewer", "editor", "deployer", "admin"]), +}); +export type Membership = z.infer; +export type Permission = "read" | "edit" | "deploy" | "admin"; + +const permissions: Record = { + viewer: ["read"], + editor: ["read", "edit"], + deployer: ["read", "deploy"], + admin: ["read", "edit", "deploy", "admin"], +}; + +// Membership must come from authenticated server-side storage, never a request header/body. +// This policy does not replace repository rights or credential-use authorization. +export function canAccessTenant( + trustedMembership: unknown, + requestedTenantId: unknown, + permission: Permission, +): boolean { + const membership = membershipSchema.safeParse(trustedMembership); + const tenant = tenantIdSchema.safeParse(requestedTenantId); + if (!membership.success || !tenant.success) return false; + return ( + membership.data.tenantId === tenant.data && + permissions[membership.data.role].includes(permission) + ); +} diff --git a/landing-zone-configurator/app/packages/domain/tsconfig.json b/landing-zone-configurator/app/packages/domain/tsconfig.json new file mode 100644 index 0000000..df59da5 --- /dev/null +++ b/landing-zone-configurator/app/packages/domain/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src/**/*.ts"] +} diff --git a/landing-zone-configurator/app/scripts/platform-check.mjs b/landing-zone-configurator/app/scripts/platform-check.mjs new file mode 100644 index 0000000..6e9c484 --- /dev/null +++ b/landing-zone-configurator/app/scripts/platform-check.mjs @@ -0,0 +1,170 @@ +import { execFile } from "node:child_process"; +import { readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import { parseEnv, promisify } from "node:util"; + +const exec = promisify(execFile); +const repoRoot = new URL("../../../", import.meta.url); +const projectFile = new URL("landing-zone-configurator.env", repoRoot); +const credentialFile = new URL( + "landing-zone-configurator-credentials.json", + repoRoot, +); + +// Only aggregate metadata is printed. Tokens and raw service responses stay in memory. +// The only non-GET request is the CLI's short-lived authentication token exchange. +async function main() { + const config = parseEnv(await readFile(projectFile, "utf8")); + const project = config.PROJECT_ID; + if ( + !project || + !/^[a-f0-9]{8}(-[a-f0-9]{4}){3}-[a-f0-9]{12}$/i.test(project) + ) { + throw new Error("Invalid project configuration"); + } + const region = config.REGION ?? "eu01"; + if (!["eu01", "eu02"].includes(region)) throw new Error("Unsupported region"); + const env = Object.fromEntries( + Object.entries(process.env).filter(([key]) => !key.startsWith("STACKIT_")), + ); + const auth = await exec( + "stackit", + [ + "auth", + "activate-service-account", + "--service-account-key-path", + fileURLToPath(credentialFile), + "--only-print-access-token", + "--verbosity", + "error", + ], + { env, timeout: 30000 }, + ); + const token = auth.stdout.trim(); + if (!token || /\s/.test(token)) + throw new Error("Invalid authentication response"); + env.STACKIT_ACCESS_TOKEN = token; + + const counts = (data) => { + if (Array.isArray(data)) return { items: data.length }; + if (!data || typeof data !== "object") return {}; + return Object.fromEntries( + Object.entries(data) + .filter(([, value]) => Array.isArray(value)) + .map(([key, value]) => [key, value.length]), + ); + }; + const get = async (name, url) => { + try { + const response = await fetch(url, { + headers: { Authorization: `Bearer ${token}` }, + redirect: "error", + signal: AbortSignal.timeout(20000), + }); + const result = { + check: name, + httpStatus: response.status, + ok: response.ok, + }; + // STACKIT's ProjectEnabled helper maps this specific endpoint's 404 to disabled. + if (name === "object-storage-status" && response.status === 404) { + return { + check: name, + httpStatus: 404, + ok: true, + enabled: false, + action: "provision-via-iac", + }; + } + if (response.ok) result.counts = counts(await response.json()); + return result; + } catch { + return { check: name, ok: false, error: "network-or-response-error" }; + } + }; + const cli = async (name, args) => { + try { + const response = await exec( + "stackit", + [ + ...args, + "--project-id", + project, + "--region", + region, + "--output-format", + "json", + "--verbosity", + "error", + ], + { env, timeout: 30000 }, + ); + const data = JSON.parse(response.stdout); + return { + check: name, + ok: true, + counts: counts(data), + ...(name === "project" + ? { projectMatches: data.projectId === project } + : {}), + }; + } catch (error) { + const raw = `${error.stdout ?? ""} ${error.stderr ?? ""}`; + return { + check: name, + ok: false, + error: /\b404\b/.test(raw) ? "not-found-404" : "cli-or-response-error", + }; + } + }; + const results = await Promise.all([ + cli("project", ["project", "describe"]), + get( + "cf-organizations", + `https://scf.api.stackit.cloud/v1/projects/${project}/regions/${region}/organizations`, + ), + get( + "cf-platforms", + `https://scf.api.stackit.cloud/v1/projects/${project}/regions/${region}/platforms`, + ), + get( + "postgres-instances", + `https://postgres-flex-service.api.stackit.cloud/v3/projects/${project}/regions/${region}/instances`, + ), + get( + "postgres-flavors", + `https://postgres-flex-service.api.stackit.cloud/v3/projects/${project}/regions/${region}/flavors`, + ), + get( + "secrets-instances", + `https://secrets-manager.api.${region === "eu01" ? "" : `${region}.`}stackit.cloud/v1/projects/${project}/instances`, + ), + get( + "model-catalog", + `https://model-serving.api.stackit.cloud/v1/regions/${region}/models`, + ), + get( + "object-storage-status", + `https://object-storage.api.stackit.cloud/v2/project/${project}/regions/${region}`, + ), + ]); + console.log( + JSON.stringify( + { + region, + regionSource: config.REGION ? "configured" : "default-probe-only", + results, + }, + null, + 2, + ), + ); + if (results.some((result) => !result.ok)) process.exitCode = 1; +} + +main().catch(() => { + console.error( + "Platform check failed; credential and raw error details withheld.", + ); + process.exitCode = 1; +}); diff --git a/landing-zone-configurator/app/tests/api.test.ts b/landing-zone-configurator/app/tests/api.test.ts new file mode 100644 index 0000000..16bf28a --- /dev/null +++ b/landing-zone-configurator/app/tests/api.test.ts @@ -0,0 +1,33 @@ +import { healthResponseSchema } from "@lzc/contracts"; +import { afterEach, describe, expect, it } from "vitest"; +import { buildApp } from "../apps/api/src/app.js"; + +const apps: ReturnType[] = []; +afterEach(async () => { + await Promise.all(apps.splice(0).map((app) => app.close())); +}); + +describe("API foundation", () => { + it("exposes only the public health contract", async () => { + const app = buildApp(); + apps.push(app); + const response = await app.inject({ method: "GET", url: "/healthz" }); + expect(response.statusCode).toBe(200); + expect(healthResponseSchema.safeParse(response.json()).success).toBe(true); + }); + it("cannot be authenticated using forged identity headers", async () => { + const app = buildApp(); + apps.push(app); + const response = await app.inject({ + method: "GET", + url: "/api/v1/session", + headers: { + authorization: "Bearer fabricated", + "x-user-id": "admin", + "x-tenant-id": "other-tenant", + }, + }); + expect(response.statusCode).toBe(401); + expect(response.json()).toEqual({ error: "authentication_required" }); + }); +}); diff --git a/landing-zone-configurator/app/tests/tenant-policy.test.ts b/landing-zone-configurator/app/tests/tenant-policy.test.ts new file mode 100644 index 0000000..2661c9c --- /dev/null +++ b/landing-zone-configurator/app/tests/tenant-policy.test.ts @@ -0,0 +1,37 @@ +import { canAccessTenant } from "@lzc/domain"; +import { describe, expect, it } from "vitest"; + +const tenantId = "00000000-0000-4000-8000-000000000001"; +const otherTenantId = "00000000-0000-4000-8000-000000000002"; +const userId = "00000000-0000-4000-8000-000000000003"; + +describe("tenant permission boundary", () => { + it("denies cross-tenant access even for administrators", () => { + expect( + canAccessTenant( + { userId, tenantId, role: "admin" }, + otherTenantId, + "read", + ), + ).toBe(false); + }); + it("does not let editors deploy or deployers edit", () => { + expect( + canAccessTenant({ userId, tenantId, role: "editor" }, tenantId, "deploy"), + ).toBe(false); + expect( + canAccessTenant({ userId, tenantId, role: "deployer" }, tenantId, "edit"), + ).toBe(false); + expect( + canAccessTenant({ userId, tenantId, role: "editor" }, tenantId, "edit"), + ).toBe(true); + }); + it("fails closed for missing or malformed membership and tenant IDs", () => { + for (const membership of [null, {}, { userId, tenantId, role: "owner" }]) { + expect(canAccessTenant(membership, tenantId, "read")).toBe(false); + } + expect( + canAccessTenant({ userId, tenantId, role: "admin" }, "", "read"), + ).toBe(false); + }); +}); diff --git a/landing-zone-configurator/app/tsconfig.base.json b/landing-zone-configurator/app/tsconfig.base.json new file mode 100644 index 0000000..30f1288 --- /dev/null +++ b/landing-zone-configurator/app/tsconfig.base.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2023", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "verbatimModuleSyntax": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "declaration": true, + "composite": true, + "sourceMap": true, + "types": ["node"] + } +} diff --git a/landing-zone-configurator/app/tsconfig.json b/landing-zone-configurator/app/tsconfig.json new file mode 100644 index 0000000..acf1757 --- /dev/null +++ b/landing-zone-configurator/app/tsconfig.json @@ -0,0 +1,17 @@ +{ + "files": [], + "references": [ + { + "path": "packages/contracts" + }, + { + "path": "packages/domain" + }, + { + "path": "apps/api" + }, + { + "path": "apps/worker" + } + ] +} diff --git a/landing-zone-configurator/app/tsconfig.tests.json b/landing-zone-configurator/app/tsconfig.tests.json new file mode 100644 index 0000000..2298327 --- /dev/null +++ b/landing-zone-configurator/app/tsconfig.tests.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "composite": false, + "declaration": false, + "noEmit": true + }, + "include": ["tests/**/*.ts", "apps/api/src/**/*.ts"] +} diff --git a/landing-zone-configurator/deploy/cloud-foundry/README.md b/landing-zone-configurator/deploy/cloud-foundry/README.md new file mode 100644 index 0000000..a83d0e9 --- /dev/null +++ b/landing-zone-configurator/deploy/cloud-foundry/README.md @@ -0,0 +1,5 @@ +# Cloud-Foundry-Releases + +Hier künftig Manifeste und Release-Einstellungen für Web/API/Worker beziehungsweise getrennte Runner-Apps verwalten. Infrastruktur-Lifecycle in `../../infra/`; App-Versionen und Startkommandos hier. Eine Ressource erhält einen eindeutigen Lifecycle-Owner, keine konkurrierende Verwaltung durch OpenTofu und `cf push`. Secrets nur geschützt zur Laufzeit injizieren. + +Das [CI/CD-Konzept](../../docs/cicd.md) legt den Release-Prozess als Owner für App-Objekte, App-Routen, App-Bindings und Code fest. OpenTofu verwaltet die zugrunde liegenden Dienste, Spaces und Rollen. Deployment-Workflows und Release-Manifeste sind noch umzusetzen. diff --git a/landing-zone-configurator/docs/cicd.md b/landing-zone-configurator/docs/cicd.md new file mode 100644 index 0000000..08f8249 --- /dev/null +++ b/landing-zone-configurator/docs/cicd.md @@ -0,0 +1,104 @@ +# CI/CD für den Landing Zone Configurator + +Status: Architektur und Umsetzungsliste; die vorhandene Validierungs-CI ist implementiert. Die hier beschriebenen Deployment-Workflows sind noch nicht implementiert oder ausgeführt. Erste Umgebung: **lzc-dev / eu01**. + +## Getrennte Lebenszyklen + +| Workflow (geplanter Dateiname) | Auslöser | Verantwortung | +| --- | --- | --- | +| `validate-configurator.yml` (vorhanden) | PR, main, manuell | App-Prüfungen, IaC-Validierung und Mock-Tests ohne Cloud-Zugang | +| `configurator-bootstrap.yml` | Ausschließlich manuell, Umgebung und Phase auswählen | State-Infrastruktur erstellen, Versionierung konfigurieren, später Credentials rotieren | +| `configurator-platform.yml` | Infrastrukturänderungen auf main: Plan; manuell | Plattform- und Runtime-IaC planen, geprüften Plan anwenden | +| `configurator-release.yml` | App-/Release-Dateien auf main; manuell für Redeploy/Rollback | Einmal bauen, prüfen, unveränderliches Release nach CF deployen | +| `configurator-drift.yml` | Optional zeitgesteuert/manuell | Infrastruktur-Drift erkennen und melden; zunächst kein automatisches Apply | + +Ein normales App-Release startet weder Bootstrap noch Plattform-Apply. Relevante Pfade umfassen auch gemeinsame Deployment-Skripte, Lockfiles und die jeweilige Workflow-Datei. Änderungen ausschließlich am Accelerator lösen keinen Configurator-Release aus. Kunden-Landing-Zone-Deployments bleiben ein eigener, mandantenisolierter Runner-Ablauf und erhalten keine Betreiber-CI-Credentials. + +## State vor Automatisierung lösen + +Der aktuelle Operator-Wrapper nutzt lokale Bootstrap-/Backend-States und lokal erzeugte Schlüssel. Flüchtige GitHub-Runner dürfen dieses Verhalten nicht unverändert übernehmen: Ein neuer Schlüssel oder verlorener State nach einem Lauf wäre kein belastbarer Betrieb. + +Ziel ist ein **unabhängiges, dauerhaftes Verwaltungs-Backend** für Bootstrap und Backend-Root, getrennt vom Bucket, den diese verwalten. Es muss vor deren erstem CI-Apply existieren, verschlüsselte States, Sperren und Wiederherstellung unterstützen. Bevorzugt wird ein vorhandenes Organisations-Backend. Falls keines existiert, wird dessen Einrichtung als eigene IaC-Seed-Stufe mit gesicherter initialer State-Übergabe geplant. Entscheidung: separater Verwaltungs-Bucket im bestehenden STACKIT-Projekt, erstellt durch die lokalen IaC-Roots `seed` und `seed-protection`. Die dauerhafte Schlüsselübergabe an CI bleibt offen; der Configurator darf sein einziges Recovery-Backend nicht selbst voraussetzen. + +Platform und Runtime erhalten getrennte State-Keys im Configurator-State-Bucket. Jeder Root und jede Umgebung bekommt einen dauerhaft verwahrten Verschlüsselungsschlüssel. CI lädt Schlüssel aus geschützten Environment-Secrets oder dem unabhängigen Secret-System; ein fehlender Schlüssel führt zum Abbruch. GitHub-Artefakte und Caches dienen nicht als dauerhaftes State-Backend. Bestehende States werden migriert, nicht durch erneutes Anlegen ersetzt. + +Der seltene Bootstrap-Workflow benötigt trotzdem einen regelmäßigen Betriebsprozess für die befristeten S3-Credentials: Ablaufüberwachung, Rotation vor Ablauf, Aktualisierung aller Consumer und Test mit dem neuen Zugang vor Widerruf des alten. + +## Infrastruktur-Pipeline + +1. Vertrauenswürdigen Commit von main auschecken; gepinnte Tools und Provider-Lockfiles verwenden. +2. Root und Umgebung aus fester Allowlist wählen; Remote-State und bestehende Schlüssel laden. +3. Plan erstellen und verschlüsselt speichern. Manifest bindet Commit, Umgebung, Root, Eingabedigest, Lockfile-Digest, Plan-Hash und kurze Gültigkeit. Nur bereinigte Ressourcenaktionen als Review-Zusammenfassung veröffentlichen, keine vollständigen State-/Plan-JSONs. +4. Apply-Job nutzt ein geschütztes GitHub Environment und exakt das geprüfte Plan-Artefakt desselben vertrauenswürdigen Runs. Freigabe gilt für diesen Plan. Abgelaufene oder veraltete Plans werden neu erstellt und erneut geprüft. +5. Backend-Locking verhindert parallele State-Schreiber. Übergreifende GitHub-Concurrency pro Umgebung serialisiert mutierende Plattform-/Runtime-/Release-Jobs; `cancel-in-progress: false` für laufende Mutationen. Keine Fairness oder FIFO-Reihenfolge voraussetzen, ältere Releases vor Deployment abweisen. +6. Outputs nur gezielt und geschützt an Consumer übergeben, Recovery und Audit protokollieren. + +Bootstrap und Backend-Versionierung laufen nacheinander. Nach Bootstrap-Apply wird der Backend-Plan mit den jetzt vorhandenen Zugangsdaten erstellt. Plattform folgt erst nach nachgewiesener Versionierung und erfolgreichem Locking-/Restore-Test. Runtime benötigt anschließend die CF-Zugänge der Plattform. Kein Plan über noch unbekannte Provider-Credentials und kein routinemäßiges `-target`. + +## Anwendung häufig und unabhängig ausliefern + +Der Release-Workflow prüft und baut Web/API/Worker einmal und erstellt ein über Commit und SHA-256 identifizierbares Release. Redeploy und spätere Promotion verwenden dasselbe geprüfte Paket; historische Artefakte sind entsprechend der Rollback-Frist dauerhaft aufzubewahren. Kein erneuter Build beim Rollback. + +Der Lifecycle-Owner wird eindeutig festgelegt: OpenTofu verwaltet Dienste, CF-Org/Spaces, Rollen und gemeinsame Service-Instanzen. Der deklarative Release-Prozess verwaltet App-Objekte, App-Routen, App-Bindings, Prozesskonfiguration und Code. Diese Ressourcen werden nicht zusätzlich vom Runtime-Root verwaltet. So bleiben auch App-Provisionierung und Bindings als Code reproduzierbar. + +Ablauf: Release prüfen → kompatible DB-Migration mit separater Migrationsidentität ausführen → CF-Deployment → Readiness-/Smoke-Tests → Release markieren. Rolling Deployment ist die bevorzugte Strategie, sofern Foundation, Quota und App-Prozesse es unterstützen; dies ist auf der konkreten STACKIT-Foundation noch zu prüfen. Worker benötigen Drain/Lease-Semantik, damit beim Austausch keine Jobs doppelt laufen. Destruktive DB-Migrationen folgen zeitversetzt; ein App-Rollback setzt keine Datenbank automatisch zurück. + +Entwicklung: nach erfolgreicher Validierung auf main automatisch deployen, sobald die Umgebung betriebsbereit ist. Produktion später: explizite Promotion des geprüften Releases mit Freigabe. Ein manueller Redeploy wählt eine bekannte Release-ID, keinen beliebigen Fork oder Shell-Befehl. + +## Identitäten und Vertrauensgrenzen + +- Bootstrap: gesonderte Betreiberidentität mit den notwendigen Projekt-/Object-Storage-Rechten. +- Plattform: scoped STACKIT-Zugang; App-Release erhält diesen Zugang nicht. +- Release: CF-Deployment-Identität auf den Ziel-Space beschränkt; DB-Migrationszugang nur im Migrationsschritt. +- Persönliche GitHub-/STACKIT-Zugänge der Kunden bleiben in der Anwendung und gelangen nicht in Betreiber-Workflows. +- GitHub Environments trennen mindestens Bootstrap, Plattform und App je Umgebung. Environment-Regeln und Branch-Schutz werden als Repository-Konfiguration per IaC/API verwaltet; initiale GitHub-Administrationsberechtigung und Secret-Einspeisung bleiben eine notwendige Vertrauensbasis. +- Secrets niemals aus PR-Code zugänglich machen; kein privilegiertes `pull_request_target` mit Checkout von Fork-Code. Actions auf Commit-SHAs pinnen, minimale Token-Rechte, keine Shell-Ausgabe von Credentials. +- Kurzlebige föderierte Identitäten bevorzugen, sofern STACKIT sie für diese Dienste nachweislich unterstützt. Bis dahin rotierbare, geschützte Service-Account-Zugänge; GitHub-OIDC-Kompatibilität nicht voraussetzen. + +Die Verfügbarkeit von Environment-Freigaberegeln hängt vom GitHub-Tarif und der Repository-Sichtbarkeit ab. Vor Aktivierung prüfen. Runner-Netzzugang zu CF, S3 und privaten Datenbankendpunkten ebenfalls prüfen; erforderliche private Runner werden getrennt von untrusted PR-Jobs betrieben und über IaC provisioniert. + +## Umsetzungsreihenfolge und Abnahme + +- [x] Workflows nach Lebenszyklus und Credentials getrennt planen. +- [x] Validierungs-CI für App und die drei vorhandenen IaC-Roots angelegt. +- [ ] Unabhängiges Verwaltungs-Backend auswählen/einrichten; Schlüsselhaltung und Recovery testen. +- [x] CI-Schutz gegen lokale Cloud-States und automatische Erzeugung von Deployment-Schlüsseln; vier Tests in der Validierungs-CI. +- [ ] CI-Modus des Wrappers: explizite Umgebung, Remote-State, bestehende Schlüssel, Plan-Bindung an Commit/Eingaben. +- [ ] GitHub-Environments, Schutzregeln und getrennte Deployment-Identitäten als Code definieren. +- [ ] Bootstrap-/Backend-Pipeline implementieren; Wiederholung ohne Änderungen und Credential-Rotation nachweisen. +- [ ] S3-Versionierung, konkurrierendes Locking und Wiederherstellung praktisch testen. +- [ ] Plattform-/Runtime-Pipeline implementieren und konkrete Foundation, Quota, DB-Größe und Netzfreigaben festlegen. +- [ ] CF-Release-Manifeste, Paketierung, Migrationen, Readiness und Worker-Drain implementieren. +- [ ] App-Release-Pipeline: zwei Releases, unabhängiger Redeploy und Rollback nachweisen, ohne Infrastrukturänderung. +- [ ] Optionalen Drift-Workflow und Alarmierung aktivieren. + +Nächster Umsetzungsschritt ist das dauerhafte CI-State-/Secret-Fundament. Anschließend kann der erste Bootstrap-Apply direkt aus CI erfolgen; ein lokales Apply ist dafür keine Voraussetzung. + +## Quellen + +- [GitHub Deployment Environments](https://docs.github.com/en/actions/concepts/workflows-and-actions/deployment-environments) +- [GitHub Deployment-Steuerung und Concurrency](https://docs.github.com/en/actions/how-tos/deploy/configure-and-manage-deployments/control-deployments) +- [GitHub Environment-Verfügbarkeit und Einrichtung](https://docs.github.com/en/actions/how-tos/deploy/configure-and-manage-deployments/manage-environments) +- [Cloud Foundry Rolling Deployments](https://docs.cloudfoundry.org/devguide/deploy-apps/rolling-deploy.html) + +### Aktueller technischer Fortschritt + +Der Wrapper erkennt GitHub Actions/CI und erlaubt dort derzeit ausschließlich lokale Validierungsbefehle einschließlich `init -backend=false`. Cloud-Operationen brechen ab, solange die Remote-State-Anbindung fehlt. Die extrahierte Schlüsselverwaltung unterstützt bestehende, im Speicher übergebene Deployment-Schlüssel ohne Dateipersistierung; der Wrapper entfernt die Schlüsselvariablen anderer Roots aus der Kindprozess-Umgebung. Diese Vorarbeit aktiviert noch keine Deployment-Pipeline. + +### Verwaltungs-Backend bereitgestellt + +Der separate STACKIT-Verwaltungs-Bucket wurde per IaC erstellt und versioniert. Bootstrap nutzt nun dessen S3-Backend; der Backend-Root ist ebenfalls darauf umgestellt. Seed-States bleiben verschlüsselt lokal und erhalten verschlüsselte Recovery-Kopien im Bucket. Vor Aktivierung der Cloud-Pipelines fehlen noch unabhängige Schlüsselverwahrung, CI-Zugangsinjektion ohne lokale Seed-Outputs und vollständige Lock-/Recovery-Abnahme. Keine GitHub-Secrets oder Deployment-Workflows wurden aktiviert. + +### CI-Implementierung und fehlgeschlagene Locking-Abnahme + +Plan-/Apply-Workflow für Bootstrap und Backend sowie deklarative GitHub-Environment-Einrichtung sind implementiert, lokal geprüft und noch nicht veröffentlicht. Details: [CI-Betrieb](../infra/ci/README.md). Secret-Upload wartet auf ausdrückliche Freigabe nach automatischer Sicherheitsprüfung. + +Der echte Parallelitätstest zeigt: Eine vorhandene `.tflock` blockiert einen zweiten OpenTofu-Plan nicht. Ein zusätzlicher S3-Test bestätigt, dass ein zweites PUT mit `If-None-Match: *` auf dasselbe existierende Objekt akzeptiert wird. Remote-Applies bleiben daher technisch gesperrt. GitHub-Concurrency allein schützt nur kooperierende Workflows desselben Repositories; lokale oder andere Clients sind davon nicht erfasst. Als Alternativen stehen ein PostgreSQL-Backend mit nativem Locking oder ein ausdrücklich begrenzter CI-only-Betrieb zur Entscheidung. Der Bucket kann weiter für verschlüsselte Backups verwendet werden. + +Quellen: [STACKIT Issue 1534](https://github.com/stackitcloud/terraform-provider-stackit/issues/1534), [OpenTofu S3-Lock-Implementierung](https://github.com/opentofu/opentofu/blob/v1.12.6/internal/backend/remote-state/s3/client.go). + +### Bestätigte Übergangslösung: ein schreibender CI-Lauf + +Der Benutzer hat den CI-only-Betrieb trotz fehlender nativer S3-Sperren bestätigt. Alle mutierenden Configurator-Workflows verwenden repositoryweit `configurator-lzc-dev-mutation` mit `cancel-in-progress: false`; lokale Remote-Applies bleiben gesperrt. Diese Entscheidung ersetzt die vorherige vollständige Apply-Sperre und die offene PostgreSQL-Alternative. Sie gilt für kooperierende CI-Läufe, nicht für direkte Zugriffe anderer Credential-Inhaber. Die ausdrückliche Freigabe der benannten Secrets und GitHub-Ziel-Environments wurde erteilt. + +GitHub-Einrichtung abgeschlossen: `lzc-dev-bootstrap-plan`, `lzc-dev-bootstrap-apply` und `lzc-dev-recovery` existieren mit main-Beschränkung. Apply/Recovery erfordern Freigabe; Admin-Bypass ist deaktiviert. Die freigegebenen Secrets sind hinterlegt und ihre Namen über die API verifiziert. Noch kein Workflow veröffentlicht oder gestartet. diff --git a/landing-zone-configurator/docs/decisions/0001-application-stack.md b/landing-zone-configurator/docs/decisions/0001-application-stack.md new file mode 100644 index 0000000..96a7e19 --- /dev/null +++ b/landing-zone-configurator/docs/decisions/0001-application-stack.md @@ -0,0 +1,48 @@ +# ADR 0001 – Sprache und Anwendungsstack + +Stand: 2026-09-29. **Status: angenommen im Planungsgespräch.** + +## Entscheidung + +TypeScript als Hauptsprache für React-Oberfläche, Node.js-Backend und Worker. Gemeinsames Fachmodell, Laufzeitschemas, API-Verträge, Konfigurationscompiler und Graph-Modell als interne Pakete. Backend: Fastify; Frontend: React mit Vite. npm-Workspaces mit Lockfile. Runtime: Node.js 24.21.0 LTS und npm 11.19.0; konkrete Paketversionen in `app/package.json` und den Workspace-Manifests. + +Go auf einen kleinen HCL-Importadapter begrenzen. OpenTofu wird als gepinnter externer Prozess in isolierten Runnern ausgeführt. Die App muss deshalb nicht in Go geschrieben sein. Für einen Runner-Supervisor erst dann zusätzlich Go wählen, wenn Packaging oder Prozesssteuerung einen konkreten Vorteil belegen. + +## Warum das zum Configurator passt + +Die Hauptarbeit besteht aus Formularen, Validierung, GitHub-/Cloud-APIs, Chat-Streaming und asynchroner Jobkoordination. TypeScript ermöglicht gemeinsame Verträge und Fachlogik zwischen Browser und Backend. Ein Sprachwechsel für jede Schemaänderung entfällt. Externe Eingaben dennoch immer zur Laufzeit validieren: TypeScript-Typen sind keine Autorisierungs- oder Validierungsgrenze. + +Die eigentliche Infrastrukturarbeit übernimmt OpenTofu. CPU-intensive Verarbeitung großer Plans und HCL-Import gehören in begrenzte Worker/Adapter-Prozesse, nicht in den HTTP-Event-Loop. Für die erwarteten API-/I/O-Aufgaben ist Backend-Rohleistung nicht das ausschlaggebende Kriterium. + +Cloud Foundry dokumentiert sowohl [Node.js](https://docs.cloudfoundry.org/buildpacks/node/) als auch [Go](https://docs.cloudfoundry.org/buildpacks/go/index.html). STACKIT beschreibt das Deployment mit Buildpacks und anderen Artefaktformen; die im Ziel angebotenen Versionen sind beim Spike zu prüfen. [STACKIT-App-Deployment](https://docs.stackit.cloud/products/runtime/cloud-foundry/getting-started/push-your-app-to-cloud-foundry/) + +Für HCL stellt das Maintainer-Projekt einen Go-Parser und Go-APIs bereit. Daraus leitet sich der gezielte Go-Adapter ab, nicht die Notwendigkeit eines vollständigen Go-Backends. [HashiCorp HCL](https://github.com/hashicorp/hcl) + +## Alternativen + +| Variante | Stärke für diese Aufgabe | Aufwand / Nachteil | +| --- | --- | --- | +| TypeScript für Web/API/Worker plus kleiner Go-Adapter | Gemeinsame Fachlogik, kurze Wege zwischen Editor und API | Node-Abhängigkeiten pflegen; kleine zusätzliche Go-Toolchain | +| React/TypeScript plus Go-Backend | Direkte HCL-Integration, kompakte Backend-Binaries, gute Prozesswerkzeuge | Zwei vollwertige Anwendungsstacks; Verträge generieren und Validierungsgrenzen abstimmen | +| React/TypeScript plus Python-Backend | Sinnvoll bei ausgeprägter Python-Erfahrung oder eigener ML-Verarbeitung | Kein klarer Mehrwert für reine Model-Serving-API-Nutzung; ebenfalls zwei Stacks | +| React/TypeScript plus Java/Kotlin-Backend | Sinnvoll bei vorhandenem JVM-Team und etablierten Betriebsstandards | Für dieses neue Projekt ohne solche Vorgaben mehr initiale Struktur | + +React plus Go wäre meine zweite Wahl, besonders bei einem überwiegend Go-erfahrenen Backend-Team. Python ist nicht erforderlich, nur weil ein Chatbot Bestandteil der App ist: Das Modell wird als Dienst angesprochen. + +## Bewusste Grenzen + +- Eigene CF-Deployables für API und Worker; keine langen OpenTofu-Läufe in HTTP-Requests. +- Gemeinsame Pakete enthalten keine Server-Secrets; Browser-Bundles werden darauf geprüft. +- Sicherheit und Mandantenisolation ergeben sich aus Architektur, Autorisierung und Tests, nicht aus der Sprache. +- Keine freie Ausführung von HCL oder Shell durch das Modell. +- Team-Erfahrung kann die Empfehlung ändern. Vor Umsetzung vorhandene TypeScript-/Go-Erfahrung und Wartungsverantwortung festhalten. + +## Abschlusskriterien + +- [x] Hauptsprache gemeinsam bestätigen. +- [x] Frontend-/Backend-Framework, Paketmanager und unterstützte Runtime-Versionen festlegen. +- [ ] Minimalen CF-Deploy von Web/API/Worker testen. +- [ ] HCL-Import mit allen acht Templates prototypisch validieren. +- [ ] Schema-Verträge, Build-Grenzen und Versionierung festlegen. + +Die lokale Basis ist implementiert und getestet. CF-Deploy und HCL-Adapter sind noch offen. Node.js 24 wurde als LTS-Linie gewählt; Aktualisierungen erfolgen bewusst über Runtime-Pin und Lockfile. [Node.js Release-Status](https://nodejs.org/en/about/previous-releases) diff --git a/landing-zone-configurator/docs/planning.md b/landing-zone-configurator/docs/planning.md new file mode 100644 index 0000000..bb6c294 --- /dev/null +++ b/landing-zone-configurator/docs/planning.md @@ -0,0 +1,596 @@ +# Landing Zone Configurator – Architektur und Planung + +> Stand: 2026-09-29 · Repo-Ausgangsstand: `2161871` +> Status: Architektur- und Umsetzungsplanung, noch keine Implementierung. +> `[x]` = dokumentiert oder entschieden; `[ ]` = offen. Technische Empfehlungen sind noch keine beschlossenen Produktentscheidungen. + +## 1. Zielbild und bestätigte Entscheidungen + +Eine zentral auf STACKIT Cloud Foundry gehostete Anwendung begleitet Benutzer vom Landing-Zone-Template bis zum überprüfbaren Deployment. Die Oberfläche arbeitet mit Organisationen, Netzwerkbereichen, Plattformdiensten und Workloads. Ein deterministischer Compiler übersetzt diese fachlichen Angaben in die Konfiguration des Accelerators. Terraform bleibt im Hintergrund; technische Details sind bei Bedarf einsehbar. + +**Bestätigt im Planungsgespräch:** + +- [x] Produktname: Landing Zone Configurator. +- [x] Sämtliche Configurator-Dienste inklusive Aktivierung werden via IaC erstellt; keine manuelle Portal-Provisionierung. +- [x] Erste Entwicklungsumgebung: lzc-dev in eu01. +- [x] App, IaC und Dokumentation unter `landing-zone-configurator/` getrennt vom Accelerator organisieren. +- [x] Betreiberprojekt und Service-Account-Datei sind lokal bereitgestellt; `NAME=WERT`-Format geprüft; Projekt-Lesezugriff geprüft; Schreibrechte noch zu prüfen. + +- [x] Von Beginn an mehrere unabhängige Kundenorganisationen unterstützen. Mandantentrennung ist Voraussetzung der ersten Freigabe, kein späterer Ausbau. +- [x] Zunächst ausschließlich Konfigurationen verändern und mit freigegebenem Accelerator-Code ausführen. Eigene Terraform-Modifikationen aus Forks folgen später. +- [x] GitHub-Zugriffe erfolgen im Namen und mit Berechtigungen des Benutzers, ohne zentrale Repository-Credentials. +- [x] App-Hosting auf STACKIT Cloud Foundry; Plattformumgebung über IaC aufbauen. +- [x] STACKIT Model Serving als Chat-Backend; dessen Betreiber-Credentials serverseitig über CF-Variablen bereitstellen. + +Die Betreiber benötigen technische Identitäten für CF, Datenbank, Secret-Verwaltung und Runner. Diese dürfen keine pauschale Berechtigung für Kunden-Repositories oder Kunden-Deployments haben. Die gewünschten zentralen Model-Serving-Credentials sind eine ausdrücklich abgegrenzte Betreiberfunktion. + +### Anforderungskatalog + +| ID | Fähigkeit | Einordnung | +| --- | --- | --- | +| F01 | GitHub-Login und benutzergebundene Repository-Zugriffe | Erste Version | +| F02 | Fork erstellen und mehrere vorhandene Repository-Verbindungen auswählen | Erste Version; GitHub-Grenzen berücksichtigen | +| F03 | Vorhandene `src/config/`-Beispiele als Templates anbieten und kopieren | Erste Version | +| F04 | Fachlicher Editor passend zu Modulen und Features | Schrittweise Umsetzung; Abdeckung sichtbar machen | +| F05 | Konfigurationen im ausgewählten eigenen Fork speichern | Erste Version | +| F06 | Persönliche Credentials und Deployment-Profile sicher verwalten | Vor erstem Deployment | +| F07 | Terraform/OpenTofu Plan und Apply visuell begleiten und ausführen | Kernumfang | +| F08 | Optionale Drift Detection und optionale Drift Correction | Nach stabilem Apply | +| F09 | Chat mit Rückfragen, vollständigem versionsbezogenem Feature-Wissen und Themenbegrenzung | Kernumfang | +| F10 | Grafische Konfigurationsdarstellung, später Plan-Delta und bekanntes Ist | Kernumfang | +| F11 | STACKIT-PaaS-Datenbank für Persistenz | Empfehlung: PostgreSQL Flex | +| F12 | Optionaler STACKIT-IdP-Login | Machbarkeit prüfen; keine MVP-Abhängigkeit | +| F13 | Model-Serving-Service als Voraussetzung und Plattformaufbau per IaC | Kernumfang | + +## 2. Ausgangslage dieses Repositories + +Grundlage sind [Root-Variablen](../../src/variables.tf), [Provider](../../src/providers.tf), [Engine-/Provider-Vorgaben](../../src/terraform.tf), [Backend-Beispiel](../../src/backend.tf), [Getting Started](../../docs/getting-started.md), [Architektur](../../docs/architecture.md), [mise.toml](../../mise.toml) und [Beitragsregeln](../../CONTRIBUTING.md). + +### Template-Katalog + +| Datei unter `src/config/` | Fachliche Darstellung | +| --- | --- | +| `standalone.tfvars` | Unabhängige Landing Zones ohne zentralen Netzwerk-Hub | +| `hub-and-spoke.tfvars` | Zentraler Netzwerk-Hub mit angebundenen Landing Zones | +| `hub-and-spoke-firewall.tfvars` | Hub-Spoke mit OPNsense-Firewall | +| `hub-and-spoke-multi-area.tfvars` | Getrennte Bereiche für regulierte und gemeinsame Workloads | +| `hub-and-spoke-finance-research.tfvars` | Getrennte Bereiche für Fachorganisationen | +| `hub-and-spoke-prod-nonprod-firewall.tfvars` | Getrennte Prod-/Nonprod-Bereiche mit Appliances | +| `hub-and-spoke-tenant-isolation.tfvars` | Netzwerkisolation innerhalb einer STACKIT-Organisation | +| `hub-and-spoke-multi-region.tfvars` | Regionale Hubs, Landing Zones und Plattformcluster in `eu01`/`eu02` | + +**Wichtige Grenzen und Konsequenzen:** + +- Templates enthalten Platzhalter und auskommentierte optionale Features. Ein HCL-Parser allein entdeckt daher nicht den gesamten Funktionsumfang. Ein expliziter Feature-Katalog ist nötig. +- `src/providers.tf` unterstützt statische regionale Aliasse für `eu01` und `eu02`. Die UI darf weitere Regionen nicht als automatisch unterstützt ausgeben. +- `connectivity_regions` ist als `map(any)` modelliert. Fachliche UI-Validierung muss über die Terraform-Typprüfung hinausgehen. +- Ein Projekt gehört gemäß Repo-Dokumentation zu genau einer SNA; mehrere SNAs oder Regionen sind nicht automatisch privat verbunden. +- `firewall_config` konfiguriert derzeit eine Appliance. Mehrere Appliances bedeuten keine automatische Policy-Verteilung. Dies bleibt eine sichtbare Einschränkung. +- Firewall-Bootstrap benötigt mehrere Schritte, ein Image und Shell-Werkzeuge. Die Anleitung beschreibt ein anfänglich öffentliches Admin-Interface mit gemeinsamem Initialpasswort. Automatisierte Freigabe erst nach sicherem Bootstrap und Passwortrotation. +- Private Firewall-/Kubernetes-Endpunkte benötigen passende Runner-Konnektivität. Zentraler CF-Betrieb allein stellt diese nicht her. +- Das Backend ist auskommentiert; die Anleitung migriert lokalen State erst nach der Erstbereitstellung in den erzeugten Management-Bucket. Kurzlebige Runner benötigen dagegen Remote State vor dem ersten Lauf. +- `mise.toml` pinnt OpenTofu `1.12.6`; `src/terraform.tf` verlangt mindestens `1.11`. README/Getting Started nennen teils abweichende Mindestversionen. Empfehlung: zunächst die gepinnte OpenTofu-Version qualifizieren; Terraform als weitere Engine separat testen. +- Das Repo verspricht keine pauschale Migrationskompatibilität. Upgrades brauchen explizite Migration, Versionsprüfung und neuen Plan. +- Die Netzwerkisolation des Templates „Tenant Isolation“ ersetzt nicht die Mandantentrennung der zentralen Anwendung. + +## 3. Zielarchitektur + +Empfehlung: modularer Backend-Dienst mit klaren internen Grenzen; Terraform-Ausführung als getrennte Laufzeit. Keine unnötige Aufteilung in viele Microservices zum Start. + +```mermaid +flowchart TB + U[Browser] --> W[Web UI und Backend-for-Frontend auf CF] + W --> GH[GitHub API mit Benutzer-Token] + W --> DB[PostgreSQL Flex: Metadaten und Jobqueue] + W --> SM[Secret Store: Benutzer- und Deployment-Secrets] + W --> AI[STACKIT Model Serving] + W --> OBJ[Object Storage: geschützte Run-Artefakte] + S[Scheduler und Dispatcher auf CF] --> DB + S --> R[Isolierter kurzlebiger Runner je Lauf] + R --> ST[Remote State je Deployment] + R --> TARGET[STACKIT-Zielumgebung des Kunden] + R --> OBJ + R --> B[Run-begrenzte Credential-Ausgabe] + B --> SM +``` + +| Baustein | Empfehlung | Verantwortung | +| --- | --- | --- | +| Frontend | React/TypeScript mit Vite; Graph-Bibliothek noch auswählen | Wizard, Fachformulare, Topologie, Chat und Run-Ansicht | +| Backend/BFF | TypeScript mit Fastify auf Node.js 24 LTS | Sessions, Autorisierung, GitHub, Konfiguration, Chat-Orchestrierung | +| HCL-Adapter | Kleiner Go-Adapter mit HCL-Parser | Bestehende `.tfvars` strukturiert lesen, keine Regex-Konvertierung | +| Konfigurationskern | Versioniertes JSON Schema, Fachregeln und Compiler | Gemeinsamer Vertrag für Editor, Import, Chat und Ausgabe | +| Datenbank | STACKIT PostgreSQL Flex | Mandanten, Entwürfe, Revisionen, Runs, Audit und Queue | +| Scheduler/Queue | Durable PostgreSQL-Queue und separater CF-Prozess | Leases, Heartbeats, Wiederaufnahme, faire Mandantenquoten | +| Secrets | STACKIT Secrets Manager, nach Prüfung der erforderlichen Policies | DB enthält Referenzen; keine Klartext-Credentials | +| Artefakte | STACKIT Object Storage | Geschützte Plans, bereinigte Logs und Laufmanifest | +| Runner | Unveränderliches Artefakt mit gepinnter Engine und Providern | Nur Credentials des konkreten Deployments | +| Chat | STACKIT Model Serving über Backend | Beratung und validierte Vorschläge; keine Ausführungsrechte | + +PostgreSQL Flex ist als verwalteter Dienst dokumentiert und unterstützt Verwaltung über Terraform und Cloud Foundry. Konkrete Region, Plan, HA, Backup, ACLs und CF-Anbindung sind vor Provisionierung festzulegen. [PostgreSQL Flex](https://docs.stackit.cloud/products/databases/postgresql-flex/) + +### CF-Hosting und Runner + +Web/API sowie Scheduler/Dispatcher laufen auf CF. Sessions und Jobs werden extern gespeichert. Mehrere Web-Instanzen, Health Checks, DB-Pooling und rückwärtskompatible DB-Migrationen vorsehen. SSE für Fortschritt/Chat, Polling als Rückfalloption; Proxy-Timeouts und Wiederverbindung testen. + +STACKIT dokumentiert für Prozesse/Tasks bis zu 32 GB RAM und 6 GB flüchtigen lokalen Speicher. Das lokale Dateisystem ist nicht persistent. Diese Grenzen sind besonders für Provider-Binaries, Firewall-Image und Plan-Artefakte zu prüfen. [Anwendungsanforderungen](https://docs.stackit.cloud/products/runtime/cloud-foundry/basics/requirements-for-applications/) + +CF Tasks laufen in eigenen Containern, erben aber Umgebungsvariablen, Bindings und Security Groups ihrer App. Deshalb **keine Terraform-Task der Backend-App** starten. Empfohlen ist eine eigene Runner-App ohne Backend-Bindings, bei Bedarf mit separaten Apps/Spaces pro Kunde. Ob dies die geforderte Isolation und private Erreichbarkeit bietet, ist durch Tests zu belegen. Logs von Tasks fließen in die App-Logs; Redaktion muss deshalb vor stdout/stderr erfolgen. [CF Tasks](https://docs.cloudfoundry.org/devguide/using-tasks.html) + +Falls CF-Ressourcenlimits, Netzwerk oder Isolation nicht genügen: isolierte Jobs auf SKE oder kurzlebige Compute-Runner. Für private Ziele gegebenenfalls Runner im Kundennetz mit ausgehender Verbindung zur Steuerung. Die Webanwendung bleibt auf CF. Diese Erweiterung benötigt eine explizite Architekturentscheidung. + +**Freigabekriterium:** Ein Runner erreicht weder andere Kunden-Secrets/States noch Model-Serving-Credentials oder die App-Datenbank. Eigener Ordner allein genügt nicht. Auch freigegebener Terraform-Code führt Provider und teilweise Skripte aus. + +## 4. Mandantenmodell und Berechtigungen + +Ein Tenant repräsentiert eine Kundenorganisation. Darunter liegen Workspaces, Repository-Verbindungen, Konfigurationen und Deployments. Benutzer können Mitglied mehrerer Tenants sein; der aktuelle Kontext muss im UI jederzeit sichtbar sein. GitHub-Organisation, App-Tenant und STACKIT-Organisation sind unterschiedliche Objekte mit expliziter Zuordnung. + +Empfehlung für den Start: gemeinsame PostgreSQL-Instanz mit `tenant_id` auf allen Kundenobjekten und Row-Level Security als zusätzliche Schutzschicht. Rollen und DB-Zugänge so gestalten, dass der Anwendungspfad RLS nicht umgeht. Separate Datenbanken/Instanzen für erhöhte Isolationsanforderungen als spätere Betriebsoption. Secrets, State, Artefakte, Queue, Caches und Chat-Verlauf brauchen dieselbe Trennung; ein DB-Filter alleine reicht nicht. + +- Tenant-Mitgliedschaft nur durch bestätigte Einladung/Administration oder verifizierte Organisationszuordnung; keine automatische Zuordnung anhand einer E-Mail-Domain. +- Rollen: Viewer, Editor, Deployer, Tenant-Admin. Credential-Nutzung wird zusätzlich autorisiert. +- GitHub-Rechte bei Repository-Aktionen weiterhin prüfen; eine App-Rolle erweitert keine GitHub-Berechtigung. +- Persönliche Credentials sind standardmäßig privat. Freigabe an Workspace oder Hintergrundjob braucht eine explizite, widerrufbare Delegation. +- Jeder Request und jeder Job wird serverseitig auf Tenant, Workspace und Objekt autorisiert. IDs im Request sind kein Berechtigungsnachweis. +- Rate Limits, aktive Runner, Queue-Anteil, Artefaktspeicher und KI-Budget pro Tenant begrenzen; kein Kunde darf alle Kapazitäten belegen. +- Betreiberzugriffe als nachvollziehbaren Notfallprozess gestalten. Keine normale Support-Oberfläche zur Anzeige von Kundensecrets. +- Bereits vor erster externer Freigabe Negativtests für horizontale Zugriffe, fremde Job-IDs, Artefakt-URLs, Cache-Schlüssel, Secret-Referenzen und Chat-Sessions. + +## 5. Benutzerablauf + +1. **Anmelden und Tenant wählen:** GitHub verbinden, eigenen Kundenkontext erstellen oder Einladung annehmen. +2. **Repository auswählen:** vorhandenen zugänglichen Fork auswählen oder einen neuen erstellen; Schreibrechte prüfen. +3. **Template kopieren:** Zielbild, Features, Grenzen und Voraussetzungen sehen; eigene Konfigurations-ID und Name vergeben. +4. **Gestalten:** Wizard oder Chat; beide arbeiten auf demselben fachlichen Modell. Topologie reagiert auf validierte Änderungen. +5. **Überprüfen und speichern:** Diff zeigen; Repository, Branch und Pfad sichtbar machen; Git-Commit oder optional PR erstellen. +6. **Deployment vorbereiten:** Zielorganisation, Region, Credential-Profil, Remote State und Runner-Netz prüfen. +7. **Plan:** feste Revision verwenden; Create/Update/Delete/Replace fachlich erklären. +8. **Apply:** konkreten Plan ausdrücklich freigeben; Fortschritt und Fehler verfolgen. +9. **Betrieb:** zuletzt erfolgreich ausgerollte Revision, letzten bekannten Ist-Stand und optionale Drift-Prüfungen sehen. + +Navigation: Dashboard, Templates, Konfigurationen, grafischer Editor mit Chat, Repository-Verbindungen, Deployment-Profile, persönliche Credentials, Runs/Drift und Tenant-Verwaltung. Begriffe wie „Netzwerkbereich“ ersetzen Terraform-Variablennamen im normalen Ablauf. Technische Details bleiben für Diagnose und Export verfügbar. + +## 6. GitHub und Login + +### Benutzergebundener Zugriff + +Empfehlung: GitHub App mit User Authorization und User Access Tokens. Jeder Benutzer autorisiert die App selbst. Repository-Aktionen nutzen ausschließlich seinen Token, keine Installation Tokens und keinen zentralen PAT. Die Rechte sind durch Benutzerrechte, App-Berechtigungen und Installation begrenzt. [Benutzerbezogene GitHub-App-Authentisierung](https://docs.github.com/en/enterprise-cloud%40latest/apps/creating-github-apps/authenticating-with-a-github-app/authenticating-with-a-github-app-on-behalf-of-a-user) + +OAuth-State, sichere Callback-Allowlist, unterstütztes PKCE, CSRF-Schutz und sichere HttpOnly-/Secure-/SameSite-Sessions vorsehen. Tokens nur serverseitig im Secret Store halten. Ablauf, Refresh, Widerruf, SSO und Organisationsrichtlinien testen. Repo-Zugriffe nach Rechteentzug blockieren. Repositories über stabile GitHub-IDs referenzieren. + +**Wichtige Fork-Voraussetzung:** Laut GitHub benötigt das Fork-API bei GitHub Apps eine Installation am Zielaccount mit Zugriff auf alle Repositories sowie eine Installation am Quellaccount mit Zugriff auf das Quellrepo. Für diesen Endpunkt sind `Administration: write` und `Contents: read` angegeben. Die Erstellung läuft asynchron. Das ist ein zentraler Onboarding-Prüfpunkt, insbesondere für den Upstream `stackitcloud/stackit-landing-zone`. [Fork-API](https://docs.github.com/en/rest/repos/forks) + +Im Spike die vollständige Berechtigungsmatrix für Fork, Lesen, Branch/Commit und optional PR prüfen. Falls die nötige Quellinstallation oder Zielberechtigung nicht verfügbar ist: geführtes manuelles Forken in GitHub und anschließendes Verbinden; alternativ separat bewertete OAuth-App. Kein stiller Wechsel auf zentrale Credentials. Eine OAuth-App kann breitere Scopes benötigen; daher keine voreilige Festlegung nur aufgrund des einfacheren Fork-Ablaufs. [GitHub Apps und OAuth Apps](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/differences-between-github-apps-and-oauth-apps) + +### Mehrere Forks und Speichern + +Mehrere Repository-Verbindungen pro Benutzer/Tenant unterstützen, beispielsweise persönlicher Fork und Forks in berechtigten Organisationen. Nicht zusagen, dass beliebig viele echte Forks desselben Upstreams im selben Owner-Namespace möglich sind: die aktuelle Fork-Netzwerkgrenze im Spike prüfen. Mehrere Konfigurationen/Branches in einem Fork sind die bevorzugte Alternative; eine eigenständige Repository-Kopie wäre separat anzubieten und kein echter Fork. + +Fork-Erstellung idempotent behandeln, Bereitschaft pollen und Fehler verständlich anzeigen. Beim Speichern mehrere Dateien atomar committen und die erwartete Basisrevision prüfen. Konflikte nicht überschreiben. Geschützte Branches über Arbeitsbranch/PR bedienen. Webhooks signiert und idempotent verarbeiten; sie erlauben keinen automatischen Apply. + +### Optionaler STACKIT-IdP + +Die gefundene STACKIT-Anleitung beschreibt Federation, bei der STACKIT als Relying Party einem externen IdP vertraut. Sie belegt **nicht**, dass Kunden eine eigene Web-App unkompliziert als OIDC-Client des STACKIT-IdP registrieren können. Daher Client-Registrierung, Discovery, Claims und Nutzungsfreigabe separat klären. GitHub bleibt der initiale Login. [STACKIT-IdP-Einstieg](https://docs.stackit.cloud/platform/access-and-identity/stackit-idp/getting-started/) + +Zusätzlicher IdP-Login erteilt weder GitHub- noch Cloud-API-Rechte. GitHub-Verbindung bleibt nötig. Identitäten nur nach Nachweis beider Konten verknüpfen, niemals allein über gleiche E-Mail-Adressen. + +## 7. Fachmodell, Templates und Compiler + +### Versionierte Ablage + +Vorschlag für UI-Konfigurationen im ausgewählten Fork: + +```text +src/config/custom// + landing-zone.json # kanonisches Fachmodell, ohne Secrets + generated.tfvars.json # deterministisch erzeugte Terraform-Eingaben + manifest.json # Schema-, Compiler-, Template-/Accelerator-Versionen +``` + +Das Fachmodell ist die Quelle für UI-verwaltete Konfigurationen. Die generierte Datei ermöglicht weiterhin CLI-Nutzung. Vor Runs neu kompilieren und Hash vergleichen; manuelle Änderungen an generierten Dateien als Konflikt behandeln. Originale `.tfvars` bleiben erhalten. Importbericht für unbekannte Felder und Kommentarverlust; keine stillschweigende Löschung nicht unterstützter Einstellungen. + +Manifest: Template-Pfad/-Commit, Accelerator-Commit/Release, Schema-/Compiler-Version und Content-Hashes. Deployment-Bindings und Secret-Referenzen liegen außerhalb von Git. DB-Entwürfe sind nicht „gespeichert“, bevor der Git-Commit erfolgreich war. Runs verwenden ausschließlich unveränderliche Git-Revisionen. + +Der Template-Katalog startet mit den acht Upstream-Dateien einer freigegebenen Version. Fork-spezifische Konfigurationen sind eigene Konfigurationen und nicht automatisch vertrauenswürdige globale Templates. Upstream-Updates zunächst als explizites Upgrade mit Diff und Migration anbieten. Rollback einer Konfiguration bedeutet neue Revision und neuen Plan, nicht automatischen Infrastruktur-Rollback. + +### Feature-Katalog + +| Fachbereich | Repo-Bezug | UI-Verhalten und Regeln | +| --- | --- | --- | +| Organisation | Owner, Firma/-code, Organisations-ID, Labels, Parent-Folder | Pflichtwerte, Platzhalter ersetzen, gültige Referenzen | +| Governance | `rm_folders`, Organisationseigentümer/-auditoren, Rollen | Hierarchie und Rollenmodell | +| Management | State-Bucket, Secrets Manager, Service Accounts, Federation | Bootstrap- und Betriebsidentität unterscheiden | +| Observability/Audit | `observability`, `audit_logs` | Retention, Zugriff, Folgen von Object Lock erklären | +| Konnektivität | `connectivity`, `connectivity_regions` | SNA, CIDRs, DNS, regionale Zuordnungen | +| Firewall/VPN | Firewall/HA, `firewall_config`, VPN | Erreichbarkeit, Bootstrap und Grenzen der Traffic-Inspektion | +| Landing Zones | `landing_zones`, Modul `landing-zone` | Projekte, Owner, Umgebung, RBAC, Netz, DNS, Buckets, Secrets | +| Kubernetes-Plattform | `platform_kubernetes` | Cluster, Node Pools, Netz, verschlüsselte Volumes, Bastion | +| Kubernetes-Services | `landing_zone_namespace_services`, Namespace-Demo, Root-Kubernetes-Ressourcen | Plattformabhängigkeiten und erreichbarer API-Endpunkt | +| DevOps | `devops` | Git-Service und zulässige Netzwerkbereiche | +| Sandboxes | `sandboxes` | Projekt und Owner | + +Jedes Feature erhält eine stabile ID, Fachtext, Mapping, Defaultwerte, Abhängigkeiten, Regionsgrenzen, Secret-Bedarf, Compiler-Regeln, Grafik-Semantik und Tests. Struktur teilweise aus HCL ableiten; Fachtexte/Abhängigkeiten explizit pflegen. CI meldet Änderungen an Variablen/Modulen ohne aktualisierten Katalog. + +Validierung: Referenzen zwischen Bereichen/Landing Zones/Clustern, Namensregeln, unterstützte Regionen und Kombinationen, CIDR-Konflikte in verbundenen Netzen. Wiederverwendete Adressbereiche in tatsächlich isolierten Netzen gesondert bewerten. Stabile Terraform-Ressourcenschlüssel erhalten; eine Umbenennung darf kein unsichtbares Destroy/Create erzeugen. + +Pro Feature getrennt kennzeichnen: **darstellbar, editierbar, planbar, ausführbar**. Ein importierbares Firewall-Template ist noch nicht vollständig automatisiert deploybar. + +### Grenze zum Terraform-Code + +Der Compiler erzeugt nur Daten, keine frei eingegebenen HCL-Ausdrücke oder Shell-Kommandos. Im MVP wird Accelerator-Code anhand eines freigegebenen Commits materialisiert; aus dem Benutzer-Fork werden nur erlaubte Konfigurationsdateien gelesen. Abweichende Module klar melden. Keine beliebigen `.auto.tfvars`, Provider, Backend-Dateien oder Skripte aus Forks implizit mitladen. + +Eigener Terraform-Code ist ein späterer Vertrauensmodus mit stärkerer Isolation und expliziten Provider-/Modul-/Netzrichtlinien. Die aktuelle Einschränkung sichtbar machen, weil das Repo grundsätzlich Modul-Anpassungen vorsieht. + +## 8. Credentials und Persistenz + +| Kategorie | Ablage / Eigentümer | Verwendung | +| --- | --- | --- | +| GitHub User-/Refresh-Token | Secret Store, persönlicher Benutzer | Repository-Zugriff im Benutzerauftrag | +| STACKIT Service Account Key bzw. unterstützte Federation | Persönliches oder explizit geteiltes Deployment-Profil | Zugeordnetes Kunden-Deployment | +| S3-Backend-Zugang | Deployment-Profil | Genau zugehöriger State-Bereich | +| VPN-PSKs, Firewall-Login/API-Key, ggf. Kubeconfig | Secret Store je Deployment | Nur benötigte Laufphase | +| Model-Serving-Token | Betreiber, CF-Variablen des Backends | Chat-Service | +| CF-/DB-/Secret-Betriebsidentität | Betreiber | Plattformbetrieb, keine Kunden-Cloud-Vollmacht | + +„Credentials anlegen“ bedeutet zuerst Profil erstellen und vorhandene Credentials sicher hinterlegen. Neue STACKIT-Service-Accounts/Schlüssel kann die App nur mit explizit delegierten Cloud-Rechten erzeugen; GitHub-Login genügt nicht. Später kurzlebige Federation bevorzugen, sofern die benötigten Flows unterstützt sind. + +STACKIT Secrets Manager auf Mandanten-Policies, Credential-Lifecycle und runbezogene Ausgabe prüfen. Keine Annahme, dass beliebige Vault-Features im Managed Service verfügbar sind. Bei fehlenden Fähigkeiten eine explizite Alternative entscheiden, z. B. tenantgetrennte Secret-Instanzen oder geprüfte Envelope-Verschlüsselung mit separat verwalteten Schlüsseln; kein unverschlüsselter DB-Fallback. + +Runner erhalten nur ein kurzlebiges, auf Run und Secret-Version begrenztes Ausgabeticket. Kein allgemeines Secret-Store-Token. Persönliche Profile zeigen Status, Zweck, Ablauf und „Ersetzen/Widerrufen“, keine Rohwerte. Rotation, Backup, Wiederherstellung und Notfallzugriff planen. + +`Sensitive`-Markierungen verhindern nicht, dass Terraform-State oder gespeicherte Plans Secrets enthalten. Diese Artefakte wie Secrets schützen. Browser, Git, Chat, Audit und normale Logs erhalten keine Roh-Secrets. Frei angegebene Endpoints gegen SSRF absichern; Zielnetze per Profil freigeben statt beliebige interne Adressen vom Backend anzusprechen. + +### Datenmodell + +Alle Kundenobjekte tragen `tenant_id`, gegebenenfalls `workspace_id`, serverseitig geprüft. + +| Entität | Inhalt | +| --- | --- | +| User / Identity / Session | Interne UUID, Provider-ID, verknüpfte Identitäten, Session-Lifecycle | +| Tenant / Workspace / Membership | Kundenkontext, Mitgliedschaft, Rollen und Einladungen | +| RepositoryConnection | GitHub-ID, Benutzerautorisierung, Branch, Zugriffsstatus | +| TemplateVersion | Pfad/Commit, Metadaten, unterstützte Features | +| Configuration / Revision | Fachmodell, Schema, Git-Ziel, Basis-SHA und Speicherstatus | +| CredentialProfile / Delegation | Secret-Referenz/-Version, Besitzer, erlaubte Nutzung, Ablauf | +| Deployment | Revision, Zielorganisation, Backend-/State-ID und Runner-Profil | +| Run / Approval / Artifact | Status, Manifest, Plan-Hash, Freigabe, Ergebnis und Artefaktreferenzen | +| DriftSchedule / DriftFinding | Opt-in, Referenzrevision, Prüfung und Korrekturpolicy | +| ChatSession / Proposal | Versionskontext, Rückfragen, Patch und Übernahmezustand | +| AuditEvent | Akteur, Aktion, Ressource, Zeit, Ergebnis; keine Secret-Werte | + +Retention getrennt für Chat, Logs, Plans, Audit und State festlegen. State niemals mit normaler Artefaktbereinigung löschen. Konto-/Konfigurationslöschung zerstört keine Infrastruktur; Offboarding umfasst Export von Konfiguration/State, Entzug von Zugängen und dokumentierte Löschung verbleibender Daten. + +## 9. Plan, Apply und State + +### Unveränderlicher Laufvertrag + +Jeder Run bindet Tenant, Deployment, Git-Commit, Config-Hash, Accelerator-Commit, Schema-/Compiler-Version, Engine-Version, Provider-Lockfile, Runner-Artefakt-Digest, Backend-/State-ID und relevante Credential-Versionen. + +```mermaid +stateDiagram-v2 + [*] --> Queued + Queued --> Preflight + Preflight --> Planning + Planning --> NoChanges + Planning --> AwaitingApproval + AwaitingApproval --> Applying: Konkreten Plan freigeben + AwaitingApproval --> Expired: Veraltet oder abgelaufen + Applying --> Succeeded + Applying --> Failed + Preflight --> Failed + Planning --> Failed + Queued --> Cancelled + AwaitingApproval --> Cancelled + Applying --> ReconciliationRequired: Prozessstatus unklar +``` + +1. Berechtigung, Profil, Kompatibilität, Quota, Backend und Erreichbarkeit prüfen. +2. Geprüften Code und genau eine ausgewählte Konfiguration materialisieren; Backend-Einstellungen ohne Secrets im Git erzeugen. +3. `init` mit festgelegtem Backend und gepinnten Providern; `validate` und Fach-/Policy-Prüfungen. +4. `plan -out= -detailed-exitcode`: 0 = keine Änderungen, 2 = Änderungen, 1 = Fehler. CLI-Vertrag mit gepinnter Engine testen. +5. `show -json` auswerten, sensible Werte anhand der Sensitivitätsmarkierungen und zusätzlicher Regeln redigieren. Roh-JSON nie direkt an Browser oder Chat geben. +6. Create/Update/Delete/Replace und unbekannte Werte zeigen; Auswirkungen auf Fachobjekte erklären. Kosten nur bei belastbarer Datenquelle als Schätzung kennzeichnen. +7. Freigabe an Plan-Hash und Manifest binden; Genehmiger und Zeitpunkt protokollieren. Tenant-Policy kann Vier-Augen-Freigabe verlangen. +8. Exakt den gespeicherten Binär-Plan mit gleicher Engine/Plattform anwenden. Geänderte Revision, State oder relevante Ausführungsbedingungen erfordern neuen Plan und neue Freigabe. +9. Ergebnis, State-Version und bereinigte Outputs persistieren; temporäre Secrets/Dateien entfernen. + +Plan-Gültigkeit begrenzen. Cloud-Änderungen außerhalb des Systems können während der Freigabezeit stattfinden; State-Vergleich allein erkennt nicht alles. Bei erkanntem Risiko neu planen. Kein unbemerkter Re-Plan zwischen Freigabe und Apply. + +### Remote State und Sperren + +Empfehlung: vorab bereitgestellter State-Bucket im Kundenbereich, eigener State-Key pro Deployment und minimal berechtigte Backend-Credentials. Bootstrap-Assistent oder separates IaC erzeugt den Bucket **vor** dem ersten Accelerator-Lauf. Der später vom Accelerator erzeugte Management-Bucket kann zusätzlich existieren; eine Migration ist ein eigener, exklusiv gesperrter Vorgang. + +Ein zentraler State-Service wäre eine Alternative, erhöht aber Verantwortung und Schadensradius des Betreibers. D07 vor Implementierung entscheiden. Plattform-IaC und Kunden-Deployments dürfen niemals denselben State teilen. + +- Backend-Locking mit gewählter Engine und STACKIT Object Storage praktisch testen, einschließlich konkurrierendem CLI-Zugriff. S3-Kompatibilität ist kein Locking-Nachweis. +- Zusätzlich DB-Lease pro kanonischer State-ID; doppelte Deployment-Registrierung desselben States verhindern. Lease ersetzt Backend-Lock nicht. +- Versionierung, Verschlüsselung, Restore-Test und protokollierte manuelle Entsperrung vor Produktivfreigabe. +- Bei Runner-Absturz nicht blind erneut Apply ausführen. Prozessstatus, Lock und State abgleichen; gegebenenfalls neuen Plan erstellen. +- Apply kann teilweise erfolgreich sein. Kein automatischer Rollback; Wiederherstellung über neuen Plan. Abbruch ist best effort und kann Teiländerungen hinterlassen. +- Ein Deployment entspricht zunächst einem Accelerator-Root. Größere State-Aufteilung benötigt gesonderte Migrations- und Abhängigkeitsplanung. + +### Spezielle Runbooks + +Firewall-Bootstrap, Policy-Konfiguration, Secret-Übergabe, HA und gegebenenfalls Kubernetes-Provider-Bootstrap als versionierte Abläufe implementieren. Jede schreibende Phase mit verändertem Plan benötigt eine passende Freigabe. Kein UI für beliebige `-target`-Argumente. + +Für Firewall: Image-Herkunft/Prüfsumme, Speicherbedarf, Werkzeuge, private API-Erreichbarkeit, Passwortrotation und TLS-Verifikation testen. Abbruch während offenem Admin-Interface muss sichtbar sein und eine konkrete Absicherungs-/Wiederaufnahmeanleitung bieten. Ohne erfolgreichen Nachweis diese Variante nicht als automatisiert ausführbar freigeben. + +## 10. Drift Detection und Correction + +Pro Deployment: Aus, nur erkennen, nach Freigabe korrigieren, später eng begrenzte automatische Korrektur. Standard: Aus. Hintergrundnutzung von Credentials benötigt eine explizite widerrufbare Delegation, getrennt von einer interaktiven Session. + +- Referenz ist die zuletzt erfolgreich ausgerollte Git-/Accelerator-Revision, nicht der aktuelle Branch-Head. +- Refresh-only-Plan kann Unterschiede zwischen Cloud und State zeigen. Normaler Plan gegen dieselbe Revision zeigt die zur Soll-Wiederherstellung nötigen Änderungen. Beide Aussagen unterscheiden. +- Kein automatisches `apply -refresh-only`, um Befunde lediglich verschwinden zu lassen. +- Zeitpläne mit Jitter, Backoff, Mandantenquoten und Lauf-Sperren; keine Prüfung parallel zu schreibendem Run desselben States. +- API-Fehler oder abgelaufene Credentials bedeuten „Prüfung fehlgeschlagen“, nicht „keine Drift“. +- Correction stellt den genehmigten Sollzustand her. „Ist als Soll übernehmen“ ist separate Konfigurationsänderung mit Git-Commit und Review. +- Automatische Correction nur nach ausdrücklichem Opt-in mit testbarer Allowlist, Wartungsfenster, Ressourcenlimit, Audit und Not-Aus. +- Delete/Replace, IAM, Firewall-/Routing-Änderungen, Secret-Rotation und unbekannte Aktionen zunächst immer freigabepflichtig. +- Nach Fehlern pausieren statt Endlosschleife; neue Konfigurationsänderungen nie als Drift-Korrektur einschleusen. + +## 11. Chatbot mit STACKIT Model Serving + +### Fachlicher Vertrag + +Der Chat behandelt ausschließlich Planung und Konfiguration des versionsgebundenen Accelerators. Er erklärt Features und Grenzen, fragt aktiv fehlende Informationen ab und erzeugt überprüfbare Änderungen. Außerhalb dieses Bereichs führt er zum unterstützten Thema zurück. + +Wissen: Feature-Katalog, Schema, freigegebene Repo-/Moduldokumentation und Templates der gewählten Version. Kein beliebiger Web-/Repository-Zugriff. Dokumentverweise in Antworten; unbekannte Features nicht erfinden. Systemprompt alleine garantiert die Einschränkung nicht: Tool- und Datenzugriffe im Backend erzwingen. + +Ablauf: + +1. Benutzer beschreibt sein Ziel. +2. Backend liefert minimale secretfreie Konfiguration und relevante versionierte Wissensabschnitte. +3. Chat fragt notwendige Angaben ab: Owner, Organisation, Regionen, Isolation, Netzplanung. Widersprüche erklären, etwa getrennte SNAs bei gleichzeitig gewünschtem gemeinsamen privaten Git-Service. +4. Modell erzeugt `questions`, `assumptions`, `patch`, `explanation`, `references`. +5. Backend prüft Schema/Fachregeln und Basisrevision des Patches. +6. UI zeigt Diff und Topologie-Vorschau. Erst „Übernehmen“ ändert den Entwurf; Git-Speichern, Plan und Apply sind separate Aktionen. + +Erlaubte interne Werkzeuge: Features nachschlagen, bereinigte Konfiguration lesen, Patch vorschlagen/validieren. Kein Secret-Lesen, Shell, Git-Schreiben oder Apply durch das Modell. Repo-Kommentare und Freitext gelten als untrusted input. Keine fremden Tenant-Daten, Credentials, States oder Roh-Plans im Modellkontext. + +### Anbindung und Betrieb + +STACKIT dokumentiert eine OpenAI-kompatible Inferenz-API mit Auth-Token. Die API hält keinen Gesprächszustand; das Backend verwaltet den nötigen Kontext selbst. [Modelle verwenden](https://docs.stackit.cloud/products/data-and-ai/ai-model-serving/how-tos/use-the-models/) + +Voraussetzung: STACKIT-Projekt mit aktiviertem AI Model Serving, ausgewähltem Modell, Kapazität und Inferenz-Token. Token-Erzeugung benötigt eine separate STACKIT-Autorisierung; Inferenz-Token nicht mit allgemeinen STACKIT-Credentials verwechseln. [Auth-Tokens](https://docs.stackit.cloud/products/data-and-ai/ai-model-serving/how-tos/manage-auth-tokens/) + +Vorgeschlagene app-eigene CF-Variablen: + +```text +MODEL_SERVING_BASE_URL +MODEL_SERVING_MODEL +MODEL_SERVING_API_KEY +MODEL_SERVING_TIMEOUT_SECONDS +``` + +Der dokumentierte Shared-Model-Einstieg verwendet `https://api.openai-compat.model-serving.eu01.onstackit.cloud/v1/chat/completions`. Endpoint/Modell aus der gewählten Service-Konfiguration übernehmen; nicht den Management-API-Endpoint für Inferenz nutzen. Modellfähigkeiten wie Tool Calling/strukturierte Ausgabe separat evaluieren. [Shared Models starten](https://docs.stackit.cloud/products/data-and-ai/ai-model-serving/getting-started/getting-started-with-shared-models/) + +Credentials ausschließlich auf Backend/Chat-App setzen, nie ins Frontend-Build oder Runner. CF-Umgebungsvariablen sind für entsprechend berechtigte Space-Developer lesbar; diese gehören damit zur Betreiber-Vertrauensgrenze. Über geschützte Deployment-Injektion setzen, nicht in Git-Manifeste schreiben; Rotation und Redaktion testen. [CF-Variablen und Secrets](https://docs.stackit.cloud/products/runtime/cloud-foundry/how-tos/manage-environment-variables-and-secrets/) + +Token-/Kostenlimits pro Tenant, Timeouts, begrenzte Reparaturversuche für ungültiges JSON und Circuit Breaker. Bei KI-Ausfall bleibt der manuelle Editor nutzbar. Gesprächsaufbewahrung, Löschung und übermittelte Organisationsdaten transparent machen. Tenant-Chatdaten nicht in einen gemeinsamen Wissensindex übernehmen. + +Evaluationsfälle: fehlende Pflichtfelder, widersprüchliche Netze, unbekannte Features, Themenwechsel, Prompt Injection, Secret-Offenlegung, fremde Tenants, veraltete Revisionen und ungültige Ausgabe. Der Chat gilt erst mit gemessenen Akzeptanzkriterien als freigegeben. + +## 12. Grafische Darstellung + +Ein deterministischer Graph-Builder verwendet dasselbe validierte Fachmodell wie der Compiler. Kein frei vom Sprachmodell gezeichnetes Infrastrukturdiagramm. + +- Gruppierung: Organisation → Region → Netzwerkbereich → Projekte/Landing Zones → Dienste. +- Knoten für Management, DevOps, Plattformcluster, Firewall, VPN, DNS und Sandboxes. +- Kanten für echte Beziehungen: SNA-Anbindung, Default Route, Cluster-Zuordnung, explizite VPN-Verbindung. Keine impliziten Verbindungen zwischen SNAs/Regionen. +- Getrennte Ansichten: Konfigurations-Soll, Plan-Delta und zuletzt bekannter Ist-/State-Stand mit Zeitstempel. State ist kein Echtzeit-Monitoring. +- Create/Update/Delete/Replace sowie unbekannte Werte durch Symbol und Farbe markieren. +- Plan-Ressourcen über versioniertes Mapping Features zuordnen; nicht zuordenbare Ressourcen bleiben technisch sichtbar. +- Knoten öffnen Fachformulare. Freies Zeichnen von Beziehungen erst später mit denselben Validierungsregeln. +- Tastaturbedienbare Listen-/Tabellenalternative; secretfreier Grafikexport als Ausbau. + +## 13. IaC, Lieferung und Betrieb + +Das getrennte Verzeichnisgerüst und die lokale App-Entwicklungsbasis sind angelegt; Bootstrap-, Backend- und Plattform-IaC sind implementiert; noch kein Cloud-Apply. Details stehen in der [Configurator-README](../README.md). Die [Sprachentscheidung](decisions/0001-application-stack.md) ist als angenommen dokumentiert. + +```text +landing-zone-configurator/ + app/apps/{web,api,worker}/ + app/packages/{domain,contracts}/ + tools/hcl-adapter/ + runner/ + infra/{bootstrap,backend,platform,modules,environments}/ + deploy/cloud-foundry/ + docs/decisions/ +``` + +Die lokal bereitgestellten Dateien `landing-zone-configurator.env` und `landing-zone-configurator-credentials.json` bleiben im Repository-Root und sind von Git ausgeschlossen. Sie betreffen die Betreiberplattform, nicht Kunden-Deployments. Werte nicht in Dokumentation, Manifeste oder Git übernehmen. Die `.env` verwendet nach Benutzerkorrektur das geprüfte `NAME=WERT`-Format. Erkannte Variablennamen: `PROJECT_ID`, `ORGANISATION_ID`. Vor IaC-Nutzung das Mapping zu den Plattformvariablen festlegen. Zielprojekt-Lesezugriff und ausgewählte Dienst-Metadaten wurden geprüft; Schreibrechte sind noch nicht nachgewiesen. Siehe [Plattformprüfung](platform-check.md). + +App-Infrastruktur und Kunden-Landing-Zones haben getrennte States, Credentials und Release-Zyklen. Die App darf nicht von einer Landing Zone abhängen, die erst durch dieselbe App erzeugt werden müsste. STACKIT dokumentiert CF-Provisionierung via Terraform; die konkrete Ressourcenabdeckung ist dennoch im Spike nachzuweisen. [CF mit Terraform](https://docs.stackit.cloud/products/runtime/cloud-foundry/how-tos/provision-cloud-foundry-via-terraform/) + +### Plattformaufbau + +- [ ] Betreiberprojekt, Region, CF-Org/-Spaces, Quoten und Betriebsrollen festlegen. +- [ ] Plattform-Remote-State und Bootstrap-Identität vorab provisionieren; anschließend eingeschränkte Betriebsidentität. +- [ ] CF-Apps/Prozesse, Routes, Domain/TLS, Bindings, Health Checks und Skalierung deklarieren. +- [ ] PostgreSQL Flex, Secret Store, Artefakt-Buckets, IAM, ACLs/Netzregeln und Monitoring provisionieren. +- [ ] Model Serving aktivieren/provisionieren oder vorhandenen Service explizit anbinden; Provider-/API-Abdeckung prüfen. +- [ ] Runner und gegebenenfalls kundenspezifische Netzverbindung über eigene Module bereitstellen. +- [ ] GitHub App, Callback-/Webhook-URLs und Schlüssel verwalten; nicht automatisierbare Registrierungsschritte dokumentieren. +- [ ] Provider-Versionen pinnen; fehlende Ressourcen nur über dokumentierte idempotente API-/CLI-Schritte ergänzen. +- [ ] Secrets geschützt in CF injizieren. `sensitive = true` verhindert keine Aufnahme in Terraform-State; Secret-Werte möglichst nicht durch IaC-Ressourcenparameter schleusen. +- [ ] Entwicklungs-, Staging- und Produktionsumgebung trennen; keine Kundensecrets in Entwicklung. + +### CI/CD und Betrieb + +- [ ] Parser-/Compiler-Fixtures für alle acht Templates; semantischer Import-/Export-Vergleich. +- [ ] Fachregeln, Versionsmigrationen und Feature-Abdeckung prüfen. +- [ ] Autorisierungs-, RLS-, Runner-Isolations- und Secret-Redaktionstests. +- [ ] Dependency-/Image-Scans, reproduzierbare Builds, IaC-Validate und Staging-Smoke-Test. +- [ ] Gepinnte Engine/Provider mit realer Testorganisation qualifizieren; bestehende Repo-Tests benötigen teilweise STACKIT-API-Zugriff. +- [ ] Blue/green oder getesteter CF-Rollout; rückwärtskompatible DB-Migrationen und App-Rollback. +- [ ] Backup/Restore von DB, Secrets und State einschließlich benötigter Schlüssel praktisch üben. +- [ ] CF-Restart, verlorenen Runner, DB-Ausfall, Token-Widerruf und teilweise fehlgeschlagenen Apply testen. +- [ ] Metriken: Queue-Alter, Runner-Heartbeat, Fehlerquoten, Plan-/Apply-Dauer, Drift-Fehler, KI-Verbrauch und Mandantenquoten. +- [ ] RPO/RTO, Verfügbarkeit, Retention, Budget, Supportwege und Notfallzugriffe vor erster Kundenfreigabe festlegen. + +## 14. Roadmap und Abnahme + +Mehrmandantenfähigkeit ist Teil jeder Phase. Funktionsumfang kann schrittweise wachsen; Kundenisolation darf nicht auf eine spätere Phase verschoben werden. Aufwand und Termine erst nach Spikes und Teamzuschnitt schätzen. + +### P0 – Machbarkeit und verbindliche Entscheidungen + +#### Entwicklungsbasis – erster abgeschlossener Schritt + +- [x] Stack bestätigen und Runtime-/Paketversionen pinnen. +- [x] Eigenen npm-Workspace mit Lockfile, React/Vite und Fastify anlegen. +- [x] Gemeinsame Verträge und initiale Tenant-/Rollen-Policy anlegen. +- [x] Lokales Linting, Typecheck, Build und fünf Tests erfolgreich ausführen. +- [x] Separate Configurator-CI als Workflow anlegen (noch nicht auf GitHub ausgeführt). +- [x] Plattform lesend prüfen und Grenzen/Ergebnisse dokumentieren. +- [ ] Vollständige Sessions, DB/RLS und tenantgetrennte Persistenz implementieren. + +Diese Basis ersetzt keinen der folgenden Architektur-Spikes. Details zum Start: [Entwicklungsanleitung](../app/README.md). + +- [x] Anforderungen, Repo-Ausgangslage und Architekturentwurf dokumentieren. +- [x] Mehrere unabhängige Kunden ab Beginn und zunächst nur Konfigurationsänderungen bestätigen. +- [ ] GitHub-Spike: User Token, Upstream-/Zielinstallation, Fork, mehrere Owner, Commit/PR, Widerruf und Refresh. +- [ ] CF-Runner-Spike: Isolation, Limits, Abbruch, Secret-Übergabe und private Ziele. +- [ ] State-Spike: erster Run, echtes Locking, konkurrierender CLI-Zugriff, Restore. +- [ ] Secret-Store-Spike: Policies, Mandantentrennung, Rotation und Delegation. +- [ ] Service-Pläne, Regionen, IaC-Ressourcenabdeckung und optionalen IdP klären. +- [ ] Model-Serving-Spike: Auth, Modell, strukturierte Vorschläge, Limits und Evaluation. + +**Abnahme:** Kritische Annahmen sind belegt oder durch beschlossene Alternativen ersetzt. Architekturentscheidungen mit Nachweisen erfassen. + +### P1 – Mandanten, GitHub, Templates und Editor + +- [ ] Tenant-Onboarding, Rollen, Einladungen, Sessions und serverseitige Objekt-Autorisierung. +- [ ] Repository-Auswahl, Fork-Flow und konkurrierendes Speichern. +- [ ] Alle acht Templates katalogisieren, importieren und Unterstützung kennzeichnen. +- [ ] Fachmodell/Compiler und Editor zunächst für Standalone und einfaches Hub-Spoke vollständig umsetzen. +- [ ] Soll-Topologie, Entwürfe, Git-Versionierung und Upgrade-Metadaten. +- [ ] Negativtests gegen fremde Mandanten und Verlust von Feldern beim Import. + +**Abnahme:** Template kopieren → fachlich ändern → Grafik prüfen → im ausgewählten Fork speichern → semantisch unverändert laden. Keine Secrets im Commit und kein Überschreiben fremder Änderungen. + +### P2 – Sichere Deployments und Betriebsgrundlage + +- [ ] Staging-/Produktionsgrundlage per IaC, Backups und Observability. +- [ ] Persönliche Credential-Profile, Delegationen und kundenbezogener Remote State. +- [ ] Runner, Queue, Manifest, Plan-Redaktion, Freigabe und exakter Apply. +- [ ] Standalone und einfaches Hub-Spoke in getrennten Testorganisationen ausführen. +- [ ] Isolation, Quoten, Crash, Abbruch, Rechteentzug, Restore und partielle Fehler prüfen. + +**Abnahme:** Mindestens zwei unabhängige Testkunden können getrennt arbeiten. Fremde Daten/Secrets sind unerreichbar; veraltete Plans werden abgewiesen; Runner-Ausfall erzeugt keinen blinden zweiten Apply. Betriebsziele und Runbooks sind festgelegt. + +### P3 – Chat und vollständige Feature-Abdeckung + +- [ ] Model Serving mit Rückfragen, Schema-Patches, Diff und Übernahme integrieren. +- [ ] Versioniertes Wissen und Evaluationssuite. +- [ ] Multi-Area, Multi-Region, Kubernetes, Firewall/HA/VPN und weitere Services abdecken. +- [ ] Spezielle Bootstrap-Abläufe und Netzkonnektivität nachweisen. +- [ ] Plan-Delta und bekannten Ist-Stand grafisch zeigen. + +**Abnahme:** Chat und Formular erzeugen äquivalente validierte Konfigurationen. Alle Templates haben nachvollziehbaren Unterstützungsstatus. Repo-Grenzen bleiben sichtbar; keine frei erfundenen Features und kein Apply durch Chat. + +### P4 – Optionaler Betriebsausbau + +- [ ] Drift Detection mit expliziter Hintergrunddelegation und Statusmeldungen. +- [ ] Correction nach Freigabe, später enge automatische Allowlist mit Not-Aus. +- [ ] Optional STACKIT-IdP nach Machbarkeitsnachweis. +- [ ] Eigener Terraform-Code erst nach gesonderter Sicherheits-/Runner-Entscheidung. +- [ ] Erweiterte Teamprozesse, Audit-Export und kundenspezifische Isolationsoptionen. + +**Abnahme:** Automatische Korrektur hält ihre Policy auch bei unbekannten oder destruktiven Plans ein. Ausfall/Widerruf führt zu kontrollierter Pause statt unkontrollierten Wiederholungen. + +## 15. Entscheidungsregister + +| ID | Entscheidung | Empfehlung / Ergebnis | Status | Owner | +| --- | --- | --- | --- | --- | +| D01 | Zielgruppe | Mehrere unabhängige Kundenorganisationen ab Beginn | Bestätigt | Produkt | +| D02 | Terraform-Modifikationen | V1 nur Konfigurationen, eigener Code später | Bestätigt | Produkt | +| D03 | GitHub-Integration | GitHub App mit User Token; Fork-Voraussetzungen nachweisen | Offen | Backend | +| D04 | Mehrere Forks | Mehrere Owner/Repos; Branches/Configs als zusätzliche Varianten | Spike | Produkt/Backend | +| D05 | Persistenz | PostgreSQL Flex, Tenant-Isolation plus RLS | Vorschlag | Plattform | +| D06 | Runner-Hosting | Separate CF-Runner, sonst explizit SKE/Compute | Kritischer Spike | Plattform/Security | +| D07 | State-Eigentum | Kunden-State vor erstem Run; eigener Backend-Bootstrap | Offen | Architektur | +| D08 | Secret-Verwaltung | Secret Store mit Run-Delegation und Tenant-Policies | Kritischer Spike | Security | +| D09 | Config-Format | Fach-JSON plus generierte tfvars.json | Vorschlag | Architektur | +| D10 | Engine | Gepinntes OpenTofu 1.12.6 zuerst; Terraform separat qualifizieren | Vorschlag | Maintainer | +| D11 | STACKIT-IdP | Optional, Client-Registrierung nicht nachgewiesen | Offen | IAM | +| D12 | Drift Correction | Freigabe zuerst, automatische Allowlist später | Vorschlag | Produkt/Betrieb | +| D13 | KI-Modell | STACKIT-Service mit evaluiertem Modell und Tenant-Limits | Spike | KI/Produkt | +| D14 | Betriebsziele | RPO/RTO, Retention, Verfügbarkeit und Budget vor Kundenfreigabe | Offen | Betrieb | +| D15 | Produktname und Struktur | Landing Zone Configurator unter eigenem Top-Level-Verzeichnis | Bestätigt / Gerüst angelegt | Produkt | +| D16 | Anwendungssprache | TypeScript für Web/API/Worker; Go für begrenzten HCL-Adapter | Bestätigt, siehe ADR 0001 | Entwicklung | +| D17 | Dienstbereitstellung | Sämtliche Configurator-Dienste und Aktivierungen via IaC | Bestätigt | Plattform | +| D18 | Erste Umgebung | lzc-dev in eu01; Backend-Zugang zunächst ca. 90 Tage | Umgebung bestätigt; Laufzeit als Betriebsdefault | Plattform | + +## 16. Risiken und Definition of Done + +| Risiko | Gegenmaßnahme / Nachweis | +| --- | --- | +| Tenant-übergreifender Zugriff | RLS plus Objekt-Autorisierung, getrennte Secrets/States, Negativtests | +| Runner liest Betreiber-Secrets | Keine Backend-Bindings; gezielter Isolationstest | +| Fork-API passt nicht zum Onboarding | Früher GitHub-Spike; manueller Fork-/Connect-Flow als klare Alternative | +| UI und Terraform divergieren | Versionierter Katalog, Compiler-Fixtures und Migrationsprüfungen | +| Secret in Plan/State/Log/Chat | Geschützte Artefakte, Redaktion vor Logging, negative Tests | +| Private API aus CF unerreichbar | Netz-Spike, dediziertes Runner-Profil im Zielnetz | +| Zwei Prozesse schreiben denselben State | Backend-Lock plus kanonische State-ID und Plattform-Lease | +| Initial offene Firewall | Sicherer Bootstrap, Passwortrotation und Fehler-Runbook vor Freigabe | +| Unerwartetes Destroy/Create | Stabile Schlüssel, sichtbare Ersetzungen und konkrete Plan-Freigabe | +| Kunden blockieren gegenseitig Kapazitäten | Tenant-Quoten, faire Queue, Rate Limits und Backoff | +| KI missachtet Thema/Schema | Backend erzwingt Tools und Datenzugriffe; Evaluation und Fallback | +| Backup nicht wiederherstellbar | Restore-Übungen einschließlich Schlüsselmaterial | + +Für jedes Feature: + +- [ ] Fachliches Verhalten, Mapping und Grenzen dokumentiert. +- [ ] Editor, Chat, Compiler und Grafik nutzen dieselbe Semantik. +- [ ] Autorisierung, Mandantentrennung und relevante Fehlerfälle geprüft. +- [ ] Keine Secrets in Git, Frontend oder ungeschützten Artefakten. +- [ ] Migration und Betrieb dokumentiert, sofern erforderlich. +- [ ] Akzeptanznachweis und Issue-/PR-Link hinterlegt. + +## 17. Quellenstatus und laufende Planung + +Lokale Analyse: Repo-Stand `2161871`. Externe Primärquellen wurden am 2026-09-29 nach Wiederherstellung des Netzwerkzugriffs geprüft und direkt an den relevanten Aussagen verlinkt. Dokumentation ersetzt keine Integrationstests. Offen bleiben insbesondere konkrete Service-Pläne/-Quoten, die GitHub-Fork-UX, Secret-Policies, S3-Locking, private Runner-Konnektivität und STACKIT-IdP-Client-Registrierung. Object Storage und Model Serving werden durch ihre Provider-Ressourcen automatisch aktiviert; der frühere 404 ist damit geklärt. + +**Nächster Planungsschritt:** P0-Spikes für GitHub, Runner/Netz, State und Secrets priorisieren und Verantwortliche zuweisen. Danach D03/D06/D07/D08 entscheiden und den ersten vertikalen Ablauf von Login bis freigegebenem Apply schätzen. + +| Datum | Ergebnis / Entscheidung | Verantwortlich | Nachweis | +| --- | --- | --- | --- | +| 2026-09-29 | Architekturentwurf und Repo-Feature-Mapping erstellt | Noch zuzuweisen | Dieses Dokument | +| 2026-09-29 | Mehrere unabhängige Kunden ab Beginn; V1 nur Konfigurationen | Benutzerentscheidung | Planungsgespräch, D01/D02 | +| 2026-09-29 | TypeScript/React/Fastify bestätigt; lokale Entwicklungsbasis und getrennte CI angelegt | Entwicklung | ADR 0001, App-Workspace | +| 2026-09-29 | Object Storage 404 als nicht aktiviert geklärt; Provider aktiviert bei erstem Bucket | Entwicklung | platform-check.md | +| 2026-09-29 | lzc-dev/eu01 bestätigt; erster echter Bootstrap-Plan mit 3 Create erfolgreich | Benutzer / Entwicklung | infra/README.md | + +Vorlage für laufende Arbeitspakete: + +```markdown +### : +- Status: offen / in Arbeit / blockiert / erledigt +- Owner: +- Ziel und Abnahmekriterium: +- Abhängigkeiten / Entscheidungen: +- Aufwand / Zieltermin: +- Issue / PR: +- [ ] Umsetzung +- [ ] Relevante Prüfungen und Abnahmenachweis +- [ ] Dokumentation und Betrieb +``` + +## 18. CI/CD nach Lebenszyklus + +Das [CI/CD-Konzept](cicd.md) definiert getrennte Workflows für seltenen Bootstrap, Plattformänderungen, häufige App-Releases und optionale Drift-Erkennung. Es enthält State-/Secret-Abhängigkeiten, Lifecycle-Ownership und eine abhakbare Umsetzungsreihenfolge. Deployment-Workflows sind geplant; die Validierungs-CI besteht bereits. Nächster technischer Schritt: unabhängiges Verwaltungs-Backend und sichere CI-Schlüsselhaltung vor dem ersten Cloud-Apply. + +Verwaltungs-Backend: separater Bucket im bestehenden Projekt durch `seed`/`seed-protection` angelegt. Bootstrap an S3 angebunden; CI-Schlüsselübergabe und Deployment-Workflows bleiben nächste Schritte. + +CI-Stand: Bootstrap-Workflow und Environment-Einrichtung lokal implementiert; Secret-Upload wartet auf explizite Freigabe. S3-Locking-Abnahme fehlgeschlagen (zweiter Prozess trotz Lock nicht blockiert); Remote-Applies sind technisch gesperrt. Backend-/Sperrstrategie muss vor Deployment entschieden werden. Siehe [CI-Betrieb](../infra/ci/README.md). + +Bestätigte Folgeentscheidung: S3 beibehalten, Deployments vorerst ausschließlich über serialisierte GitHub Actions. Lokale Remote-Applies bleiben gesperrt; alle mutierenden Workflows nutzen eine gemeinsame Concurrency-Gruppe. Benutzer hat den konkreten Secret-Transfer in die drei benannten GitHub Environments ausdrücklich genehmigt. + +## Feature-Branch und Code Ownership + +Die Entwicklung erfolgt auf `feature/landing-zone-configurator`; eine Übernahme nach `main` ist derzeit ausdrücklich nicht vorgesehen. Für `/landing-zone-configurator/` und Configurator-Workflows unter `/.github/workflows/` ist ausschließlich `@lweberru` als CODEOWNER eingetragen. Gemeinsame Repository-Dateien behalten ihre bestehenden Owner. + +Die GitHub-Environment-Regeln und der Deployment-Workflow erlauben weiterhin ausschließlich `main`. Feature-Branch-Entwicklung aktiviert daher keine Cloud-Deployments. Ein späterer Testbetrieb vom Feature-Branch braucht eine gezielte Anpassung dieser Regeln; der Branch wird dafür nicht automatisch nach main übernommen. + +GitHub wertet CODEOWNERS für Pull Requests aus dem jeweiligen Zielbranch aus: Für einen späteren PR nach main gilt zunächst die dortige bisherige Regel. CODEOWNERS setzt außerdem keine Branch-Protection-Regeln außer Kraft; eigene PRs können nicht selbst freigegeben werden. diff --git a/landing-zone-configurator/docs/platform-check.md b/landing-zone-configurator/docs/platform-check.md new file mode 100644 index 0000000..865ae48 --- /dev/null +++ b/landing-zone-configurator/docs/platform-check.md @@ -0,0 +1,74 @@ +# Lesende Plattformprüfung + +Stand: 2026-09-29. Ziel: Betreiberprojekt aus der lokalen `landing-zone-configurator.env`, Authentisierung mit der zugehörigen Service-Account-Datei. IDs, Schlüssel und Tokens werden hier bewusst nicht wiederholt. + +## Ergebnis + +| Prüfung | Beobachtung | Aussage / Grenze | +| --- | --- | --- | +| Service-Account-Authentisierung | Kurzlebiger Token erfolgreich bezogen | Kein persistenter CLI-Login geändert | +| Projekt lesen | Erfolgreich; zurückgegebene Projekt-ID stimmt mit Eingabe überein | Projekt-Lesezugriff nachgewiesen; keine Prüfung von Schreibrechten oder Organisationszuordnung | +| CF-Organisationen | HTTP 200; 0 Einträge | Im abgefragten Projekt/Region keine Organisation sichtbar | +| CF-Plattformangebote | HTTP 200; 2 Einträge | Angebote erreichbar; konkrete Foundation/Quota noch auswählen | +| PostgreSQL-Flex-Instanzen | HTTP 200; 0 Einträge | Keine Instanz sichtbar | +| PostgreSQL-Flavors | HTTP 200; 10 Einträge | Angebot lesbar; HA, Größe, Backup und Kosten noch entscheiden | +| Secrets-Manager-Instanzen | HTTP 200; 0 Einträge | Keine Instanz sichtbar; konkrete Policies/Isolation noch nicht prüfbar | +| Model-Serving-Katalog | HTTP 200; 10 Modelle | Allgemeiner Katalog lesbar; kein Nachweis projektbezogener Aktivierung, Inferenzrechte oder gebuchter Kapazität | +| Object-Storage-Service-Status | HTTP 404 | Nach Abgleich mit der offiziellen CLI: Dienst im Projekt/Region noch nicht aktiviert; der erste IaC-Bucket aktiviert ihn | + +Die erste Abfrage verwendete `eu01` als Probe-Region. Anschließend wurden **lzc-dev/eu01** als erste Entwicklungsumgebung bestätigt und lokal eingetragen. Leere Listen sind keine Zusage, dass Erstellen erlaubt ist. Listenantworten beschreiben die aktuelle Sicht des Service Accounts, nicht sämtliche eventuell anderswo vorhandenen Ressourcen. + +Der Object-Storage-404 trat sowohl über die installierte STACKIT-CLI als auch direkt am dokumentierten v2-Service-Status-Endpunkt auf. Die offizielle CLI behandelt genau diesen Service-Status-404 in `ProjectEnabled` als nicht aktiviert. Der Provider aktiviert Object Storage beim Erstellen des ersten Buckets. Es wurde kein Bucket angelegt und es gab keinen fehlgeschlagenen Zugriff auf einen von uns erstellten Bucket. + +Quellen: [CLI-Auswertung](https://github.com/stackitcloud/stackit-cli/blob/main/internal/pkg/services/object-storage/utils/utils.go), [Bucket-Aktivierung im gepinnten Provider](https://github.com/stackitcloud/terraform-provider-stackit/blob/v0.114.0/stackit/internal/services/objectstorage/bucket/resource.go). + +## Wiederholen + +Siehe [Entwicklungsanleitung](../app/README.md): `npm run platform:check`. Das Skript nutzt Node.js-Systemzertifikate, begrenzte Timeouts, keine HTTP-Redirects mit Token und gibt nur aggregierte Metadaten aus. Es liefert Exitcode 1 bei unerwarteten Fehlern. Der bekannte Object-Storage-404 ist jetzt eine erfolgreiche Statusbestimmung mit `enabled: false`; das bedeutet noch keine Deployment-Bereitschaft. + +Installierte STACKIT-CLI beim Test: `0.61.0`. Deshalb wurden die Dienstlisten direkt über die bestätigten APIs nachgeprüft; die CLI bleibt für den Token-Austausch und Projekt-Lesezugriff im Skript. Eine ältere CLI-Ausgabe ohne JSON wurde nicht als leere Liste gewertet. + +API-Verträge aus dem offiziellen SDK: [SCF](https://github.com/stackitcloud/stackit-sdk-go/tree/main/services/scf), [PostgreSQL Flex](https://github.com/stackitcloud/stackit-sdk-go/tree/main/services/postgresflex), [Secrets Manager](https://github.com/stackitcloud/stackit-sdk-go/tree/main/services/secretsmanager), [Model Serving](https://github.com/stackitcloud/stackit-sdk-go/tree/main/services/modelserving), [Object Storage](https://github.com/stackitcloud/stackit-sdk-go/tree/main/services/objectstorage). + +## Konsequenzen für den nächsten Schritt + +- [x] Projekt-Lesezugriff mit bereitgestellter Identität bestätigen. +- [x] CF-/DB-/Secret-Angebote beziehungsweise Bestandslisten und Modellkatalog lesen. +- [x] Zielregion eu01 und Name lzc-dev für Entwicklung bestätigen. +- [ ] CF-Foundation/Quota festlegen. +- [x] Object-Storage-404 klären: noch nicht aktiviert; Aktivierung als Teil der Bucket-IaC. +- [x] State-Bootstrap und separaten Versionierungs-Root implementieren; erster realer Bootstrap-Plan erfolgreich. +- [ ] Bootstrap/Versionierung anwenden und S3-Locking sowie Restore praktisch testen. +- [ ] Erforderliche Create-/Update-Rechte anhand der geplanten Ressourcen prüfen; aus den GETs nicht ableiten. +- [ ] CF-Organisation und technischen CF-Zugang per IaC planen. +- [ ] DB-Plan, Secret-Policies und Modell-/Kapazitätsauswahl entscheiden. +- [ ] Runner-Isolation, private Erreichbarkeit und S3-Locking praktisch nachweisen. + +STACKIT-Projekt-Service-Account und CF-Runtime-Service-Account sind unterschiedliche Zugänge. Der offizielle IaC-Leitfaden provisioniert eine CF-Organisation und einen technischen Org-Manager über den STACKIT-Provider; anschließend verwaltet der CF-Provider die Runtime-Ressourcen. Die vorhandene JSON-Datei ist deshalb nicht automatisch ein funktionierender `cf login`-Zugang. Der Bootstrap und die sichere Ablage der erzeugten Zugangsdaten bleiben zu planen. [Offizieller CF-IaC-Leitfaden](https://github.com/stackitcloud/terraform-provider-stackit/blob/main/docs/guides/scf_cloudfoundry.md) + +## Lokale Entwicklungsprüfung + +- [x] Reproduzierbare Installation mit Node.js 24.21.0, npm 11.19.0 und `npm ci`. +- [x] Biome-Lint/Format und strikte TypeScript-Prüfung einschließlich Testcode. +- [x] Produktionsbuild von API, gemeinsamen Paketen und React-Oberfläche. +- [x] Fünf Tests: Tenant-Grenze, Rollenabgrenzung, ungültige Identitäten, Health-Vertrag, abgewiesene gefälschte Login-Header. +- [x] HTTP-Smoke-Test: gebaute API, Vite-UI-Dokument und Entwicklungsproxy; Prozesse danach beendet. +- [ ] Visuelle Browser-Abnahme (noch keine funktionale Editoroberfläche). +- [ ] GitHub-CI-Ausführung (Workflow angelegt, noch nicht gepusht/ausgeführt). +- [ ] CF-Deployment (noch keine Cloud-Mutation). + +## IaC-Fortschritt nach der Bestandsaufnahme + +Die bestätigte Umgebung ist lzc-dev/eu01. Ein echter Bootstrap-Plan wurde erfolgreich erstellt und anschließend aus der verschlüsselten Plan-Datei überprüft: **3 Create, 0 Update, 0 Delete**. Geplant sind Bucket, Credential-Gruppe und S3-Zugang bis 2026-12-28. Kein Apply wurde ausgeführt. + +Die erste gemeinsame Planung mit S3-Versionierung schlug fehl, weil der S3-Provider bereits zum Plan-Zeitpunkt gültige Credentials braucht. Die endgültige Struktur verwendet deshalb getrennte Roots `bootstrap`, `backend` (Versionierung) und `platform`. Alle drei sind validiert; sieben Mock-Plan-Tests und drei Tests für Plan-Hash/Kontext/Ablauf bestehen. Die IaC-CI ist entsprechend erweitert. + +Der bereinigte Plattform-Check wurde erneut ausgeführt und endet jetzt erfolgreich: Object Storage meldet nachvollziehbar `enabled: false` mit anstehender IaC-Provisionierung. Dies ist kein Nachweis einer bereits angelegten Umgebung. + +## Erstes Verwaltungs-Backend angewendet + +Seed: 3 Ressourcen erstellt, Seed-Protection: Versionierung aktiviert. Bootstrap-Remote-Backend initialisiert und Plan erfolgreich. S3-Versionierung, Auslesen einer früheren Testobjekt-Version und Hash-verifizierte Sicherung der verschlüsselten Seed-States erfolgreich. Schlüssel verbleiben separat lokal; unabhängige dauerhafte Schlüsselverwahrung und konkurrierender Lock-Test stehen aus. + +## S3-Locking: negatives Integrationsergebnis + +Zwei getrennte OpenTofu-Datenverzeichnisse, derselbe isolierte S3-State-Key: erster Apply hält eine sichtbare `.tflock`, zweiter Plan mit `-lock-timeout=0s` läuft dennoch erfolgreich. Separater Test mit AWS CLI: wiederholtes `put-object --if-none-match '*'` auf ein bestehendes Testobjekt wird nicht mit 412 abgewiesen. Native S3-Sperrwirkung ist damit am getesteten eu01-Endpunkt nicht gegeben. Testobjekte und Versionen wurden entfernt; produktive States wurden nicht verändert. diff --git a/landing-zone-configurator/infra/README.md b/landing-zone-configurator/infra/README.md new file mode 100644 index 0000000..9901878 --- /dev/null +++ b/landing-zone-configurator/infra/README.md @@ -0,0 +1,125 @@ +# Configurator-Infrastruktur + +Alle Dienste und deren Aktivierung werden über IaC verwaltet. Keine manuelle Erstellung im Portal. Operator-Eingaben und sichere Übergabe von Credentials sind Konfiguration, keine alternative Provisionierung. + +## Roots und Reihenfolge + +0. **seed/** und **seed-protection/**: separater Verwaltungs-Bucket im bestehenden Projekt, Zugang und Versionierung; verschlüsselter lokaler Seed-State mit verschlüsselter Remote-Sicherung. +1. **bootstrap/**: aktiviert Object Storage über den ersten Bucket, erstellt State-Bucket, Credential-Gruppe und befristeten S3-Zugang. Verschlüsselter S3-State im Verwaltungs-Bucket. +2. **backend/**: aktiviert Bucket-Versionierung über die STACKIT-S3-API mit den nun existierenden Credentials; eigener verschlüsselter S3-State im Verwaltungs-Bucket. +3. **platform/**: S3-Backend im Bootstrap-Bucket, CF-Organisation und technischer Manager, PostgreSQL mit Datenbank/Migrationsnutzer, Secrets Manager mit Provisionierungsnutzer, Artefakt-Bucket und Model-Serving-Aktivierung/Token. +4. **runtime/** (noch zu implementieren): CF-Spaces, Rollen, gemeinsame Service-Instanzen, eingeschränkter DB-App-Zugang/RLS, Secret-Policies und Artefaktzugriffe. Eigener State; nur benötigte Outputs aus der vorigen Schicht im Speicher übergeben. + +Diese Trennung verhindert, dass der CF-Provider einen noch nicht erzeugten Runtime-Zugang benötigt. Kein allgemeiner `-target`-Workflow und kein manueller Portal-Bootstrap. Weitere Dienste, etwa Observability, folgen ebenfalls als IaC-Ressourcen. + +## Nachgewiesene Aktivierung im Provider + +Der gepinnte STACKIT-Provider `0.114.0` ruft beim Erstellen von `stackit_objectstorage_bucket` zuerst `EnableProject` auf. Für `stackit_modelserving_token` aktiviert er zunächst den regionalen Model-Serving-Service und erstellt anschließend den Token. Beide Aktivierungen sind damit Bestandteil des jeweiligen OpenTofu-Apply. + +Quellen: [Bucket-Create](https://github.com/stackitcloud/terraform-provider-stackit/blob/v0.114.0/stackit/internal/services/objectstorage/bucket/resource.go), [Model-Serving-Create](https://github.com/stackitcloud/terraform-provider-stackit/blob/v0.114.0/stackit/internal/services/modelserving/token/resource.go). + +Für Bucket-Versionierung fehlt in diesem Provider eine eigene Ressource. `aws_s3_bucket_versioning` aus `hashicorp/aws` spricht ausschließlich den STACKIT-S3-Endpunkt an; es werden keine Ressourcen bei AWS angelegt. Tatsächliche API-Kompatibilität und Locking bleiben bis zum ersten Apply/Integrationstest unbestätigt. + +## Werkzeug und lokale Dateien + +OpenTofu **1.12.6**, Node.js **24.21.0**. Provider sind exakt gepinnt, alle drei `.terraform.lock.hcl` werden versioniert. Der Wrapper nutzt standardmäßig `tofu`; alternativ `LZC_TOFU_BIN=/absoluter/pfad/tofu` setzen. + +Aus dem Verzeichnis `landing-zone-configurator/`: + +```sh +node infra/tofu.mjs bootstrap init -backend=false +node infra/tofu.mjs bootstrap validate +node infra/tofu.mjs bootstrap test +node infra/tofu.mjs backend init -backend=false +node infra/tofu.mjs backend validate +node infra/tofu.mjs backend test +node infra/tofu.mjs platform init -backend=false +node infra/tofu.mjs platform validate +node infra/tofu.mjs platform test +``` + +Tests nutzen gemockte Provider; sie legen keine Cloud-Ressourcen an. Bei abweichender globaler Node-Version kann der aus der [App-Anleitung](../app/README.md) bekannte `npm exec`-Präfix verwendet werden. + +Der Wrapper erzeugt root-spezifische Verschlüsselungs-Passphrases unter `../.local//state.passphrase`, Dateirechte 0600. State und gespeicherte Plans sind durch OpenTofu AES-GCM verschlüsselt; Passphrases werden nur über `TF_ENCRYPTION` übergeben. Nicht in tfvars, Git oder CF-Variablen übernehmen. **Vor dem ersten Apply die Schlüssel unabhängig und sicher sichern.** `.gitignore` ersetzt keine Sicherung und verhindert auch keine Betriebssystem-/Cloud-Drive-Synchronisation. + +Bootstrap und Backend verwenden nun den separaten Verwaltungs-Bucket. Nur die Seed-Stufen verbleiben lokal: Sie erstellen den Bucket und dessen Versionierung selbst. `check-management-backend.mjs` sichert ihre verschlüsselten States unter `recovery/` im Bucket und prüft den Download per Hash. Die Schlüssel bleiben separat lokal; ihre dauerhafte unabhängige Hinterlegung im betrieblichen Secret-System/GitHub Environment ist noch offen. Die Kopie im selben Bucket schützt nicht vor Verlust des gesamten Buckets. Seed-Änderungen erfolgen bis zur Betriebsübergabe durch einen einzigen Operator; danach die Sicherung erneuern. + +Der bisherige Bootstrap hatte nur einen Plan, keinen angewendeten State. Daher wurde kein existierender Ressourcen-State migriert. Bei künftigen Backend-Wechseln bestehende States ausdrücklich migrieren und niemals durch `init -reconfigure` verwerfen. + +## Operator-Eingaben und Plan + +Die erste Entwicklungsumgebung **lzc-dev in eu01** ist bestätigt und in der vorhandenen, ignorierten Root-Datei eingetragen. Der erste Backend-Zugang ist mit ungefähr 90 Tagen Laufzeit geplant. Beispiel für die relevanten Eingaben: + +```dotenv +# PROJECT_ID und ORGANISATION_ID sind bereits vorhanden. +REGION=eu01 +NAME_PREFIX=lzc-dev +STATE_CREDENTIAL_EXPIRATION=2026-12-28T00:00:00Z +``` + +Dies ist die bestätigte Entwicklungsumgebung, keine Produktionskonfiguration. Ablaufdatum bewusst wählen und vor Ablauf rotieren. `ORGANISATION_ID` wird nicht zur Erstellung einer neuen Organisation verwendet; dieses Vorhaben arbeitet im bestehenden Projekt. + +Der Wrapper führt die Datei nicht als Shellcode aus und verwendet ausschließlich die zugehörige Service-Account-Datei. Für den Plattform-Root zusätzlich eine geprüfte lokale tfvars-Datei unter `.local/` bereitstellen; siehe [Parameterbeispiel](environments/development.tfvars.example). Keine Platzhalter in einen echten Apply übernehmen. + +```sh +node infra/tofu.mjs bootstrap init +node infra/tofu.mjs bootstrap plan +# Nach Prüfung des angezeigten Plans und unabhängiger Schlüsselsicherung: +node infra/tofu.mjs bootstrap apply + +node infra/tofu.mjs backend init +node infra/tofu.mjs backend plan +node infra/tofu.mjs backend apply + +node infra/tofu.mjs platform init +node infra/tofu.mjs platform plan -var-file=../../.local/platform-inputs.tfvars +# Nach Prüfung des konkreten Plans: +node infra/tofu.mjs platform apply +``` + +`plan` speichert verschlüsselt unter `.local//review.tfplan`; `apply` führt ausschließlich diese Datei aus. Ein gespeicherter Plan wird von OpenTofu ohne weitere interaktive Bestätigung angewendet: Der explizite `apply`-Aufruf ist die Freigabe. Ein Manifest bindet den Plan an seinen Hash und die lokale `.env`-Konfiguration; fehlgeschlagene, verbrauchte oder mehr als 24 Stunden alte Plans werden abgewiesen. Nach Konfigurationsänderungen immer neu planen. `prevent_destroy` blockiert unbeabsichtigte Ersetzung/Löschung wichtiger Ressourcen, ersetzt aber kein Backup. + +Der Wrapper liest für die Plattform die S3-Zugangsdaten aus den verschlüsselten Bootstrap-Outputs in den Arbeitsspeicher und setzt sie nur im Kindprozess. Backend-Konfiguration enthält keine Schlüssel. Die Plattforminitialisierung prüft zuvor die vom Backend-Root angewendete Versionierung. Der Wrapper verwaltet derzeit genau eine Umgebung pro Checkout; weitere Umgebungen benötigen getrennte State-/Schlüsselkontexte und dürfen nicht durch bloßes Umbenennen dieses Checkouts erzeugt werden. S3-Locking mit `use_lockfile` muss gegen STACKIT praktisch getestet werden, bevor mehrere Operatoren oder automatisierte Applies zugelassen werden. + +## Implementierungsstand + +- [x] Bootstrap-/Backend-/Plattform-Ressourcen als Code, Provider-Lockfiles und Verschlüsselung. +- [x] Alle drei Roots validiert; sieben Mock-Plan-Tests sowie drei Tests des Plan-Prüfmechanismus. +- [x] Eigene CI-Jobs ohne Cloud-Credentials vorbereitet. +- [x] Erster echter Bootstrap-Plan für lzc-dev/eu01: 3 Create, 0 Update, 0 Delete. +- [ ] Erstes Apply und S3-Versionierungs-/Locking-/Restore-Test. +- [ ] Runtime-Root, App-Bindings, eingeschränkte DB-Rollen und Secret-/Bucket-Policies. +- [ ] Automatisierte Credential-Rotation samt Bindings und Restore-Runbook. + +Angewendet sind jetzt ausschließlich Verwaltungs-Bucket, Credentials-Gruppe, S3-Zugang und Versionierung. Bootstrap ist am Remote-Backend initialisiert und neu geplant; Plattformdienste sind noch nicht erstellt. + +## CI/CD-Zielbild + +Getrennte Bootstrap-, Plattform- und Release-Pipelines sind in [CI/CD](../docs/cicd.md) geplant. App-Objekte, Routen und App-Bindings gehören dabei dem deklarativen Release-Prozess. Vor Cloud-Applies in CI werden lokale Bootstrap-/Backend-States auf ein unabhängiges dauerhaftes Backend umgestellt und bestehende Verschlüsselungsschlüssel sicher eingebunden. Die Deployment-Pipelines sind noch nicht implementiert. + +## Verwaltungs-Seed ausführen und wiederherstellen + +```sh +node infra/tofu.mjs seed init +node infra/tofu.mjs seed plan +node infra/tofu.mjs seed apply +node infra/tofu.mjs seed-protection init +node infra/tofu.mjs seed-protection plan +node infra/tofu.mjs seed-protection apply +node infra/check-management-backend.mjs +# Nur bei einer erstmaligen Initialisierung ohne vorhandenen lokalen Ressourcen-State: +node infra/tofu.mjs bootstrap init -reconfigure +node infra/tofu.mjs bootstrap plan +``` + +Der Integrationscheck benötigt zusätzlich die AWS CLI, spricht aber ausschließlich den STACKIT-Endpunkt an. Er legt kurzlebige Testobjekt-Versionen an und entfernt nur diese; verschlüsselte Recovery-States bleiben erhalten. Wiederherstellung: passende Seed-State-Version aus `recovery/` herunterladen, lokalen State-Pfad und zugehörigen unabhängig gesicherten Schlüssel wiederherstellen, Seed initialisieren und zunächst einen Plan prüfen. Bei abgelaufenen S3-Zugängen ist eine Wiederherstellung des Zugangs über den Projekt-Service-Account erforderlich. Dieser Recovery-Pfad und Schlüsselrotation müssen vor CI-Freigabe vollständig geübt werden. + +## CI-Übergabe: aktueller Stand + +[Bootstrap-CI](ci/README.md) ist lokal implementiert und mit einem echten Plan geprüft. Der konkurrierende S3-Locking-Test ist **fehlgeschlagen**; `use_lockfile=true` bietet am getesteten Endpunkt keinen nachgewiesenen Schutz. `ci/backend-safety.json` sperrt deshalb Bootstrap-/Backend-/Plattform-Applies im Wrapper und im CI-Einstieg. Die oben gezeigten Apply-Befehle sind bis zur Lösung nicht ausführbar. Seed und Seed-Protection bleiben lokale Einzeloperator-Stufen. + +Reproduktion des isolierten Tests: `node infra/check-state-lock.mjs`. Er nutzt ausschließlich einen eindeutigen `checks/locking/`-Präfix, erstellt keine Cloud-Infrastruktur und entfernt seine Testobjekt-Versionen nach dem Lauf. Verschlüsselung und Versionierung ersetzen kein funktionierendes Locking. + +### Aktualisierte Betriebsfreigabe + +Der Benutzer hat anschließend ausschließlich serialisierte CI-Applies bestätigt. Die vollständige Remote-Apply-Sperre ist dafür im CI-Einstieg aufgehoben; im lokalen Wrapper bleibt sie bestehen. Gemeinsame Concurrency-Gruppe aller mutierenden Workflows: `configurator-lzc-dev-mutation`, laufende Jobs nicht abbrechen. Native S3-Locking-Unterstützung wird weiterhin nicht behauptet. Details und Grenzen: [CI-Betrieb](ci/README.md). diff --git a/landing-zone-configurator/infra/backend/.terraform.lock.hcl b/landing-zone-configurator/infra/backend/.terraform.lock.hcl new file mode 100644 index 0000000..5a93d70 --- /dev/null +++ b/landing-zone-configurator/infra/backend/.terraform.lock.hcl @@ -0,0 +1,39 @@ +# This file is maintained automatically by "tofu init". +# Manual edits may be lost in future updates. + +provider "registry.opentofu.org/hashicorp/aws" { + version = "6.66.0" + constraints = "6.66.0" + hashes = [ + "h1:A2L/03k6fbOyb8Pg6224b9vajh285FZDvlWdgaO2Fkw=", + "h1:HbcFNt1OvD4MBknLg/m0rg7grEaKlaSUbnecF3vxvx0=", + "h1:KEKMmD7afHqcJdw54IFJmXE+59xgh9LJlpXhemEN6H0=", + "h1:LBazejB2S9h3e2kmXI8QDSVA11U2JEW4XSDs1c/S1P0=", + "h1:PWE6gqi8Mg8crSuvxb5KmrR0rFoddB4WbpIdSHRC59U=", + "h1:QLqaOtzPyuv1vdT4NuAgVOGCld9Pk3eWAW9u8z102lI=", + "h1:UHgIgYN6WcVabz8xOAzKX4Iet+XNTyOJvvVMCWLkoCw=", + "h1:YNwRhbk68IhAHqL1x2D9zj0BhY8qqyPLO+zTQOdfMpM=", + "h1:av0q2IS8IaCEvxSg8O9aapxHTyKOeeaXT++IeWZPz2Y=", + "h1:bUp871GCBdQOMB4TC6ND/+k64QjHmYkqqfQPh682kew=", + "h1:dSXxrFpGyQ1obQeGZQw5iA54LBx58z9XZhDZTtV6m+c=", + "h1:dnGqQEhsy2djusN1G6JJgfjkGoQREYEmNpvJD/fZycU=", + "h1:hkkPlR574ztoxUs76kSsS8tnxfjVFqpWZOFlffPqVRQ=", + "h1:jvqzmQntdO80OzKw9ikpCUsQZVK3/mznM1Eie9tIspE=", + "h1:o6+yfy74tYUxiD4jMxB/TzGNlVYvLExG+BLrIH9enS8=", + "zh:005994add988d9acdd291a10641d27ef828246cb4c4c66e82317bd0012d4b79e", + "zh:387e8ba5af9d628e36ca554453d5f664c1649c475fac92e7b3a23bc01e0a7d31", + "zh:45acbc6df5900c070a7db81b8286b54b7366a7d973f9c186a06be4de0b59c229", + "zh:533dc482c05948a005355ad5263ab832ae01eb137a8fceec2b3bf63cb00fad72", + "zh:5d3fe32f05510182a43fca628312ba916cabef61dfc4cabfe7fb84326e509894", + "zh:7d2dc52b6e0a245565ae92fc07926fd9284ae4e1f35b148053b1cc80615a3e66", + "zh:8bbb54f340d98db06d93ef34cab6560fb89a18e8c07af2e7085789701ec7a7ce", + "zh:97904961a5c1813c200ab51a20831dd76c3cc6bf262cb9e34ea9b1afdde05216", + "zh:a26c7bb1ad5ecb691bb2558dde9eb13827fdf326b855ab8e522f74c91a0aa475", + "zh:ab9c77d4427bf7a118b8e666c9bc90cc128662301b27dc2fcbb9653019a07a82", + "zh:d467d3ca59c526dc5ee7e0d6bfa87151dbcfade1fa81e1b48af7a582b4aabe7a", + "zh:dcd3e32777e9db4d679f732f0f73dbb9a51ebe94beef264276b7ea6e8ee2d83b", + "zh:e2ded7927ea0a6941d91e675bc25b89f93eb466ae3e782f47a83b7401f8fc447", + "zh:ecce136f374f456638192e12bfd11ba7561434789b38a9c76d303d5f622f38ea", + "zh:fc60d067c52a1975841eb5dc20faa31763cd1cbbe2f017a1ff9598e7038a7590", + ] +} diff --git a/landing-zone-configurator/infra/backend/1-versioning.tf b/landing-zone-configurator/infra/backend/1-versioning.tf new file mode 100644 index 0000000..d22b9a7 --- /dev/null +++ b/landing-zone-configurator/infra/backend/1-versioning.tf @@ -0,0 +1,10 @@ +# STACKIT supports S3 bucket versioning but exposes no dedicated resource in the pinned provider. +resource "aws_s3_bucket_versioning" "state" { + bucket = var.state_bucket_name + versioning_configuration { + status = "Enabled" + } + lifecycle { + prevent_destroy = true + } +} diff --git a/landing-zone-configurator/infra/backend/README.md b/landing-zone-configurator/infra/backend/README.md new file mode 100644 index 0000000..2d8b7ae --- /dev/null +++ b/landing-zone-configurator/infra/backend/README.md @@ -0,0 +1,9 @@ +# Remote-State-Absicherung + +Dieser Root verwaltet `aws_s3_bucket_versioning` am STACKIT-S3-Endpunkt. Es werden keine AWS-Ressourcen angelegt. Voraussetzung sind der Bucket und S3-Credentials aus dem bereits angewendeten Bootstrap-Root. + +Der echte Erstplan hat gezeigt, dass der S3-Provider existierende Credentials bereits zur Planung benötigt. Deshalb ist diese Konfiguration ein eigener Root und kein `-target`-Sonderlauf innerhalb des Bootstrap-Roots. + +Der Wrapper übergibt Credentials ausschließlich im Prozessumfeld. Dieser Root nutzt einen eigenen State-Key im Verwaltungs-Bucket. Provider-Zugang zum Workload-Bucket und Backend-Zugang zum Verwaltungs-Bucket werden getrennt übergeben. Der Plattform-Root darf erst nach angewendeter Versionierung sein Remote-Backend initialisieren. [Operator-Anleitung](../README.md). + +Dieser Root wurde noch nicht angewendet. Der gleichartige Seed-Protection-Root hat Versionierung am Verwaltungs-Bucket erfolgreich aktiviert; Versionswiederherstellung wurde dort geprüft. Konkurrierende OpenTofu-Zugriffe und vollständiges Disaster Recovery bleiben zu testen. diff --git a/landing-zone-configurator/infra/backend/outputs.tf b/landing-zone-configurator/infra/backend/outputs.tf new file mode 100644 index 0000000..22289b4 --- /dev/null +++ b/landing-zone-configurator/infra/backend/outputs.tf @@ -0,0 +1,9 @@ +output "versioning_enabled" { + description = "Successful IaC application of bucket versioning; not a substitute for a restore test." + value = aws_s3_bucket_versioning.state.versioning_configuration[0].status == "Enabled" +} + +output "state_bucket_name" { + description = "Bucket that was protected, checked against the platform backend." + value = aws_s3_bucket_versioning.state.bucket +} diff --git a/landing-zone-configurator/infra/backend/providers.tf b/landing-zone-configurator/infra/backend/providers.tf new file mode 100644 index 0000000..7d5ba03 --- /dev/null +++ b/landing-zone-configurator/infra/backend/providers.tf @@ -0,0 +1,14 @@ +# Receives already-created STACKIT S3 credentials via the operator process environment. +provider "aws" { + access_key = var.storage_access_key + secret_key = var.storage_secret_key + region = var.region + skip_credentials_validation = true + skip_region_validation = true + skip_requesting_account_id = true + skip_metadata_api_check = true + s3_use_path_style = true + endpoints { + s3 = "https://object.storage.${var.region}.onstackit.cloud" + } +} diff --git a/landing-zone-configurator/infra/backend/terraform.tf b/landing-zone-configurator/infra/backend/terraform.tf new file mode 100644 index 0000000..d8b312a --- /dev/null +++ b/landing-zone-configurator/infra/backend/terraform.tf @@ -0,0 +1,28 @@ +terraform { + required_version = "= 1.12.6" + required_providers { + aws = { + source = "hashicorp/aws" + version = "6.66.0" + } + } + + # Remote state is stored in the separate management bucket. + backend "s3" {} + + encryption { + # The operator wrapper supplies the passphrase via TF_ENCRYPTION, never a tfvars file. + key_provider "pbkdf2" "state" {} + method "aes_gcm" "state" { + keys = key_provider.pbkdf2.state + } + state { + method = method.aes_gcm.state + enforced = true + } + plan { + method = method.aes_gcm.state + enforced = true + } + } +} diff --git a/landing-zone-configurator/infra/backend/tests/backend.tftest.hcl b/landing-zone-configurator/infra/backend/tests/backend.tftest.hcl new file mode 100644 index 0000000..d66fbe6 --- /dev/null +++ b/landing-zone-configurator/infra/backend/tests/backend.tftest.hcl @@ -0,0 +1,16 @@ +mock_provider "aws" {} + +variables { + storage_access_key = "mock-access" + storage_secret_key = "mock-secret" + region = "eu01" + state_bucket_name = "lzc-test-state" +} + +run "versioning_is_required" { + command = plan + assert { + condition = aws_s3_bucket_versioning.state.versioning_configuration[0].status == "Enabled" + error_message = "The remote state bucket must retain object versions." + } +} diff --git a/landing-zone-configurator/infra/backend/variables.tf b/landing-zone-configurator/infra/backend/variables.tf new file mode 100644 index 0000000..e73f292 --- /dev/null +++ b/landing-zone-configurator/infra/backend/variables.tf @@ -0,0 +1,18 @@ +variable "region" { + type = string + description = "STACKIT region of the bootstrap bucket." +} + +variable "state_bucket_name" { + type = string + description = "Existing state bucket from the bootstrap root." +} + +variable "storage_access_key" { + type = string + sensitive = true +} +variable "storage_secret_key" { + type = string + sensitive = true +} diff --git a/landing-zone-configurator/infra/bootstrap/.terraform.lock.hcl b/landing-zone-configurator/infra/bootstrap/.terraform.lock.hcl new file mode 100644 index 0000000..15140f8 --- /dev/null +++ b/landing-zone-configurator/infra/bootstrap/.terraform.lock.hcl @@ -0,0 +1,36 @@ +# This file is maintained automatically by "tofu init". +# Manual edits may be lost in future updates. + +provider "registry.opentofu.org/stackitcloud/stackit" { + version = "0.114.0" + constraints = "0.114.0" + hashes = [ + "h1:1LWQQnyh7olj5+Ceu33qfZrtvwV6oB4OatOT01QhHBQ=", + "h1:3WbnQ683RK3lVPMZ8Kt+hkJAgVVXYUkoetlhZ74/o8A=", + "h1:6wvA9Zh+oe/0dpCyjEakLPeq1MX2Kd84QFQz/o25xtU=", + "h1:BrXRBNOvpOOTIoJitmN4wD6utYcxVcfzk4/zJzKW15A=", + "h1:MTREOQy9s4S+guQpHVAQwZjk9oudo+57vbt7LSvG6jk=", + "h1:Oi6VgqQ3zQVeFjnksXYno9JOyQyYDfulBNCdEgDeimM=", + "h1:Pw89rX8OcLNY06pMMsdZ48ATRHRdc14Hqr7Gz70jfRo=", + "h1:ShJfA+qiqn/Hh4/alQH90yeJiGipB/4CnLiFfkDsJqM=", + "h1:UF2Zbvh5weaMRKhQql1VT7OvfgAllgad7EdhSIwN/pM=", + "h1:ao60KSPxE34NBuNZDYg78p4/ZxXhmbnlh8tMBsu2Omg=", + "h1:g14AMgsC94E7L7RTo0tK6hzcBepik/vcEw1ixONnMHo=", + "h1:oGShI7JYRcemHmAvOp+fwLQCtsrDv9KNBud/6O8JqrU=", + "h1:oQdvYsXY32mfOZv+kJ9uFSorpTj1IWrJeGyYIZcFOf0=", + "zh:096d6a1d716f124e9a7576318ffb2128ae0ac29e7c1f13b1b3c7723b79f760de", + "zh:0dde99e7b343fa01f8eefc378171fb8621bedb20f59157d6cc8e3d46c738105f", + "zh:1d48af034c791b5565a86c4905abd309152bf50c2c67c44e4943a750a3b0b060", + "zh:1f5703f9616cde7a19ae0e4aa987d02d3d04f3c1b5e593de5d3e8c8043b9da9b", + "zh:1f984dd23d3933734238f1e8b3e1ad2cab20c5e58010b62861ca64f9058b220f", + "zh:338244fafd34188194789bd0d0f84702b12c9086901b7e6bc740cc2415a82c95", + "zh:3b52f81ac36371435a9fde9ff4b437d7b4aee0b692081c490bf7aa4fdf4197b6", + "zh:45f281c29dc783023a1b8deb054bc9dc8f559ed450471005afb4b6d44e68ddc8", + "zh:70d41f68d8a522aa10aab708c425e4c7ce158ced023a9a4dd32cfd4775206c1c", + "zh:a1c7d0e7f9f91d95241ba5ecc6b17ff0a0cd90fe2b2343f5d515b551a97341fa", + "zh:ae292b2f3fc479755f3fcd730bdf5f05c283be788a9daba33012439c0084ce4d", + "zh:b41b5b7a899735d2e516eb76a9b2d67cc46b01fa3fc0392b60ca1448184bf470", + "zh:d01cc2b743081d0c1085e0a134c1d011368305f2ba2219167a0e29ff1b66397b", + "zh:fe4e441850590c04cfaf21398020d0460befd490e1cc7aa791a13ceace80fcbf", + ] +} diff --git a/landing-zone-configurator/infra/bootstrap/1-state-storage.tf b/landing-zone-configurator/infra/bootstrap/1-state-storage.tf new file mode 100644 index 0000000..4aa34f2 --- /dev/null +++ b/landing-zone-configurator/infra/bootstrap/1-state-storage.tf @@ -0,0 +1,20 @@ +# Creating the first bucket also enables Object Storage through the STACKIT provider. +resource "stackit_objectstorage_bucket" "state" { + project_id = var.project_id + name = "${var.name_prefix}-state-${substr(var.project_id, 0, 8)}" + lifecycle { + prevent_destroy = true + } +} + +resource "stackit_objectstorage_credentials_group" "state" { + project_id = var.project_id + name = "${var.name_prefix}-state-operators" + depends_on = [stackit_objectstorage_bucket.state] +} + +resource "stackit_objectstorage_credential" "state" { + project_id = var.project_id + credentials_group_id = stackit_objectstorage_credentials_group.state.credentials_group_id + expiration_timestamp = var.state_credential_expiration +} diff --git a/landing-zone-configurator/infra/bootstrap/README.md b/landing-zone-configurator/infra/bootstrap/README.md new file mode 100644 index 0000000..1c2111a --- /dev/null +++ b/landing-zone-configurator/infra/bootstrap/README.md @@ -0,0 +1,11 @@ +# State-Bootstrap + +Dieser Root erstellt drei Ressourcen: State-Bucket, Credential-Gruppe und befristeten S3-Zugang. Die erste Bucket-Erstellung aktiviert Object Storage automatisch im vorhandenen Projekt. + +Dieser Root nutzt den separaten, bereits per Seed-IaC erstellten Verwaltungs-Bucket als verschlüsseltes S3-Backend. Der nachgelagerte [Backend-Root](../backend/README.md) aktiviert die Versionierung mit den dann verfügbaren S3-Credentials. Der Plattform-Root nutzt anschließend den geschützten Bucket. + +Erster echter Plan am 2026-09-29 für lzc-dev/eu01 erfolgreich: **3 anlegen, 0 ändern, 0 löschen**. Geplantes Credential-Ablaufdatum: 2026-12-28. Kein Apply ausgeführt. Der gespeicherte Plan liegt verschlüsselt und Git-ignoriert unter `.local/bootstrap/review.tfplan` und ist nur 24 Stunden verwendbar. + +[Operator-Anleitung](../README.md). Mock-Tests weisen die Struktur nach; Aktivierung, S3-Kompatibilität und Berechtigungen zum Schreiben sind erst durch den anschließenden Integrationstest belegt. + +Aktuell: Remote-Backend initialisiert, echter Plan mit 3 Create erfolgreich; für diesen Root weiterhin kein Apply. diff --git a/landing-zone-configurator/infra/bootstrap/outputs.tf b/landing-zone-configurator/infra/bootstrap/outputs.tf new file mode 100644 index 0000000..d8d7ed3 --- /dev/null +++ b/landing-zone-configurator/infra/bootstrap/outputs.tf @@ -0,0 +1,17 @@ +output "backend" { + description = "Non-secret backend settings; apply the backend root to enable versioning before platform use." + value = { + bucket = stackit_objectstorage_bucket.state.name + region = var.region + endpoint = "https://object.storage.${var.region}.onstackit.cloud" + } +} + +output "backend_credentials" { + description = "Operator-only S3 credentials; pass in memory to the backend, never commit or print them." + sensitive = true + value = { + access_key = stackit_objectstorage_credential.state.access_key + secret_key = stackit_objectstorage_credential.state.secret_access_key + } +} diff --git a/landing-zone-configurator/infra/bootstrap/providers.tf b/landing-zone-configurator/infra/bootstrap/providers.tf new file mode 100644 index 0000000..a259d14 --- /dev/null +++ b/landing-zone-configurator/infra/bootstrap/providers.tf @@ -0,0 +1,3 @@ +provider "stackit" { + default_region = var.region +} diff --git a/landing-zone-configurator/infra/bootstrap/terraform.tf b/landing-zone-configurator/infra/bootstrap/terraform.tf new file mode 100644 index 0000000..440c275 --- /dev/null +++ b/landing-zone-configurator/infra/bootstrap/terraform.tf @@ -0,0 +1,28 @@ +terraform { + required_version = "= 1.12.6" + required_providers { + stackit = { + source = "stackitcloud/stackit" + version = "0.114.0" + } + } + + # Remote state is stored in the separate management bucket. + backend "s3" {} + + encryption { + # The operator wrapper supplies the passphrase via TF_ENCRYPTION, never a tfvars file. + key_provider "pbkdf2" "state" {} + method "aes_gcm" "state" { + keys = key_provider.pbkdf2.state + } + state { + method = method.aes_gcm.state + enforced = true + } + plan { + method = method.aes_gcm.state + enforced = true + } + } +} diff --git a/landing-zone-configurator/infra/bootstrap/tests/bootstrap.tftest.hcl b/landing-zone-configurator/infra/bootstrap/tests/bootstrap.tftest.hcl new file mode 100644 index 0000000..71c9ef5 --- /dev/null +++ b/landing-zone-configurator/infra/bootstrap/tests/bootstrap.tftest.hcl @@ -0,0 +1,31 @@ +mock_provider "stackit" { + mock_resource "stackit_objectstorage_credentials_group" { + defaults = { + credentials_group_id = "00000000-0000-4000-8000-000000000010" + } + } +} + +variables { + project_id = "00000000-0000-4000-8000-000000000001" + region = "eu01" + name_prefix = "lzc-test" + state_credential_expiration = "2027-01-01T00:00:00Z" +} + +run "backend_credential_lifetime" { + command = plan + + assert { + condition = stackit_objectstorage_credential.state.expiration_timestamp == "2027-01-01T00:00:00Z" + error_message = "Backend credentials must have an explicit expiry." + } +} + +run "reject_unsupported_region" { + command = plan + variables { + region = "unsupported" + } + expect_failures = [var.region] +} diff --git a/landing-zone-configurator/infra/bootstrap/variables.tf b/landing-zone-configurator/infra/bootstrap/variables.tf new file mode 100644 index 0000000..bb8e7c5 --- /dev/null +++ b/landing-zone-configurator/infra/bootstrap/variables.tf @@ -0,0 +1,35 @@ +variable "project_id" { + type = string + description = "Existing STACKIT operator project; this root does not create customer projects." + validation { + condition = can(regex("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", var.project_id)) + error_message = "project_id must be a UUID." + } +} + +variable "region" { + type = string + description = "Explicitly selected STACKIT deployment region." + validation { + condition = contains(["eu01", "eu02"], var.region) + error_message = "Use a supported region: eu01 or eu02." + } +} + +variable "name_prefix" { + type = string + description = "Stable environment-specific naming prefix." + validation { + condition = can(regex("^[a-z][a-z0-9-]{2,29}[a-z0-9]$", var.name_prefix)) + error_message = "Use 4-31 lowercase letters, numbers or hyphens, starting with a letter and ending with a letter or number." + } +} + +variable "state_credential_expiration" { + type = string + description = "RFC3339 expiry of the operator backend credential; rotate before this date." + validation { + condition = can(formatdate("YYYY", var.state_credential_expiration)) + error_message = "Supply an explicit RFC3339 expiration timestamp." + } +} diff --git a/landing-zone-configurator/infra/check-management-backend.mjs b/landing-zone-configurator/infra/check-management-backend.mjs new file mode 100644 index 0000000..e724d63 --- /dev/null +++ b/landing-zone-configurator/infra/check-management-backend.mjs @@ -0,0 +1,62 @@ +// Integration check and encrypted seed-state backup. Never uploads encryption keys. +import { execFileSync } from "node:child_process"; +import { randomUUID, createHash } from "node:crypto"; +import { readFileSync, writeFileSync, mkdirSync, rmSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +const infra = dirname(fileURLToPath(import.meta.url)); +const local = resolve(infra, "../.local"); +const binary = process.env.LZC_TOFU_BIN ?? "tofu"; +const baseEnv = Object.fromEntries(Object.entries(process.env).filter(([k]) => !/^(AWS_|TF_|STACKIT_|LZC_STATE_KEY_)/.test(k))); +const seedEnv = { ...baseEnv, TF_DATA_DIR: resolve(local, "seed/data"), + TF_ENCRYPTION: JSON.stringify({ key_provider: { pbkdf2: { state: { + passphrase: readFileSync(resolve(local, "seed/state.passphrase"), "utf8").trim(), + } } } }) }; +function run(command, args, env) { + try { return execFileSync(command, args, { env, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }); } + catch { throw new Error(`Backend check failed: ${command === binary ? "seed output" : args[1]}`); } +} +const outputs = JSON.parse(run(binary, [`-chdir=${resolve(infra, "seed")}`, "output", "-json"], seedEnv)); +const { bucket, endpoint, region } = outputs.backend.value; +const credentials = outputs.backend_credentials.value; +const env = { ...baseEnv, AWS_ACCESS_KEY_ID: credentials.access_key, + AWS_SECRET_ACCESS_KEY: credentials.secret_key, AWS_DEFAULT_REGION: region, AWS_PAGER: "" }; +function s3(operation, args = []) { + const output = run("aws", ["s3api", operation, "--bucket", bucket, "--endpoint-url", endpoint, "--output", "json", ...args], env); + return output.trim() ? JSON.parse(output) : {}; +} +const directory = resolve(local, "backend-check", randomUUID()); +mkdirSync(directory, { recursive: true, mode: 0o700 }); +const key = `checks/${randomUUID()}/restore.txt`; +const versions = []; +try { + if (s3("get-bucket-versioning").Status !== "Enabled") throw new Error("Versioning is not enabled"); + console.log("PASS: bucket versioning enabled"); + for (const value of ["first-version", "second-version"]) { + const path = resolve(directory, "input"); + writeFileSync(path, value, { mode: 0o600 }); + const result = s3("put-object", ["--key", key, "--body", path]); + if (!result.VersionId) throw new Error("Object version ID missing"); + versions.push(result.VersionId); + } + const restored = resolve(directory, "restored"); + s3("get-object", ["--key", key, "--version-id", versions[0], restored]); + if (readFileSync(restored, "utf8") !== "first-version") throw new Error("Version restore mismatch"); + console.log("PASS: previous object version restored and verified"); + for (const root of ["seed", "seed-protection"]) { + const path = resolve(local, root, "terraform.tfstate"); + const contents = readFileSync(path); + const envelope = JSON.parse(contents); + if (!envelope.encrypted_data || !envelope.encryption_version) throw new Error("Refusing to back up unencrypted state"); + const backupKey = `recovery/${root}/terraform.tfstate`; + s3("put-object", ["--key", backupKey, "--body", path]); + const restoredState = resolve(directory, root); + s3("get-object", ["--key", backupKey, restoredState]); + const hash = (data) => createHash("sha256").update(data).digest("hex"); + if (hash(readFileSync(restoredState)) !== hash(contents)) throw new Error("State backup mismatch"); + console.log(`PASS: encrypted ${root} state backup verified (keys remain separate)`); + } +} finally { + for (const version of versions) s3("delete-object", ["--key", key, "--version-id", version]); + rmSync(directory, { recursive: true, force: true }); +} diff --git a/landing-zone-configurator/infra/check-state-lock.mjs b/landing-zone-configurator/infra/check-state-lock.mjs new file mode 100644 index 0000000..8f2b9eb --- /dev/null +++ b/landing-zone-configurator/infra/check-state-lock.mjs @@ -0,0 +1,91 @@ +import { execFileSync, spawn } from "node:child_process"; +import { randomUUID, randomBytes } from "node:crypto"; +import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { setTimeout } from "node:timers/promises"; +const infra = dirname(fileURLToPath(import.meta.url)); +const local = resolve(infra, "../.local"); +const binary = process.env.LZC_TOFU_BIN ?? "tofu"; +const cleanEnv = Object.fromEntries(Object.entries(process.env).filter(([k]) => !/^(AWS_|TF_|STACKIT_|LZC_STATE_KEY_)/.test(k))); +const encryption = (passphrase) => JSON.stringify({key_provider:{pbkdf2:{state:{passphrase}}}}); +function invoke(args, env) { + try { return execFileSync(binary, args, { env, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }); } + catch { throw new Error(`Lock test failed during ${args[1]}`); } +} +const seed = JSON.parse(invoke([`-chdir=${resolve(infra, "seed")}`, "output", "-json"], { + ...cleanEnv, TF_DATA_DIR: resolve(local, "seed/data"), + TF_ENCRYPTION: encryption(readFileSync(resolve(local, "seed/state.passphrase"), "utf8").trim()), +})); +const { bucket, endpoint, region } = seed.backend.value; +const directory = resolve(local, "lock-check", randomUUID()); +mkdirSync(directory, {recursive:true, mode:0o700}); +const prefix = `checks/locking/${randomUUID()}/`; +const backend = { bucket, key: `${prefix}terraform.tfstate`, region, endpoints:{s3:endpoint}, + use_path_style:true, use_lockfile:true, skip_credentials_validation:true, skip_region_validation:true, + skip_requesting_account_id:true, skip_metadata_api_check:true, skip_s3_checksum:true }; +writeFileSync(resolve(directory, "main.tf.json"), JSON.stringify({ + terraform:{required_version:"= 1.12.6", backend:{s3:backend}}, + resource:{terraform_data:{probe:{input:"lock-test",provisioner:[{"local-exec":{command:"node hold.mjs"}}]}}}})); +writeFileSync(resolve(directory,"encryption.tf"), `terraform { + encryption { + key_provider "pbkdf2" "state" {} + method "aes_gcm" "state" { keys = key_provider.pbkdf2.state } + state { + method = method.aes_gcm.state + enforced = true + } + plan { + method = method.aes_gcm.state + enforced = true + } + } +} +`); +writeFileSync(resolve(directory,"hold.mjs"), `import {writeFileSync,existsSync} from 'node:fs';\nwriteFileSync('holding','yes');\nconst start=Date.now();\nconst timer=setInterval(()=>{if(existsSync('release')||Date.now()-start>120000)clearInterval(timer)},100);\n`); +const env = {...cleanEnv, AWS_ACCESS_KEY_ID:seed.backend_credentials.value.access_key, + AWS_SECRET_ACCESS_KEY:seed.backend_credentials.value.secret_key, AWS_DEFAULT_REGION:region, + AWS_PAGER:"", TF_INPUT:"0", TF_ENCRYPTION:encryption(randomBytes(48).toString("base64url"))}; +const firstEnv = {...env,TF_DATA_DIR:resolve(directory,"first")}; +const secondEnv = {...env,TF_DATA_DIR:resolve(directory,"second")}; +let done; +try { + for(const runEnv of [firstEnv,secondEnv]) invoke([`-chdir=${directory}`,"init","-input=false","-no-color"],runEnv); + const first=spawn(binary,[`-chdir=${directory}`,"apply","-auto-approve","-no-color"],{env:firstEnv,stdio:"ignore"}); + done=new Promise((res)=>{first.on("error",()=>res(-1));first.on("exit",res)}); + const deadline=Date.now()+30000; + while(!existsSync(resolve(directory,"holding"))) { + if(Date.now()>deadline || first.exitCode!==null)throw new Error("First writer did not acquire state lock"); + await setTimeout(100); + } + const listing=JSON.parse(execFileSync("aws",["s3api","list-objects-v2","--bucket",bucket,"--endpoint-url",endpoint,"--prefix",prefix,"--output","json"],{env,encoding:"utf8",stdio:["ignore","pipe","pipe"]})); + console.log("Objects while first writer is holding:", (listing.Contents??[]).map(x=>x.Key.slice(prefix.length))); + const conditionalFile=resolve(directory,"conditional-test"); + writeFileSync(conditionalFile,"first"); + const awsPut=["s3api","put-object","--bucket",bucket,"--endpoint-url",endpoint,"--key",`${prefix}conditional-test`,"--body",conditionalFile,"--if-none-match","*","--output","json"]; + execFileSync("aws",awsPut,{env,stdio:["ignore","pipe","pipe"]}); + let conditionalRejected=false; + try { execFileSync("aws",awsPut,{env,stdio:["ignore","pipe","pipe"]}); } + catch(error) {conditionalRejected=/PreconditionFailed|412/.test(String(error.stderr));} + console.log("Conditional S3 overwrite rejected:",conditionalRejected); + let rejected=false; + try {execFileSync(binary,[`-chdir=${directory}`,"plan",`-out=${resolve(directory,"contender.tfplan")}`,"-lock-timeout=0s","-no-color"],{env:secondEnv,stdio:["ignore","pipe","pipe"],timeout:30000});} + catch(error){rejected=/Error acquiring the state lock/.test(String(error.stderr)+String(error.stdout));} + writeFileSync(resolve(local,"lock-verification.json"),JSON.stringify({checkedAt:new Date().toISOString(),bucket,region,conditionalRejected,competingProcessRejected:rejected},null,2),{mode:0o600}); + if(!rejected || !conditionalRejected)throw new Error("Second writer was not rejected specifically by the state lock"); + console.log("PASS: competing OpenTofu process rejected while first writer holds S3 lock"); + writeFileSync(resolve(directory,"release"),"yes"); + if(await done!==0)throw new Error("First writer failed"); + invoke([`-chdir=${directory}`,"plan","-lock-timeout=0s","-no-color"],secondEnv); + console.log("PASS: another process can read encrypted state and acquire lock after release"); +} finally { + writeFileSync(resolve(directory,"release"),"yes"); + if(done)await done; + const aws=(args)=>JSON.parse(execFileSync("aws",["s3api",...args,"--bucket",bucket,"--endpoint-url",endpoint,"--output","json"],{env,encoding:"utf8",stdio:["ignore","pipe","pipe"]})||"{}"); + const result=aws(["list-object-versions","--prefix",prefix]); + for(const item of [...(result.Versions??[]),...(result.DeleteMarkers??[])]) { + if(!item.Key.startsWith(prefix))throw new Error("Unexpected object outside test prefix"); + aws(["delete-object","--key",item.Key,"--version-id",item.VersionId]); + } + rmSync(directory,{recursive:true,force:true}); +} diff --git a/landing-zone-configurator/infra/ci/README.md b/landing-zone-configurator/infra/ci/README.md new file mode 100644 index 0000000..2dd0b5c --- /dev/null +++ b/landing-zone-configurator/infra/ci/README.md @@ -0,0 +1,44 @@ +# Bootstrap-CI + +Implementiert, noch nicht auf GitHub veröffentlicht/ausgeführt. Der Workflow `.github/workflows/configurator-bootstrap.yml` wird manuell auf main gestartet: zuerst Root `bootstrap`, anschließend `backend`. Plan und Apply sind getrennte Jobs; Apply verlangt die Environment-Freigabe und verwendet das verschlüsselte Plan-Artefakt desselben Runs. Re-runs werden abgewiesen: immer einen neuen Workflow-Lauf mit neuem Plan starten. + +## Betriebsentscheidung: ausschließlich serialisierte CI-Applies + +Native S3-Sperren funktionieren im Integrationstest nicht. Der Benutzer hat als Übergang den Betrieb mit genau einem schreibenden CI-Lauf bestätigt. S3 bleibt State-Backend. Der gesamte Workflow einschließlich Plan, Freigabewartezeit und Apply nutzt `concurrency.group: configurator-lzc-dev-mutation` und `cancel-in-progress: false`. + +Alle zukünftigen mutierenden Configurator-Workflows im selben Repository müssen dieselbe Gruppe verwenden. GitHub serialisiert aktive Läufe dieser Gruppe; wartende Läufe bilden keine garantierte FIFO-Warteschlange und können durch spätere Anfragen ersetzt werden. Lokale Remote-Applies bleiben im Operator-Wrapper gesperrt. Der CI-Einstieg erlaubt Apply nur im freigegebenen Workflow auf main. Umgebungsvariablen sind dabei keine unabhängige Authentifizierung: Direkte OpenTofu-Aufrufe durch Inhaber der Credentials oder andere Repositories werden nicht von GitHub-Concurrency erfasst und sind im vereinbarten Betrieb ausgeschlossen. + +`backend-safety.json` dokumentiert diesen Modus, ohne den fehlgeschlagenen S3-Locking-Test als erfolgreich zu markieren. Der lokale CI-Plan-Smoke-Test gegen das echte S3-Backend war erfolgreich, ohne Apply. + +Die ausdrückliche Freigabe für die bezeichneten GitHub-Environment-Secrets liegt vor. Alle drei Environments wurden angelegt und per API geprüft: ausschließlich main, kein Admin-Bypass, erforderliche Freigabe für Apply und Recovery. Fünf Secrets je Plan-/Apply-Environment und vier Recovery-Secrets sind hinterlegt; Werte wurden nicht ausgegeben. Der Workflow ist bislang nur lokal vorbereitet und muss vor dem ersten GitHub-Lauf auf main veröffentlicht werden. + +## Reproduzierbare GitHub-Einrichtung + +`github-environments.json` beschreibt Repository, erlaubten Branch, Reviewer und drei getrennte Environments. `configure-github.mjs` setzt die Konfiguration über GitHub API/CLI, prüft Schutzregeln und lädt Secret-Werte über stdin hoch; keine Werte in Argumenten, Logs oder Dateien im Repository. Die ausdrückliche Freigabe des Secret-Transfers wurde erteilt: + +```sh +node infra/ci/configure-github.mjs +``` + +| Environment | Verwendung | Secrets | +| --- | --- | --- | +| lzc-dev-bootstrap-plan | Plan auf main | Projekt-Service-Account, Management-S3-Zugang, Bootstrap-/Backend-State-Schlüssel | +| lzc-dev-bootstrap-apply | Apply auf main, Freigabe durch lweberru | Gleiche Deployment-Secrets | +| lzc-dev-recovery | Separat geschützter Recovery-Kontext, Freigabe durch lweberru | Management-S3-Zugang und Seed-/Seed-Protection-State-Schlüssel | + +Das vorhandene Projekt-Service-Account-Recht ist breiter als eine reine Plan-Leserolle. Der Plan-Job ist deshalb auf vertrauenswürdigen main-Code beschränkt. Eine separate minimal berechtigte Plan-Identität ist ein weiterer Härtungsschritt. Die Apply-Freigabe verhindert keinen missbräuchlichen Code auf main mit den Plan-Credentials; Branch-/PR-Reviews bleiben erforderlich. + +GitHub-Secrets sind nicht per API im Klartext exportierbar. Ihre Hinterlegung ersetzt kein unabhängig abrufbares Recovery-Backup der Schlüssel. Ein Recovery-Workflow und eine externe Schlüsselverwahrung müssen vor Betriebsfreigabe ergänzt und getestet werden. Seed-Schlüssel gehören nicht in normale Deployment-Jobs. + +## Planbindung und Credentials + +`run.mjs` liest ausschließlich explizite CI-Eingaben und bestehende Schlüssel. Es benötigt keine lokalen Seed-States oder Operator-env-Dateien. Credentials werden nur im Jobprozess beziehungsweise in einer restriktiven temporären Service-Account-Datei genutzt, die im finally-Block entfernt wird. Der CI-Job lädt ausschließlich Plan, Manifest und bereinigte Review-Zusammenfassung hoch. + +Der Apply-Prüfmechanismus bindet Plan-Hash, Repository, Commit, Workflow-Lauf, Root, Umgebung, Eingaben sowie Terraform-Dateien und Provider-Lockfiles. Die Gültigkeit beträgt maximal 24 Stunden. OpenTofu prüft zusätzlich den State-Stand. Fehlerhafte Läufe werden nicht automatisch wiederholt. + +## Nachweise + +- Lokaler CI-Planpfad gegen Management-S3: erfolgreich, drei geplante Bootstrap-Ressourcen. +- Beide Workflow-Dateien mit actionlint 1.7.7 validiert. +- Zwölf Node-Tests für Planbindung, Schlüsselhaltung und Apply-Sperre erfolgreich. +- Kein GitHub-Workflow gestartet und kein Plattform-Apply durchgeführt. diff --git a/landing-zone-configurator/infra/ci/backend-safety.json b/landing-zone-configurator/infra/ci/backend-safety.json new file mode 100644 index 0000000..74c8a2f --- /dev/null +++ b/landing-zone-configurator/infra/ci/backend-safety.json @@ -0,0 +1,8 @@ +{ + "environment": "lzc-dev", + "region": "eu01", + "s3LockingVerified": false, + "reason": "User-approved interim operation: S3 remains the backend; only serialized GitHub Actions may apply remote states. Local remote applies remain disabled. Native S3 locking is not verified.", + "mode": "github-actions-single-writer", + "concurrencyGroup": "configurator-lzc-dev-mutation" +} diff --git a/landing-zone-configurator/infra/ci/configure-github.mjs b/landing-zone-configurator/infra/ci/configure-github.mjs new file mode 100644 index 0000000..27aaf8c --- /dev/null +++ b/landing-zone-configurator/infra/ci/configure-github.mjs @@ -0,0 +1,58 @@ +// Operator entrypoint: idempotent environment configuration and encrypted secret upload via gh. +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { parseEnv } from "node:util"; +const here=dirname(fileURLToPath(import.meta.url)); +const infra=resolve(here,".."); +const local=resolve(infra,"../.local"); +const repoPath=resolve(infra,"../.."); +const spec=JSON.parse(readFileSync(resolve(here,"github-environments.json"),"utf8")); +if(spec.repository!=="stackitcloud/stackit-landing-zone")throw new Error("Unexpected secret destination"); +function gh(args,input) { + try {return execFileSync("gh",args,{input,encoding:"utf8",stdio:["pipe","pipe","pipe"]});} + catch {throw new Error(`GitHub operation failed (${args[0]} ${args[1]}); no secret output retained`);} +} +const api=(path,method="GET",body)=>JSON.parse(gh(["api",path,"--method",method,...(body?["--input","-"]:[])],body?JSON.stringify(body):undefined)||"{}"); +const repository=api(`repos/${spec.repository}`); +if(!repository.permissions?.admin)throw new Error("Repository administration permission required"); +const reviewer=api(`users/${spec.reviewer}`); +const config=parseEnv(readFileSync(resolve(repoPath,"landing-zone-configurator.env"),"utf8")); +const env={...process.env,TF_DATA_DIR:resolve(local,"seed/data"),TF_ENCRYPTION:JSON.stringify({key_provider:{pbkdf2:{state:{passphrase:readFileSync(resolve(local,"seed/state.passphrase"),"utf8").trim()}}}})}; +let outputs; +try {outputs=JSON.parse(execFileSync(process.env.LZC_TOFU_BIN??"tofu",[`-chdir=${resolve(infra,"seed")}`,"output","-json"],{env,encoding:"utf8",stdio:["ignore","pipe","pipe"]}));} +catch {throw new Error("Cannot read encrypted seed outputs");} +const sharedSecrets={ + LZC_MANAGEMENT_ACCESS_KEY:outputs.backend_credentials.value.access_key, + LZC_MANAGEMENT_SECRET_KEY:outputs.backend_credentials.value.secret_key, +}; +const deploySecrets={...sharedSecrets, + LZC_SERVICE_ACCOUNT_KEY:readFileSync(resolve(repoPath,"landing-zone-configurator-credentials.json"),"utf8"), + LZC_STATE_KEY_BOOTSTRAP:readFileSync(resolve(local,"bootstrap/state.passphrase"),"utf8").trim(), + LZC_STATE_KEY_BACKEND:readFileSync(resolve(local,"backend/state.passphrase"),"utf8").trim(), +}; +const recoverySecrets={...sharedSecrets, + LZC_STATE_KEY_SEED:readFileSync(resolve(local,"seed/state.passphrase"),"utf8").trim(), + LZC_STATE_KEY_SEED_PROTECTION:readFileSync(resolve(local,"seed-protection/state.passphrase"),"utf8").trim(), +}; +const variables={LZC_PROJECT_ID:config.PROJECT_ID,LZC_REGION:config.REGION,LZC_NAME_PREFIX:config.NAME_PREFIX, + LZC_MANAGEMENT_BUCKET:outputs.backend.value.bucket,LZC_CREDENTIAL_EXPIRATION:config.STATE_CREDENTIAL_EXPIRATION}; +if(Object.values(variables).some(v=>!v))throw new Error("Missing environment configuration"); +for(const target of spec.environments) { + const path=`repos/${spec.repository}/environments/${target.name}`; + api(path,"PUT",{wait_timer:0,prevent_self_review:false,can_admins_bypass:false, + reviewers:target.review?[{type:"User",id:reviewer.id}]:[], + deployment_branch_policy:{protected_branches:false,custom_branch_policies:true}}); + const policies=api(`${path}/deployment-branch-policies`).branch_policies; + if(policies.some(p=>p.name!==spec.branch||p.type!=="branch"))throw new Error("Unexpected existing deployment policy; inspect before uploading secrets"); + if(!policies.some(p=>p.name===spec.branch&&p.type==="branch"))api(`${path}/deployment-branch-policies`,"POST",{name:spec.branch,type:"branch"}); + const checked=api(path); + if(checked.deployment_branch_policy?.custom_branch_policies!==true)throw new Error("Environment branch protection missing"); + if(target.review&&!checked.protection_rules.some(r=>r.type==="required_reviewers"&&r.reviewers.some(x=>x.reviewer.id===reviewer.id)))throw new Error("Required reviewer missing"); + for(const [name,value] of Object.entries(variables))gh(["variable","set",name,"--repo",spec.repository,"--env",target.name],value); + for(const [name,value] of Object.entries(target.name.endsWith("recovery")?recoverySecrets:deploySecrets)) { + gh(["secret","set",name,"--repo",spec.repository,"--env",target.name],value); + } + console.log(`Configured ${target.name}: main-only, review=${target.review}; secret values not displayed.`); +} diff --git a/landing-zone-configurator/infra/ci/context.mjs b/landing-zone-configurator/infra/ci/context.mjs new file mode 100644 index 0000000..820e39a --- /dev/null +++ b/landing-zone-configurator/infra/ci/context.mjs @@ -0,0 +1,43 @@ +import { createHash } from "node:crypto"; +export const digest = (value) => createHash("sha256").update(value).digest("hex"); +export function deploymentInputs(env) { + const required = (name) => { + if (!env[name]?.trim()) throw new Error(`Missing ${name}`); + return env[name].trim(); + }; + if (required("GITHUB_REPOSITORY") !== "stackitcloud/stackit-landing-zone") throw new Error("Unexpected repository"); + const root = required("LZC_ROOT"); + if (!["bootstrap", "backend"].includes(root)) throw new Error("Unsupported CI root"); + if (required("LZC_ENVIRONMENT") !== "lzc-dev") throw new Error("Unsupported environment"); + if (required("GITHUB_REF") !== "refs/heads/main") throw new Error("Deployment requires main"); + if (required("GITHUB_EVENT_NAME") !== "workflow_dispatch") throw new Error("Deployment requires manual dispatch"); + if (required("GITHUB_RUN_ATTEMPT") !== "1") throw new Error("Start a new workflow run instead of retrying a previous plan"); + const commit = required("GITHUB_SHA"); + if (!/^[a-f0-9]{40}$/.test(commit)) throw new Error("Invalid commit"); + const project = required("LZC_PROJECT_ID"); + if (!/^[a-f0-9-]{36}$/.test(project)) throw new Error("Invalid project ID"); + const region = required("LZC_REGION"); + if (region !== "eu01") throw new Error("Unexpected region"); + const prefix = required("LZC_NAME_PREFIX"); + if (prefix !== "lzc-dev") throw new Error("Unexpected prefix"); + const bucket = required("LZC_MANAGEMENT_BUCKET"); + if (bucket !== `${prefix}-management-${project.slice(0,8)}`) throw new Error("Management bucket does not match project"); + const expiration = required("LZC_CREDENTIAL_EXPIRATION"); + if (!Number.isFinite(Date.parse(expiration))) throw new Error("Invalid expiration"); + return { root, environment:"lzc-dev", project, region, prefix, bucket, expiration, + commit, repository:required("GITHUB_REPOSITORY"), run:required("GITHUB_RUN_ID"), attempt:"1" }; +} +export function planContext(inputs, sourceDigest) { + return digest(JSON.stringify({ inputs, sourceDigest })); +} + +export function assertApplyExecution(safety, env) { + if (safety.mode !== "github-actions-single-writer" || + safety.concurrencyGroup !== "configurator-lzc-dev-mutation") { + throw new Error("Apply requires the approved single-writer configuration"); + } + if (env.GITHUB_ACTIONS !== "true" || + env.GITHUB_WORKFLOW_REF !== "stackitcloud/stackit-landing-zone/.github/workflows/configurator-bootstrap.yml@refs/heads/main") { + throw new Error("Remote apply is restricted to the serialized GitHub Actions workflow"); + } +} diff --git a/landing-zone-configurator/infra/ci/context.test.mjs b/landing-zone-configurator/infra/ci/context.test.mjs new file mode 100644 index 0000000..534e641 --- /dev/null +++ b/landing-zone-configurator/infra/ci/context.test.mjs @@ -0,0 +1,40 @@ +import test from "node:test"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import assert from "node:assert/strict"; +import { deploymentInputs, planContext, assertApplyExecution } from "./context.mjs"; +const env={LZC_ROOT:"bootstrap",LZC_ENVIRONMENT:"lzc-dev",GITHUB_REF:"refs/heads/main",GITHUB_EVENT_NAME:"workflow_dispatch", + GITHUB_RUN_ATTEMPT:"1",GITHUB_SHA:"a".repeat(40),LZC_PROJECT_ID:"00000000-0000-4000-8000-000000000001", + LZC_REGION:"eu01",LZC_NAME_PREFIX:"lzc-dev",LZC_MANAGEMENT_BUCKET:"lzc-dev-management-00000000", + LZC_CREDENTIAL_EXPIRATION:"2026-12-28T00:00:00Z",GITHUB_REPOSITORY:"stackitcloud/stackit-landing-zone",GITHUB_RUN_ID:"123"}; +test("trusted manual main context accepted",()=>assert.equal(deploymentInputs(env).root,"bootstrap")); +test("reject untrusted source, retry and wrong backend",()=>{ + for(const patch of [{GITHUB_REF:"refs/pull/1/merge"},{GITHUB_EVENT_NAME:"pull_request_target"}, + {GITHUB_REPOSITORY:"other/repository"},{GITHUB_RUN_ATTEMPT:"2"},{LZC_ROOT:"seed"},{LZC_ENVIRONMENT:"production"},{LZC_MANAGEMENT_BUCKET:"other"}]) { + assert.throws(()=>deploymentInputs({...env,...patch})); + } +}); +test("plan context binds inputs, root, commit, run and source files",()=>{ + const inputs=deploymentInputs(env); + const initial=planContext(inputs,"source"); + for(const patch of [{root:"backend"},{commit:"b".repeat(40)},{run:"124"},{expiration:"2027-01-01T00:00:00Z"}]) { + assert.notEqual(planContext({...inputs,...patch},"source"),initial); + } + assert.notEqual(planContext(inputs,"changed-lockfile"),initial); +}); + +test("local invocation cannot apply even with a valid plan context",()=>{ + const result=spawnSync(process.execPath,[fileURLToPath(new URL("./run.mjs",import.meta.url)),"apply"],{env:{...process.env,...env,GITHUB_ACTIONS:"false"},encoding:"utf8"}); + assert.notEqual(result.status,0); + assert.match(result.stderr,/Remote apply is restricted to the serialized GitHub Actions workflow/); +}); + +test("single-writer apply requires approved workflow identity",()=>{ + const safety={mode:"github-actions-single-writer",concurrencyGroup:"configurator-lzc-dev-mutation"}; + const trusted={GITHUB_ACTIONS:"true",GITHUB_WORKFLOW_REF:"stackitcloud/stackit-landing-zone/.github/workflows/configurator-bootstrap.yml@refs/heads/main"}; + assert.doesNotThrow(()=>assertApplyExecution(safety,trusted)); + for(const patch of [{GITHUB_ACTIONS:"false"},{GITHUB_WORKFLOW_REF:"other/workflow@refs/heads/main"}]) { + assert.throws(()=>assertApplyExecution(safety,{...trusted,...patch})); + } + assert.throws(()=>assertApplyExecution({...safety,concurrencyGroup:"other"},trusted)); +}); diff --git a/landing-zone-configurator/infra/ci/github-environments.json b/landing-zone-configurator/infra/ci/github-environments.json new file mode 100644 index 0000000..8af926a --- /dev/null +++ b/landing-zone-configurator/infra/ci/github-environments.json @@ -0,0 +1,10 @@ +{ + "repository": "stackitcloud/stackit-landing-zone", + "branch": "main", + "reviewer": "lweberru", + "environments": [ + { "name": "lzc-dev-bootstrap-plan", "review": false }, + { "name": "lzc-dev-bootstrap-apply", "review": true }, + { "name": "lzc-dev-recovery", "review": true } + ] +} diff --git a/landing-zone-configurator/infra/ci/run.mjs b/landing-zone-configurator/infra/ci/run.mjs new file mode 100644 index 0000000..1f9fef7 --- /dev/null +++ b/landing-zone-configurator/infra/ci/run.mjs @@ -0,0 +1,96 @@ +// CI-only entrypoint. No local .env, seed output, state or encryption key is required. +import { execFileSync } from "node:child_process"; +import { readFileSync, writeFileSync, mkdirSync, rmSync, readdirSync, appendFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { deploymentInputs, digest, planContext, assertApplyExecution } from "./context.mjs"; +import { isApplicablePlan } from "../plan-guard.mjs"; +const infra = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const [command, ...extra] = process.argv.slice(2); +if (!["plan", "apply"].includes(command) || extra.length) throw new Error("Usage: node infra/ci/run.mjs plan|apply"); +const inputs = deploymentInputs(process.env); +const safety = JSON.parse(readFileSync(resolve(infra,"ci/backend-safety.json"),"utf8")); +if(command === "apply") assertApplyExecution(safety, process.env); +const binary = process.env.LZC_TOFU_BIN ?? "tofu"; +const temporary = resolve(process.env.RUNNER_TEMP ?? infra, `lzc-ci-${inputs.run}-${command}`); +const artifact = resolve(infra,"../.local/ci-plan"); +process.umask(0o077); +mkdirSync(temporary,{recursive:true,mode:0o700}); +mkdirSync(artifact,{recursive:true,mode:0o700}); +const cleanEnv = Object.fromEntries(Object.entries(process.env).filter(([k]) => !/^(AWS_|TF_|STACKIT_|LZC_)/.test(k))); +const secret = (name) => { if(!process.env[name])throw new Error(`Missing ${name}`); return process.env[name]; }; +const credentialsPath=resolve(temporary,"service-account.json"); +const encryption = (root) => { + const passphrase=secret(`LZC_STATE_KEY_${root.toUpperCase()}`).trim(); + if(passphrase.length<32)throw new Error("Invalid encryption key"); + return JSON.stringify({key_provider:{pbkdf2:{state:{passphrase}}}}); +}; +const environment = (root) => ({...cleanEnv, TF_DATA_DIR:resolve(temporary,root), TF_INPUT:"0", TF_ENCRYPTION:encryption(root), + AWS_ACCESS_KEY_ID:secret("LZC_MANAGEMENT_ACCESS_KEY"), AWS_SECRET_ACCESS_KEY:secret("LZC_MANAGEMENT_SECRET_KEY"), + STACKIT_SERVICE_ACCOUNT_KEY_PATH:credentialsPath, TF_VAR_project_id:inputs.project, TF_VAR_region:inputs.region, + TF_VAR_name_prefix:inputs.prefix, TF_VAR_state_credential_expiration:inputs.expiration }); +function tofu(root,args,env) { + try { return execFileSync(binary,[`-chdir=${resolve(infra,root)}`,...args],{env,encoding:"utf8",stdio:["ignore","pipe","pipe"],maxBuffer:16*1024*1024}); } + catch { throw new Error(`OpenTofu ${root}/${args[0]} failed; raw output withheld to protect credentials`); } +} +function initialize(root) { + const env=environment(root); + const backend={bucket:inputs.bucket,key:`configurator/${inputs.prefix}/${root}/terraform.tfstate`,region:inputs.region, + endpoints:{s3:`https://object.storage.${inputs.region}.onstackit.cloud`},use_path_style:true,use_lockfile:true, + skip_credentials_validation:true,skip_region_validation:true,skip_requesting_account_id:true,skip_metadata_api_check:true,skip_s3_checksum:true}; + const file=resolve(temporary,`${root}.backend.hcl`); + writeFileSync(file,Object.entries(backend).map(([k,v])=>`${k} = ${JSON.stringify(v)}`).join("\n")); + tofu(root,["init",`-backend-config=${file}`,"-lockfile=readonly","-input=false","-no-color"],env); + return env; +} +function sourceDigest() { + const pieces=[]; + for(const root of ["bootstrap","backend"]) { + for(const file of readdirSync(resolve(infra,root)).filter(f=>f.endsWith(".tf")||f===".terraform.lock.hcl").sort()) { + pieces.push(`${root}/${file}:${digest(readFileSync(resolve(infra,root,file)))}`); + } + } + return digest(pieces.join("\n")); +} +try { + const version=JSON.parse(execFileSync(binary,["version","-json"],{encoding:"utf8"})); + if(version.terraform_version!=="1.12.6")throw new Error("OpenTofu 1.12.6 required"); + const credentialJson=secret("LZC_SERVICE_ACCOUNT_KEY"); + try { JSON.parse(credentialJson); } catch { throw new Error("Invalid service-account JSON"); } + writeFileSync(credentialsPath,credentialJson,{mode:0o600}); + const env=initialize(inputs.root); + if(inputs.root==="backend") { + const bootstrapEnv=initialize("bootstrap"); + const outputs=JSON.parse(tofu("bootstrap",["output","-json"],bootstrapEnv)); + if(outputs.backend?.value?.region!==inputs.region)throw new Error("Bootstrap region mismatch"); + env.TF_VAR_state_bucket_name=outputs.backend.value.bucket; + env.TF_VAR_storage_access_key=outputs.backend_credentials.value.access_key; + env.TF_VAR_storage_secret_key=outputs.backend_credentials.value.secret_key; + } + const contextHash=planContext(inputs,sourceDigest()); + const plan=resolve(artifact,"review.tfplan"); + const manifest=resolve(artifact,"review.json"); + if(command==="plan") { + writeFileSync(manifest,JSON.stringify({status:"planning"})); + tofu(inputs.root,["plan",`-out=${plan}`,"-input=false","-lock-timeout=60s","-no-color"],env); + const envelope=JSON.parse(readFileSync(plan,"utf8")); + if(!envelope.encrypted_data||!envelope.encryption_version)throw new Error("Refusing an unencrypted plan artifact"); + const details=JSON.parse(tofu(inputs.root,["show","-json",plan],env)); + const summary=(details.resource_changes??[]).map(r=>({address:r.address,actions:r.change.actions})); + const review={status:"ready",contextHash,planHash:digest(readFileSync(plan)),createdAt:new Date().toISOString(),...inputs}; + writeFileSync(manifest,JSON.stringify(review,null,2)); + const text=`## ${inputs.environment}: ${inputs.root}\nCommit: ${inputs.commit}\n\n${summary.map(r=>`- ${r.address}: ${r.actions.join(", ")}`).join("\n")}\n\nPlan SHA-256: ${review.planHash}\n`; + writeFileSync(resolve(artifact,"summary.md"),text); + if(process.env.GITHUB_STEP_SUMMARY)appendFileSync(process.env.GITHUB_STEP_SUMMARY,text); + console.log(text); + } else { + const review=JSON.parse(readFileSync(manifest,"utf8")); + if(!isApplicablePlan(review,contextHash,digest(readFileSync(plan))))throw new Error("Plan mismatch or expiry; start a fresh workflow run"); + // Saved plan applies exactly the reviewed actions. No fresh plan is generated here. + writeFileSync(manifest,JSON.stringify({...review,status:"consumed"})); + tofu(inputs.root,["apply","-lock-timeout=60s","-no-color",plan],env); + console.log(`Applied reviewed ${inputs.root} plan successfully.`); + } +} finally { + rmSync(temporary,{recursive:true,force:true}); +} diff --git a/landing-zone-configurator/infra/environments/README.md b/landing-zone-configurator/infra/environments/README.md new file mode 100644 index 0000000..d7f635f --- /dev/null +++ b/landing-zone-configurator/infra/environments/README.md @@ -0,0 +1,3 @@ +# Umgebungen + +Hier später secretfreie Parameter für getrennte Entwicklungs-, Staging- und Produktionsumgebungen ablegen. Ein genanntes Betreiberprojekt bedeutet noch nicht, dass alle Umgebungen darin gemeinsam betrieben werden sollen. Zuordnung offen. diff --git a/landing-zone-configurator/infra/environments/development.tfvars.example b/landing-zone-configurator/infra/environments/development.tfvars.example new file mode 100644 index 0000000..6cfe221 --- /dev/null +++ b/landing-zone-configurator/infra/environments/development.tfvars.example @@ -0,0 +1,16 @@ +# Template only: replace catalog IDs and verified egress CIDRs before a real plan. +# Project, region and prefix come from the ignored operator env file. +cf_platform_id = "00000000-0000-4000-8000-000000000001" +cf_quota_id = "00000000-0000-4000-8000-000000000002" + +# RFC 5737 documentation address; not a real CF egress address. +service_access_cidrs = ["192.0.2.10/32"] + +database = { + flavor_id = "REPLACE_FROM_CATALOG" + version = "17" + storage_class = "REPLACE_FROM_CATALOG" + storage_size_gb = 5 + backup_schedule = "0 2 * * *" + retention_days = 32 +} diff --git a/landing-zone-configurator/infra/modules/README.md b/landing-zone-configurator/infra/modules/README.md new file mode 100644 index 0000000..8df34e3 --- /dev/null +++ b/landing-zone-configurator/infra/modules/README.md @@ -0,0 +1,3 @@ +# Plattformmodule + +Nur Module für Configurator-Infrastruktur. Accelerator-Module unter dem Root-`src/modules/` bleiben unabhängig. diff --git a/landing-zone-configurator/infra/plan-guard.mjs b/landing-zone-configurator/infra/plan-guard.mjs new file mode 100644 index 0000000..d7de232 --- /dev/null +++ b/landing-zone-configurator/infra/plan-guard.mjs @@ -0,0 +1,6 @@ +export function isApplicablePlan(manifest, contextHash, planHash, now = Date.now()) { + if (!manifest || manifest.status !== "ready") return false; + if (manifest.contextHash !== contextHash || manifest.planHash !== planHash) return false; + const created = Date.parse(manifest.createdAt); + return Number.isFinite(created) && created <= now && now - created <= 24 * 60 * 60 * 1000; +} diff --git a/landing-zone-configurator/infra/plan-guard.test.mjs b/landing-zone-configurator/infra/plan-guard.test.mjs new file mode 100644 index 0000000..3ce1b81 --- /dev/null +++ b/landing-zone-configurator/infra/plan-guard.test.mjs @@ -0,0 +1,23 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { isApplicablePlan } from "./plan-guard.mjs"; + +const now = Date.parse("2026-09-29T12:00:00Z"); +const manifest = { status: "ready", contextHash: "context", planHash: "plan", createdAt: new Date(now).toISOString() }; + +test("only a matching reviewed plan can be applied", () => { + assert.equal(isApplicablePlan(manifest, "context", "plan", now), true); + assert.equal(isApplicablePlan(manifest, "other-context", "plan", now), false); + assert.equal(isApplicablePlan(manifest, "context", "changed-plan", now), false); +}); +test("failed, missing and consumed plans cannot be retried blindly", () => { + for (const status of ["planning", "failed", "consumed"]) { + assert.equal(isApplicablePlan({ ...manifest, status }, "context", "plan", now), false); + } + assert.equal(isApplicablePlan(null, "context", "plan", now), false); +}); +test("invalid, future and expired timestamps fail closed", () => { + for (const createdAt of ["invalid", new Date(now + 1).toISOString(), new Date(now - 86400001).toISOString()]) { + assert.equal(isApplicablePlan({ ...manifest, createdAt }, "context", "plan", now), false); + } +}); diff --git a/landing-zone-configurator/infra/platform/.terraform.lock.hcl b/landing-zone-configurator/infra/platform/.terraform.lock.hcl new file mode 100644 index 0000000..15140f8 --- /dev/null +++ b/landing-zone-configurator/infra/platform/.terraform.lock.hcl @@ -0,0 +1,36 @@ +# This file is maintained automatically by "tofu init". +# Manual edits may be lost in future updates. + +provider "registry.opentofu.org/stackitcloud/stackit" { + version = "0.114.0" + constraints = "0.114.0" + hashes = [ + "h1:1LWQQnyh7olj5+Ceu33qfZrtvwV6oB4OatOT01QhHBQ=", + "h1:3WbnQ683RK3lVPMZ8Kt+hkJAgVVXYUkoetlhZ74/o8A=", + "h1:6wvA9Zh+oe/0dpCyjEakLPeq1MX2Kd84QFQz/o25xtU=", + "h1:BrXRBNOvpOOTIoJitmN4wD6utYcxVcfzk4/zJzKW15A=", + "h1:MTREOQy9s4S+guQpHVAQwZjk9oudo+57vbt7LSvG6jk=", + "h1:Oi6VgqQ3zQVeFjnksXYno9JOyQyYDfulBNCdEgDeimM=", + "h1:Pw89rX8OcLNY06pMMsdZ48ATRHRdc14Hqr7Gz70jfRo=", + "h1:ShJfA+qiqn/Hh4/alQH90yeJiGipB/4CnLiFfkDsJqM=", + "h1:UF2Zbvh5weaMRKhQql1VT7OvfgAllgad7EdhSIwN/pM=", + "h1:ao60KSPxE34NBuNZDYg78p4/ZxXhmbnlh8tMBsu2Omg=", + "h1:g14AMgsC94E7L7RTo0tK6hzcBepik/vcEw1ixONnMHo=", + "h1:oGShI7JYRcemHmAvOp+fwLQCtsrDv9KNBud/6O8JqrU=", + "h1:oQdvYsXY32mfOZv+kJ9uFSorpTj1IWrJeGyYIZcFOf0=", + "zh:096d6a1d716f124e9a7576318ffb2128ae0ac29e7c1f13b1b3c7723b79f760de", + "zh:0dde99e7b343fa01f8eefc378171fb8621bedb20f59157d6cc8e3d46c738105f", + "zh:1d48af034c791b5565a86c4905abd309152bf50c2c67c44e4943a750a3b0b060", + "zh:1f5703f9616cde7a19ae0e4aa987d02d3d04f3c1b5e593de5d3e8c8043b9da9b", + "zh:1f984dd23d3933734238f1e8b3e1ad2cab20c5e58010b62861ca64f9058b220f", + "zh:338244fafd34188194789bd0d0f84702b12c9086901b7e6bc740cc2415a82c95", + "zh:3b52f81ac36371435a9fde9ff4b437d7b4aee0b692081c490bf7aa4fdf4197b6", + "zh:45f281c29dc783023a1b8deb054bc9dc8f559ed450471005afb4b6d44e68ddc8", + "zh:70d41f68d8a522aa10aab708c425e4c7ce158ced023a9a4dd32cfd4775206c1c", + "zh:a1c7d0e7f9f91d95241ba5ecc6b17ff0a0cd90fe2b2343f5d515b551a97341fa", + "zh:ae292b2f3fc479755f3fcd730bdf5f05c283be788a9daba33012439c0084ce4d", + "zh:b41b5b7a899735d2e516eb76a9b2d67cc46b01fa3fc0392b60ca1448184bf470", + "zh:d01cc2b743081d0c1085e0a134c1d011368305f2ba2219167a0e29ff1b66397b", + "zh:fe4e441850590c04cfaf21398020d0460befd490e1cc7aa791a13ceace80fcbf", + ] +} diff --git a/landing-zone-configurator/infra/platform/1-cloud-foundry.tf b/landing-zone-configurator/infra/platform/1-cloud-foundry.tf new file mode 100644 index 0000000..d36a0c8 --- /dev/null +++ b/landing-zone-configurator/infra/platform/1-cloud-foundry.tf @@ -0,0 +1,19 @@ +resource "stackit_scf_organization" "configurator" { + project_id = var.project_id + name = var.name_prefix + platform_id = var.cf_platform_id + quota_id = var.cf_quota_id + lifecycle { + prevent_destroy = true + } +} + +resource "stackit_scf_organization_manager" "configurator" { + project_id = var.project_id + org_id = stackit_scf_organization.configurator.org_id +} + +data "stackit_scf_platform" "configurator" { + project_id = var.project_id + platform_id = var.cf_platform_id +} diff --git a/landing-zone-configurator/infra/platform/2-database.tf b/landing-zone-configurator/infra/platform/2-database.tf new file mode 100644 index 0000000..3529230 --- /dev/null +++ b/landing-zone-configurator/infra/platform/2-database.tf @@ -0,0 +1,39 @@ +resource "stackit_postgresflex_instance" "configurator" { + project_id = var.project_id + name = "${var.name_prefix}-db" + flavor_id = var.database.flavor_id + version = var.database.version + backup_schedule = var.database.backup_schedule + retention_days = var.database.retention_days + network = { + acl = sort(tolist(var.service_access_cidrs)) + } + storage = { + class = var.database.storage_class + size = var.database.storage_size_gb + } + lifecycle { + prevent_destroy = true + } +} + +# Database owner is for migrations only; application users and RLS are a separate runtime step. +resource "stackit_postgresflex_user" "migration" { + project_id = var.project_id + instance_id = stackit_postgresflex_instance.configurator.instance_id + username = "configurator_migration" + roles = ["login"] + rotate_when_changed = { + generation = var.credential_generation + } +} + +resource "stackit_postgresflex_database" "configurator" { + project_id = var.project_id + instance_id = stackit_postgresflex_instance.configurator.instance_id + name = "configurator" + owner = stackit_postgresflex_user.migration.username + lifecycle { + prevent_destroy = true + } +} diff --git a/landing-zone-configurator/infra/platform/3-secrets.tf b/landing-zone-configurator/infra/platform/3-secrets.tf new file mode 100644 index 0000000..53aa56d --- /dev/null +++ b/landing-zone-configurator/infra/platform/3-secrets.tf @@ -0,0 +1,19 @@ +resource "stackit_secretsmanager_instance" "configurator" { + project_id = var.project_id + name = "${var.name_prefix}-secrets" + acls = var.service_access_cidrs + lifecycle { + prevent_destroy = true + } +} + +# Bootstrap identity for provisioning policies; never bind this user to a customer runner. +resource "stackit_secretsmanager_user" "provisioner" { + project_id = var.project_id + instance_id = stackit_secretsmanager_instance.configurator.instance_id + description = "Configurator secret-policy provisioning" + write_enabled = true + rotate_when_changed = { + generation = var.credential_generation + } +} diff --git a/landing-zone-configurator/infra/platform/4-artifacts.tf b/landing-zone-configurator/infra/platform/4-artifacts.tf new file mode 100644 index 0000000..ccfd5a1 --- /dev/null +++ b/landing-zone-configurator/infra/platform/4-artifacts.tf @@ -0,0 +1,7 @@ +resource "stackit_objectstorage_bucket" "artifacts" { + project_id = var.project_id + name = "${var.name_prefix}-artifacts-${substr(var.project_id, 0, 8)}" + lifecycle { + prevent_destroy = true + } +} diff --git a/landing-zone-configurator/infra/platform/5-model-serving.tf b/landing-zone-configurator/infra/platform/5-model-serving.tf new file mode 100644 index 0000000..d7e0b32 --- /dev/null +++ b/landing-zone-configurator/infra/platform/5-model-serving.tf @@ -0,0 +1,9 @@ +resource "stackit_modelserving_token" "configurator" { + project_id = var.project_id + name = "${var.name_prefix}-chat" + description = "Landing Zone Configurator backend inference access" + ttl_duration = "2160h" + rotate_when_changed = { + generation = var.credential_generation + } +} diff --git a/landing-zone-configurator/infra/platform/README.md b/landing-zone-configurator/infra/platform/README.md new file mode 100644 index 0000000..50519fa --- /dev/null +++ b/landing-zone-configurator/infra/platform/README.md @@ -0,0 +1,9 @@ +# Configurator-Plattform + +Dieser Root verwaltet CF-Organisation und technischen Org-Manager, PostgreSQL Flex samt Datenbank/Migrationsnutzer, Secrets Manager samt Provisionierungsnutzer, Artefakt-Bucket und AI Model Serving inklusive Auth-Token. Der STACKIT-Provider aktiviert Model Serving bei der Token-Erstellung. + +Region, Foundation, Quota, DB-Plan und verifizierte Quell-CIDRs sind explizite Eingaben. Keine offene Standard-ACL. Der Datenbank-Owner ist für Migrationen vorgesehen, nicht als späterer App-Benutzer; App-Rollen/RLS und Secret-Policies werden in der Runtime-Schicht ergänzt. + +Voraussetzung: erfolgreich angewendeter State-Bootstrap und Backend-Root mit aktivierter Versionierung. Der Wrapper übergibt dessen Backend-Zugang ohne Klartextdatei. [IaC-Anleitung](../README.md). + +Ein `sensitive`-Output maskiert nur die Anzeige. Provider-erzeugte Credentials liegen im verschlüsselten State. Nachgelagerte IaC erhält nur die benötigten Outputs; keine Anbindung der unbeschränkten Provisionierungsidentitäten an Kunden-Runner. diff --git a/landing-zone-configurator/infra/platform/outputs.tf b/landing-zone-configurator/infra/platform/outputs.tf new file mode 100644 index 0000000..82037be --- /dev/null +++ b/landing-zone-configurator/infra/platform/outputs.tf @@ -0,0 +1,25 @@ +output "cf_runtime" { + description = "Sensitive credentials for the later CF runtime root; do not emit into logs." + sensitive = true + value = { + api_url = data.stackit_scf_platform.configurator.api_url + org_id = stackit_scf_organization.configurator.org_id + username = stackit_scf_organization_manager.configurator.username + password = stackit_scf_organization_manager.configurator.password + } +} + +output "service_ids" { + description = "Platform resources for subsequent runtime configuration; not customer deployments." + value = { + database_instance_id = stackit_postgresflex_instance.configurator.instance_id + secrets_instance_id = stackit_secretsmanager_instance.configurator.instance_id + artifacts_bucket = stackit_objectstorage_bucket.artifacts.name + } +} + +output "model_serving_token" { + description = "Backend-only inference token for the later CF binding; never include in the frontend." + sensitive = true + value = stackit_modelserving_token.configurator.token +} diff --git a/landing-zone-configurator/infra/platform/providers.tf b/landing-zone-configurator/infra/platform/providers.tf new file mode 100644 index 0000000..a259d14 --- /dev/null +++ b/landing-zone-configurator/infra/platform/providers.tf @@ -0,0 +1,3 @@ +provider "stackit" { + default_region = var.region +} diff --git a/landing-zone-configurator/infra/platform/terraform.tf b/landing-zone-configurator/infra/platform/terraform.tf new file mode 100644 index 0000000..01afc73 --- /dev/null +++ b/landing-zone-configurator/infra/platform/terraform.tf @@ -0,0 +1,26 @@ +terraform { + required_version = "= 1.12.6" + required_providers { + stackit = { + source = "stackitcloud/stackit" + version = "0.114.0" + } + } + backend "s3" {} + + encryption { + # The operator wrapper supplies the passphrase via TF_ENCRYPTION, never a tfvars file. + key_provider "pbkdf2" "state" {} + method "aes_gcm" "state" { + keys = key_provider.pbkdf2.state + } + state { + method = method.aes_gcm.state + enforced = true + } + plan { + method = method.aes_gcm.state + enforced = true + } + } +} diff --git a/landing-zone-configurator/infra/platform/tests/platform.tftest.hcl b/landing-zone-configurator/infra/platform/tests/platform.tftest.hcl new file mode 100644 index 0000000..c01f0a0 --- /dev/null +++ b/landing-zone-configurator/infra/platform/tests/platform.tftest.hcl @@ -0,0 +1,70 @@ +mock_provider "stackit" { + mock_resource "stackit_scf_organization" { + defaults = { + org_id = "00000000-0000-4000-8000-000000000010" + } + } + mock_resource "stackit_postgresflex_instance" { + defaults = { + instance_id = "00000000-0000-4000-8000-000000000011" + } + } + mock_resource "stackit_secretsmanager_instance" { + defaults = { + instance_id = "00000000-0000-4000-8000-000000000012" + } + } +} + +variables { + project_id = "00000000-0000-4000-8000-000000000001" + region = "eu01" + name_prefix = "lzc-test" + cf_platform_id = "00000000-0000-4000-8000-000000000002" + cf_quota_id = "00000000-0000-4000-8000-000000000003" + service_access_cidrs = ["192.0.2.10/32"] + database = { + flavor_id = "test-flavor" + version = "17" + storage_class = "test-storage" + storage_size_gb = 5 + backup_schedule = "0 2 * * *" + retention_days = 32 + } +} + +run "service_boundaries" { + command = plan + assert { + condition = stackit_secretsmanager_instance.configurator.acls == toset(["192.0.2.10/32"]) + error_message = "Secrets must be restricted to the configured egress ranges." + } + assert { + condition = stackit_modelserving_token.configurator.ttl_duration == "2160h" + error_message = "Model-serving tokens must have a bounded lifetime." + } +} + +run "reject_public_database_and_secrets" { + command = plan + variables { + service_access_cidrs = ["0.0.0.0/0"] + } + expect_failures = [var.service_access_cidrs] +} + +run "reject_empty_access_list" { + command = plan + variables { + service_access_cidrs = [] + } + expect_failures = [var.service_access_cidrs] +} + +run "reject_noncanonical_default_route" { + command = plan + variables { + service_access_cidrs = ["192.0.2.1/0"] + } + expect_failures = [var.service_access_cidrs] +} diff --git a/landing-zone-configurator/infra/platform/variables.tf b/landing-zone-configurator/infra/platform/variables.tf new file mode 100644 index 0000000..2a97e3a --- /dev/null +++ b/landing-zone-configurator/infra/platform/variables.tf @@ -0,0 +1,69 @@ +variable "project_id" { + type = string + description = "Existing STACKIT operator project; this root does not create customer projects." + validation { + condition = can(regex("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", var.project_id)) + error_message = "project_id must be a UUID." + } +} + +variable "region" { + type = string + description = "Explicitly selected STACKIT deployment region." + validation { + condition = contains(["eu01", "eu02"], var.region) + error_message = "Use a supported region: eu01 or eu02." + } +} + +variable "name_prefix" { + type = string + description = "Stable environment-specific naming prefix." + validation { + condition = can(regex("^[a-z][a-z0-9-]{2,29}[a-z0-9]$", var.name_prefix)) + error_message = "Use 4-31 lowercase letters, numbers or hyphens, starting with a letter and ending with a letter or number." + } +} + +variable "cf_platform_id" { + type = string + description = "Explicitly chosen CF foundation ID from the project-specific platform catalog." +} + +variable "cf_quota_id" { + type = string + description = "Explicitly chosen quota ID for this CF foundation." +} + +variable "service_access_cidrs" { + type = set(string) + description = "Verified CF egress and operator CIDRs permitted to access PostgreSQL and Secrets Manager." + validation { + condition = length(var.service_access_cidrs) > 0 && alltrue([ + for cidr in var.service_access_cidrs : can(cidrhost(cidr, 0)) && try(tonumber(split("/", cidr)[1]) > 0, false) + ]) + error_message = "Supply explicit valid access CIDRs; unrestricted internet access is not accepted." + } +} + +variable "database" { + type = object({ + flavor_id = string + version = string + storage_class = string + storage_size_gb = number + backup_schedule = string + retention_days = number + }) + description = "Explicitly selected PostgreSQL Flex plan, version, storage and backup settings." + validation { + condition = var.database.storage_size_gb > 0 && var.database.retention_days >= 32 && var.database.retention_days <= 90 + error_message = "Storage must be positive and backup retention between 32 and 90 days." + } +} + +variable "credential_generation" { + type = string + description = "Increment intentionally to rotate supported platform credentials, followed by binding updates." + default = "initial" +} diff --git a/landing-zone-configurator/infra/seed-protection/.terraform.lock.hcl b/landing-zone-configurator/infra/seed-protection/.terraform.lock.hcl new file mode 100644 index 0000000..5a93d70 --- /dev/null +++ b/landing-zone-configurator/infra/seed-protection/.terraform.lock.hcl @@ -0,0 +1,39 @@ +# This file is maintained automatically by "tofu init". +# Manual edits may be lost in future updates. + +provider "registry.opentofu.org/hashicorp/aws" { + version = "6.66.0" + constraints = "6.66.0" + hashes = [ + "h1:A2L/03k6fbOyb8Pg6224b9vajh285FZDvlWdgaO2Fkw=", + "h1:HbcFNt1OvD4MBknLg/m0rg7grEaKlaSUbnecF3vxvx0=", + "h1:KEKMmD7afHqcJdw54IFJmXE+59xgh9LJlpXhemEN6H0=", + "h1:LBazejB2S9h3e2kmXI8QDSVA11U2JEW4XSDs1c/S1P0=", + "h1:PWE6gqi8Mg8crSuvxb5KmrR0rFoddB4WbpIdSHRC59U=", + "h1:QLqaOtzPyuv1vdT4NuAgVOGCld9Pk3eWAW9u8z102lI=", + "h1:UHgIgYN6WcVabz8xOAzKX4Iet+XNTyOJvvVMCWLkoCw=", + "h1:YNwRhbk68IhAHqL1x2D9zj0BhY8qqyPLO+zTQOdfMpM=", + "h1:av0q2IS8IaCEvxSg8O9aapxHTyKOeeaXT++IeWZPz2Y=", + "h1:bUp871GCBdQOMB4TC6ND/+k64QjHmYkqqfQPh682kew=", + "h1:dSXxrFpGyQ1obQeGZQw5iA54LBx58z9XZhDZTtV6m+c=", + "h1:dnGqQEhsy2djusN1G6JJgfjkGoQREYEmNpvJD/fZycU=", + "h1:hkkPlR574ztoxUs76kSsS8tnxfjVFqpWZOFlffPqVRQ=", + "h1:jvqzmQntdO80OzKw9ikpCUsQZVK3/mznM1Eie9tIspE=", + "h1:o6+yfy74tYUxiD4jMxB/TzGNlVYvLExG+BLrIH9enS8=", + "zh:005994add988d9acdd291a10641d27ef828246cb4c4c66e82317bd0012d4b79e", + "zh:387e8ba5af9d628e36ca554453d5f664c1649c475fac92e7b3a23bc01e0a7d31", + "zh:45acbc6df5900c070a7db81b8286b54b7366a7d973f9c186a06be4de0b59c229", + "zh:533dc482c05948a005355ad5263ab832ae01eb137a8fceec2b3bf63cb00fad72", + "zh:5d3fe32f05510182a43fca628312ba916cabef61dfc4cabfe7fb84326e509894", + "zh:7d2dc52b6e0a245565ae92fc07926fd9284ae4e1f35b148053b1cc80615a3e66", + "zh:8bbb54f340d98db06d93ef34cab6560fb89a18e8c07af2e7085789701ec7a7ce", + "zh:97904961a5c1813c200ab51a20831dd76c3cc6bf262cb9e34ea9b1afdde05216", + "zh:a26c7bb1ad5ecb691bb2558dde9eb13827fdf326b855ab8e522f74c91a0aa475", + "zh:ab9c77d4427bf7a118b8e666c9bc90cc128662301b27dc2fcbb9653019a07a82", + "zh:d467d3ca59c526dc5ee7e0d6bfa87151dbcfade1fa81e1b48af7a582b4aabe7a", + "zh:dcd3e32777e9db4d679f732f0f73dbb9a51ebe94beef264276b7ea6e8ee2d83b", + "zh:e2ded7927ea0a6941d91e675bc25b89f93eb466ae3e782f47a83b7401f8fc447", + "zh:ecce136f374f456638192e12bfd11ba7561434789b38a9c76d303d5f622f38ea", + "zh:fc60d067c52a1975841eb5dc20faa31763cd1cbbe2f017a1ff9598e7038a7590", + ] +} diff --git a/landing-zone-configurator/infra/seed-protection/1-versioning.tf b/landing-zone-configurator/infra/seed-protection/1-versioning.tf new file mode 100644 index 0000000..d22b9a7 --- /dev/null +++ b/landing-zone-configurator/infra/seed-protection/1-versioning.tf @@ -0,0 +1,10 @@ +# STACKIT supports S3 bucket versioning but exposes no dedicated resource in the pinned provider. +resource "aws_s3_bucket_versioning" "state" { + bucket = var.state_bucket_name + versioning_configuration { + status = "Enabled" + } + lifecycle { + prevent_destroy = true + } +} diff --git a/landing-zone-configurator/infra/seed-protection/README.md b/landing-zone-configurator/infra/seed-protection/README.md new file mode 100644 index 0000000..cc2e7f8 --- /dev/null +++ b/landing-zone-configurator/infra/seed-protection/README.md @@ -0,0 +1,3 @@ +# seed-protection + +Lokale IaC-Seed-Stufe für den unabhängigen Verwaltungs-Bucket. Ablauf und Recovery stehen in [der IaC-Anleitung](../README.md). State ist verschlüsselt; Schlüssel niemals in Git oder den Bucket hochladen. diff --git a/landing-zone-configurator/infra/seed-protection/outputs.tf b/landing-zone-configurator/infra/seed-protection/outputs.tf new file mode 100644 index 0000000..22289b4 --- /dev/null +++ b/landing-zone-configurator/infra/seed-protection/outputs.tf @@ -0,0 +1,9 @@ +output "versioning_enabled" { + description = "Successful IaC application of bucket versioning; not a substitute for a restore test." + value = aws_s3_bucket_versioning.state.versioning_configuration[0].status == "Enabled" +} + +output "state_bucket_name" { + description = "Bucket that was protected, checked against the platform backend." + value = aws_s3_bucket_versioning.state.bucket +} diff --git a/landing-zone-configurator/infra/seed-protection/providers.tf b/landing-zone-configurator/infra/seed-protection/providers.tf new file mode 100644 index 0000000..438da7f --- /dev/null +++ b/landing-zone-configurator/infra/seed-protection/providers.tf @@ -0,0 +1,12 @@ +# Receives already-created STACKIT S3 credentials via the operator process environment. +provider "aws" { + region = var.region + skip_credentials_validation = true + skip_region_validation = true + skip_requesting_account_id = true + skip_metadata_api_check = true + s3_use_path_style = true + endpoints { + s3 = "https://object.storage.${var.region}.onstackit.cloud" + } +} diff --git a/landing-zone-configurator/infra/seed-protection/terraform.tf b/landing-zone-configurator/infra/seed-protection/terraform.tf new file mode 100644 index 0000000..416f4e4 --- /dev/null +++ b/landing-zone-configurator/infra/seed-protection/terraform.tf @@ -0,0 +1,28 @@ +terraform { + required_version = "= 1.12.6" + required_providers { + aws = { + source = "hashicorp/aws" + version = "6.66.0" + } + } + + # Enables protection after bootstrap credentials exist; encrypted local operator state. + backend "local" {} + + encryption { + # The operator wrapper supplies the passphrase via TF_ENCRYPTION, never a tfvars file. + key_provider "pbkdf2" "state" {} + method "aes_gcm" "state" { + keys = key_provider.pbkdf2.state + } + state { + method = method.aes_gcm.state + enforced = true + } + plan { + method = method.aes_gcm.state + enforced = true + } + } +} diff --git a/landing-zone-configurator/infra/seed-protection/tests/backend.tftest.hcl b/landing-zone-configurator/infra/seed-protection/tests/backend.tftest.hcl new file mode 100644 index 0000000..3e52f91 --- /dev/null +++ b/landing-zone-configurator/infra/seed-protection/tests/backend.tftest.hcl @@ -0,0 +1,14 @@ +mock_provider "aws" {} + +variables { + region = "eu01" + state_bucket_name = "lzc-test-state" +} + +run "versioning_is_required" { + command = plan + assert { + condition = aws_s3_bucket_versioning.state.versioning_configuration[0].status == "Enabled" + error_message = "The remote state bucket must retain object versions." + } +} diff --git a/landing-zone-configurator/infra/seed-protection/variables.tf b/landing-zone-configurator/infra/seed-protection/variables.tf new file mode 100644 index 0000000..ebd8bb1 --- /dev/null +++ b/landing-zone-configurator/infra/seed-protection/variables.tf @@ -0,0 +1,9 @@ +variable "region" { + type = string + description = "STACKIT region of the bootstrap bucket." +} + +variable "state_bucket_name" { + type = string + description = "Existing state bucket from the bootstrap root." +} diff --git a/landing-zone-configurator/infra/seed/.terraform.lock.hcl b/landing-zone-configurator/infra/seed/.terraform.lock.hcl new file mode 100644 index 0000000..15140f8 --- /dev/null +++ b/landing-zone-configurator/infra/seed/.terraform.lock.hcl @@ -0,0 +1,36 @@ +# This file is maintained automatically by "tofu init". +# Manual edits may be lost in future updates. + +provider "registry.opentofu.org/stackitcloud/stackit" { + version = "0.114.0" + constraints = "0.114.0" + hashes = [ + "h1:1LWQQnyh7olj5+Ceu33qfZrtvwV6oB4OatOT01QhHBQ=", + "h1:3WbnQ683RK3lVPMZ8Kt+hkJAgVVXYUkoetlhZ74/o8A=", + "h1:6wvA9Zh+oe/0dpCyjEakLPeq1MX2Kd84QFQz/o25xtU=", + "h1:BrXRBNOvpOOTIoJitmN4wD6utYcxVcfzk4/zJzKW15A=", + "h1:MTREOQy9s4S+guQpHVAQwZjk9oudo+57vbt7LSvG6jk=", + "h1:Oi6VgqQ3zQVeFjnksXYno9JOyQyYDfulBNCdEgDeimM=", + "h1:Pw89rX8OcLNY06pMMsdZ48ATRHRdc14Hqr7Gz70jfRo=", + "h1:ShJfA+qiqn/Hh4/alQH90yeJiGipB/4CnLiFfkDsJqM=", + "h1:UF2Zbvh5weaMRKhQql1VT7OvfgAllgad7EdhSIwN/pM=", + "h1:ao60KSPxE34NBuNZDYg78p4/ZxXhmbnlh8tMBsu2Omg=", + "h1:g14AMgsC94E7L7RTo0tK6hzcBepik/vcEw1ixONnMHo=", + "h1:oGShI7JYRcemHmAvOp+fwLQCtsrDv9KNBud/6O8JqrU=", + "h1:oQdvYsXY32mfOZv+kJ9uFSorpTj1IWrJeGyYIZcFOf0=", + "zh:096d6a1d716f124e9a7576318ffb2128ae0ac29e7c1f13b1b3c7723b79f760de", + "zh:0dde99e7b343fa01f8eefc378171fb8621bedb20f59157d6cc8e3d46c738105f", + "zh:1d48af034c791b5565a86c4905abd309152bf50c2c67c44e4943a750a3b0b060", + "zh:1f5703f9616cde7a19ae0e4aa987d02d3d04f3c1b5e593de5d3e8c8043b9da9b", + "zh:1f984dd23d3933734238f1e8b3e1ad2cab20c5e58010b62861ca64f9058b220f", + "zh:338244fafd34188194789bd0d0f84702b12c9086901b7e6bc740cc2415a82c95", + "zh:3b52f81ac36371435a9fde9ff4b437d7b4aee0b692081c490bf7aa4fdf4197b6", + "zh:45f281c29dc783023a1b8deb054bc9dc8f559ed450471005afb4b6d44e68ddc8", + "zh:70d41f68d8a522aa10aab708c425e4c7ce158ced023a9a4dd32cfd4775206c1c", + "zh:a1c7d0e7f9f91d95241ba5ecc6b17ff0a0cd90fe2b2343f5d515b551a97341fa", + "zh:ae292b2f3fc479755f3fcd730bdf5f05c283be788a9daba33012439c0084ce4d", + "zh:b41b5b7a899735d2e516eb76a9b2d67cc46b01fa3fc0392b60ca1448184bf470", + "zh:d01cc2b743081d0c1085e0a134c1d011368305f2ba2219167a0e29ff1b66397b", + "zh:fe4e441850590c04cfaf21398020d0460befd490e1cc7aa791a13ceace80fcbf", + ] +} diff --git a/landing-zone-configurator/infra/seed/1-state-storage.tf b/landing-zone-configurator/infra/seed/1-state-storage.tf new file mode 100644 index 0000000..70f2b1a --- /dev/null +++ b/landing-zone-configurator/infra/seed/1-state-storage.tf @@ -0,0 +1,20 @@ +# Creating the first bucket also enables Object Storage through the STACKIT provider. +resource "stackit_objectstorage_bucket" "state" { + project_id = var.project_id + name = "${var.name_prefix}-management-${substr(var.project_id, 0, 8)}" + lifecycle { + prevent_destroy = true + } +} + +resource "stackit_objectstorage_credentials_group" "state" { + project_id = var.project_id + name = "${var.name_prefix}-management-operators" + depends_on = [stackit_objectstorage_bucket.state] +} + +resource "stackit_objectstorage_credential" "state" { + project_id = var.project_id + credentials_group_id = stackit_objectstorage_credentials_group.state.credentials_group_id + expiration_timestamp = var.state_credential_expiration +} diff --git a/landing-zone-configurator/infra/seed/README.md b/landing-zone-configurator/infra/seed/README.md new file mode 100644 index 0000000..a80963b --- /dev/null +++ b/landing-zone-configurator/infra/seed/README.md @@ -0,0 +1,3 @@ +# seed + +Lokale IaC-Seed-Stufe für den unabhängigen Verwaltungs-Bucket. Ablauf und Recovery stehen in [der IaC-Anleitung](../README.md). State ist verschlüsselt; Schlüssel niemals in Git oder den Bucket hochladen. diff --git a/landing-zone-configurator/infra/seed/outputs.tf b/landing-zone-configurator/infra/seed/outputs.tf new file mode 100644 index 0000000..d8d7ed3 --- /dev/null +++ b/landing-zone-configurator/infra/seed/outputs.tf @@ -0,0 +1,17 @@ +output "backend" { + description = "Non-secret backend settings; apply the backend root to enable versioning before platform use." + value = { + bucket = stackit_objectstorage_bucket.state.name + region = var.region + endpoint = "https://object.storage.${var.region}.onstackit.cloud" + } +} + +output "backend_credentials" { + description = "Operator-only S3 credentials; pass in memory to the backend, never commit or print them." + sensitive = true + value = { + access_key = stackit_objectstorage_credential.state.access_key + secret_key = stackit_objectstorage_credential.state.secret_access_key + } +} diff --git a/landing-zone-configurator/infra/seed/providers.tf b/landing-zone-configurator/infra/seed/providers.tf new file mode 100644 index 0000000..a259d14 --- /dev/null +++ b/landing-zone-configurator/infra/seed/providers.tf @@ -0,0 +1,3 @@ +provider "stackit" { + default_region = var.region +} diff --git a/landing-zone-configurator/infra/seed/terraform.tf b/landing-zone-configurator/infra/seed/terraform.tf new file mode 100644 index 0000000..006cbab --- /dev/null +++ b/landing-zone-configurator/infra/seed/terraform.tf @@ -0,0 +1,28 @@ +terraform { + required_version = "= 1.12.6" + required_providers { + stackit = { + source = "stackitcloud/stackit" + version = "0.114.0" + } + } + + # This root creates its own future backend; the initial state is encrypted locally. + backend "local" {} + + encryption { + # The operator wrapper supplies the passphrase via TF_ENCRYPTION, never a tfvars file. + key_provider "pbkdf2" "state" {} + method "aes_gcm" "state" { + keys = key_provider.pbkdf2.state + } + state { + method = method.aes_gcm.state + enforced = true + } + plan { + method = method.aes_gcm.state + enforced = true + } + } +} diff --git a/landing-zone-configurator/infra/seed/tests/bootstrap.tftest.hcl b/landing-zone-configurator/infra/seed/tests/bootstrap.tftest.hcl new file mode 100644 index 0000000..71c9ef5 --- /dev/null +++ b/landing-zone-configurator/infra/seed/tests/bootstrap.tftest.hcl @@ -0,0 +1,31 @@ +mock_provider "stackit" { + mock_resource "stackit_objectstorage_credentials_group" { + defaults = { + credentials_group_id = "00000000-0000-4000-8000-000000000010" + } + } +} + +variables { + project_id = "00000000-0000-4000-8000-000000000001" + region = "eu01" + name_prefix = "lzc-test" + state_credential_expiration = "2027-01-01T00:00:00Z" +} + +run "backend_credential_lifetime" { + command = plan + + assert { + condition = stackit_objectstorage_credential.state.expiration_timestamp == "2027-01-01T00:00:00Z" + error_message = "Backend credentials must have an explicit expiry." + } +} + +run "reject_unsupported_region" { + command = plan + variables { + region = "unsupported" + } + expect_failures = [var.region] +} diff --git a/landing-zone-configurator/infra/seed/variables.tf b/landing-zone-configurator/infra/seed/variables.tf new file mode 100644 index 0000000..bb8e7c5 --- /dev/null +++ b/landing-zone-configurator/infra/seed/variables.tf @@ -0,0 +1,35 @@ +variable "project_id" { + type = string + description = "Existing STACKIT operator project; this root does not create customer projects." + validation { + condition = can(regex("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", var.project_id)) + error_message = "project_id must be a UUID." + } +} + +variable "region" { + type = string + description = "Explicitly selected STACKIT deployment region." + validation { + condition = contains(["eu01", "eu02"], var.region) + error_message = "Use a supported region: eu01 or eu02." + } +} + +variable "name_prefix" { + type = string + description = "Stable environment-specific naming prefix." + validation { + condition = can(regex("^[a-z][a-z0-9-]{2,29}[a-z0-9]$", var.name_prefix)) + error_message = "Use 4-31 lowercase letters, numbers or hyphens, starting with a letter and ending with a letter or number." + } +} + +variable "state_credential_expiration" { + type = string + description = "RFC3339 expiry of the operator backend credential; rotate before this date." + validation { + condition = can(formatdate("YYYY", var.state_credential_expiration)) + error_message = "Supply an explicit RFC3339 expiration timestamp." + } +} diff --git a/landing-zone-configurator/infra/state-key.mjs b/landing-zone-configurator/infra/state-key.mjs new file mode 100644 index 0000000..0170ffa --- /dev/null +++ b/landing-zone-configurator/infra/state-key.mjs @@ -0,0 +1,23 @@ +import { randomBytes } from "node:crypto"; +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; + +// CI validation is explicitly disposable. Deployment must use an existing key. +export function stateKey(location, { ci = false, validation = false, suppliedKey } = {}) { + if (ci && !validation) { + if (typeof suppliedKey !== "string" || suppliedKey.trim().length < 32) { + throw new Error("CI deployment requires an existing state-encryption key; automatic generation is forbidden"); + } + return suppliedKey.trim(); + } + const keyFile = resolve(location, "state.passphrase"); + if (!existsSync(keyFile)) { + if (existsSync(resolve(location, "data")) || existsSync(resolve(location, "terraform.tfstate"))) { + throw new Error("Encryption key missing for an existing workspace; restore the original key"); + } + writeFileSync(keyFile, randomBytes(48).toString("base64url"), { mode: 0o600, flag: "wx" }); + } + const key = readFileSync(keyFile, "utf8").trim(); + if (key.length < 32) throw new Error("Invalid local state-encryption key"); + return key; +} diff --git a/landing-zone-configurator/infra/state-key.test.mjs b/landing-zone-configurator/infra/state-key.test.mjs new file mode 100644 index 0000000..ce5c59a --- /dev/null +++ b/landing-zone-configurator/infra/state-key.test.mjs @@ -0,0 +1,33 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync, existsSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { stateKey } from "./state-key.mjs"; + +function workspace(t) { + const path = mkdtempSync(join(tmpdir(), "lzc-key-")); + t.after(() => rmSync(path, { recursive: true, force: true })); + return path; +} +test("CI deployment refuses to generate a key", (t) => { + const path = workspace(t); + assert.throws(() => stateKey(path, { ci: true }), /existing state-encryption key/); + assert.equal(existsSync(join(path, "state.passphrase")), false); +}); +test("CI deployment uses supplied key without persisting it", (t) => { + const path = workspace(t); + const key = "test-only-key-".repeat(4); + assert.equal(stateKey(path, { ci: true, suppliedKey: key }), key); + assert.equal(existsSync(join(path, "state.passphrase")), false); +}); +test("local validation reuses its key", (t) => { + const path = workspace(t); + const key = stateKey(path, { ci: true, validation: true }); + assert.equal(stateKey(path, { ci: true, validation: true }), key); +}); +test("lost local key is never silently replaced", (t) => { + const path = workspace(t); + writeFileSync(join(path, "terraform.tfstate"), "existing encrypted state"); + assert.throws(() => stateKey(path), /restore the original key/); +}); diff --git a/landing-zone-configurator/infra/tofu.mjs b/landing-zone-configurator/infra/tofu.mjs new file mode 100644 index 0000000..cd1c8db --- /dev/null +++ b/landing-zone-configurator/infra/tofu.mjs @@ -0,0 +1,181 @@ +import { execFileSync, spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { parseEnv } from "node:util"; +import { stateKey } from "./state-key.mjs"; +import { isApplicablePlan } from "./plan-guard.mjs"; + +const infra = dirname(fileURLToPath(import.meta.url)); +const repository = resolve(infra, "../.."); +const local = resolve(infra, "../.local"); +const [root, command, ...extra] = process.argv.slice(2); +const allowed = ["init", "validate", "plan", "apply", "test", "fmt", "providers"]; +if (!["seed", "seed-protection", "bootstrap", "backend", "platform"].includes(root) || !allowed.includes(command)) { + console.error("Usage: node infra/tofu.mjs [options]"); + process.exit(1); +} +const binary = process.env.LZC_TOFU_BIN ?? "tofu"; +const version = JSON.parse(execFileSync(binary, ["version", "-json"], { encoding: "utf8" })); +if (version.terraform_version !== "1.12.6") throw new Error("OpenTofu 1.12.6 is required"); +process.umask(0o077); +mkdirSync(local, { recursive: true, mode: 0o700 }); + +function rootEnvironment(name) { + const location = resolve(local, name); + mkdirSync(location, { recursive: true, mode: 0o700 }); + const ci = process.env.CI === "true" || process.env.GITHUB_ACTIONS === "true"; + const validation = ["validate", "test", "fmt", "providers"].includes(command) || + (command === "init" && extra.includes("-backend=false")); + // Fail closed until durable remote state has been integrated for every root. + if (ci && !validation) throw new Error("CI cloud operations require the remote-state integration; local CI state is forbidden"); + const passphrase = stateKey(location, { + ci, validation, suppliedKey: process.env[`LZC_STATE_KEY_${name.toUpperCase()}`], + }); + const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => + !key.startsWith("LZC_STATE_KEY_") && !key.startsWith("STACKIT_") && !key.startsWith("TF_VAR_") && !key.startsWith("TF_LOG") && + !key.startsWith("TF_CLI_ARGS") && !key.startsWith("AWS_"))); + env.TF_DATA_DIR = resolve(location, "data"); + env.TF_INPUT = "0"; + env.TF_ENCRYPTION = JSON.stringify({ key_provider: { pbkdf2: { state: { passphrase } } } }); + return env; +} + +try { + if(command === "apply" && ["bootstrap","backend","platform"].includes(root)) { + const safety=JSON.parse(readFileSync(resolve(infra,"ci/backend-safety.json"),"utf8")); + if(safety.mode === "github-actions-single-writer" || safety.s3LockingVerified !== true)throw new Error("Local remote apply disabled: use the serialized GitHub Actions workflow"); + } + const env = rootEnvironment(root); + const configFile = resolve(repository, "landing-zone-configurator.env"); + const config = existsSync(configFile) ? parseEnv(readFileSync(configFile, "utf8")) : {}; + const cloudOperation = ["plan", "apply"].includes(command) || (["bootstrap", "backend", "platform"].includes(root) && command === "init" && !extra.includes("-backend=false")); + if (cloudOperation) { + if (!config.PROJECT_ID || !config.REGION || !config.NAME_PREFIX) { + throw new Error("Set PROJECT_ID, REGION and NAME_PREFIX in the local configurator env file before planning/applying"); + } + const credentials = resolve(repository, "landing-zone-configurator-credentials.json"); + if (!existsSync(credentials)) throw new Error("Configurator credential file is missing"); + env.STACKIT_SERVICE_ACCOUNT_KEY_PATH = credentials; + env.TF_VAR_project_id = config.PROJECT_ID; + env.TF_VAR_region = config.REGION; + env.TF_VAR_name_prefix = config.NAME_PREFIX; + if (["seed", "bootstrap"].includes(root)) { + if (!config.STATE_CREDENTIAL_EXPIRATION) throw new Error("Set STATE_CREDENTIAL_EXPIRATION explicitly"); + env.TF_VAR_state_credential_expiration = config.STATE_CREDENTIAL_EXPIRATION; + } + } + const args = [`-chdir=${resolve(infra, root)}`, command, ...extra]; + if (["seed", "seed-protection"].includes(root) && command === "init" && !extra.includes("-backend=false")) { + args.push(`-backend-config=path=${resolve(local, root, "terraform.tfstate")}`); + } + function readOutputs(name) { + try { + return JSON.parse(execFileSync(binary, [`-chdir=${resolve(infra, name)}`, "output", "-json"], + { env: rootEnvironment(name), encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] })); + } catch { throw new Error(`Outputs unavailable for ${name}; initialize/apply or restore it first`); } + } + function credentialsFrom(outputs) { + const credentials = outputs.backend_credentials?.value; + if (!credentials) throw new Error("Backend credentials missing"); + return credentials; + } + if (["seed-protection", "bootstrap", "backend", "platform"].includes(root) && cloudOperation) { + const management = readOutputs("seed"); + const managementCredentials = credentialsFrom(management); + let source = management; + if (root === "platform") { + // Read bootstrap with its management backend credentials, without printing outputs. + const bootstrapEnv = rootEnvironment("bootstrap"); + bootstrapEnv.AWS_ACCESS_KEY_ID = managementCredentials.access_key; + bootstrapEnv.AWS_SECRET_ACCESS_KEY = managementCredentials.secret_key; + try { + source = JSON.parse(execFileSync(binary, [`-chdir=${resolve(infra, "bootstrap")}`, "output", "-json"], + { env: bootstrapEnv, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] })); + } catch { throw new Error("Bootstrap outputs unavailable"); } + } + const backend = source.backend?.value; + const credentials = credentialsFrom(source); + if (!backend || backend.region !== config.REGION) throw new Error("Backend region mismatch"); + env.AWS_ACCESS_KEY_ID = credentials.access_key; + env.AWS_SECRET_ACCESS_KEY = credentials.secret_key; + if (root === "seed-protection") env.TF_VAR_state_bucket_name = backend.bucket; + if (root === "backend" && command !== "init") { + const bootstrapEnv = { ...rootEnvironment("bootstrap"), AWS_ACCESS_KEY_ID: managementCredentials.access_key, + AWS_SECRET_ACCESS_KEY: managementCredentials.secret_key }; + let outputs; + try { + outputs = JSON.parse(execFileSync(binary, [`-chdir=${resolve(infra, "bootstrap")}`, "output", "-json"], + { env: bootstrapEnv, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] })); + } catch { throw new Error("Bootstrap outputs unavailable"); } + env.TF_VAR_state_bucket_name = outputs.backend.value.bucket; + // AWS provider uses explicit credentials; S3 state backend retains management credentials. + const workload = credentialsFrom(outputs); + env.TF_VAR_storage_access_key = workload.access_key; + env.TF_VAR_storage_secret_key = workload.secret_key; + } + if (["bootstrap", "backend", "platform"].includes(root) && command === "init") { + if (root === "platform") { + const protectionEnv = { ...rootEnvironment("backend"), AWS_ACCESS_KEY_ID: managementCredentials.access_key, + AWS_SECRET_ACCESS_KEY: managementCredentials.secret_key }; + let protection; + try { + protection = JSON.parse(execFileSync(binary, [`-chdir=${resolve(infra, "backend")}`, "output", "-json"], + { env: protectionEnv, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] })); + } catch { throw new Error("Backend protection outputs unavailable"); } + if (protection.versioning_enabled?.value !== true || protection.state_bucket_name?.value !== backend.bucket) { + throw new Error("Apply workload bucket versioning first"); + } + } else { + const protection = readOutputs("seed-protection"); + if (protection.versioning_enabled?.value !== true || protection.state_bucket_name?.value !== backend.bucket) { + throw new Error("Apply management bucket versioning first"); + } + } + const backendFile = resolve(local, root, "backend.hcl"); + const settings = [ + `bucket = ${JSON.stringify(backend.bucket)}`, + `key = ${JSON.stringify(`configurator/${config.NAME_PREFIX}/${root}/terraform.tfstate`)}`, + `region = ${JSON.stringify(backend.region)}`, + `endpoints = { s3 = ${JSON.stringify(backend.endpoint)} }`, + "use_path_style = true", "use_lockfile = true", "skip_credentials_validation = true", + "skip_region_validation = true", "skip_requesting_account_id = true", "skip_metadata_api_check = true", + "skip_s3_checksum = true", + ]; + writeFileSync(backendFile, `${settings.join("\n")}\n`, { mode: 0o600 }); + args.push(`-backend-config=${backendFile}`); + } + } + const planPath = resolve(local, root, "review.tfplan"); + const manifestPath = resolve(local, root, "review.json"); + const contextHash = createHash("sha256").update(JSON.stringify(Object.entries(config).sort())).digest("hex"); + const hashPlan = () => createHash("sha256").update(readFileSync(planPath)).digest("hex"); + if (command === "plan") { + writeFileSync(manifestPath, JSON.stringify({ status: "planning" }), { mode: 0o600 }); + if (extra.some((arg) => arg.startsWith("-out"))) throw new Error("Plan path is managed by the wrapper"); + args.push(`-out=${planPath}`, "-input=false"); + } + if (command === "apply") { + if (extra.length) throw new Error("Apply takes no additional options; it applies only the saved review plan"); + if (!existsSync(planPath) || !existsSync(manifestPath)) throw new Error("Create and review a plan first"); + const manifest = JSON.parse(readFileSync(manifestPath, "utf8")); + if (!isApplicablePlan(manifest, contextHash, hashPlan())) { + throw new Error("Saved plan is failed, changed, expired or belongs to different inputs; plan again"); + } + args.push(planPath); + } + const result = spawnSync(binary, args, { env, stdio: "inherit" }); + if (result.error) throw new Error("OpenTofu could not start"); + if (command === "plan" && [0, 2].includes(result.status) && existsSync(planPath)) { + writeFileSync(manifestPath, JSON.stringify({ status: "ready", contextHash, planHash: hashPlan(), createdAt: new Date().toISOString() }), { mode: 0o600 }); + } + if (command === "apply") { + writeFileSync(manifestPath, JSON.stringify({ status: "consumed" }), { mode: 0o600 }); + } + process.exitCode = result.status ?? 1; +} catch (error) { + // No child-process output or credentials are incorporated in these operator errors. + console.error(error instanceof Error ? error.message : "Infrastructure command failed"); + process.exitCode = 1; +} diff --git a/landing-zone-configurator/mise.toml b/landing-zone-configurator/mise.toml new file mode 100644 index 0000000..e65a869 --- /dev/null +++ b/landing-zone-configurator/mise.toml @@ -0,0 +1,6 @@ +[tools] +node = "24.21.0" + +opentofu = "1.12.6" + +# Go is pinned when the HCL adapter is introduced. diff --git a/landing-zone-configurator/runner/README.md b/landing-zone-configurator/runner/README.md new file mode 100644 index 0000000..f0cfc5c --- /dev/null +++ b/landing-zone-configurator/runner/README.md @@ -0,0 +1,5 @@ +# OpenTofu-Runner + +Isolierte Laufzeit und Run-Protokoll; getrennt von API und Worker. Gepinnte Engine, Provider und Accelerator-Revision. Die Implementierungssprache des kleinen Supervisors bleibt offen; eigene Go-Laufzeit nur bei nachgewiesenem Vorteil. + +Keine Backend-/Model-Serving-Bindings; nur runbezogene Kunden-Credentials und State-Zugriffe. CF-Eignung, Limits und private Konnektivität vor Implementierung nachweisen. diff --git a/landing-zone-configurator/tools/hcl-adapter/README.md b/landing-zone-configurator/tools/hcl-adapter/README.md new file mode 100644 index 0000000..c59fd31 --- /dev/null +++ b/landing-zone-configurator/tools/hcl-adapter/README.md @@ -0,0 +1,3 @@ +# HCL-Importadapter + +Empfehlung: kleiner Go-Adapter mit HashiCorp HCL v2 für bestehende `.tfvars`. Versionierte JSON-Schnittstelle, z. B. stdin/stdout; kein eigener Netzwerkdienst zum Start. Keine freie Codeausführung oder Secret-Auflösung. Fachregeln verbleiben im Domain-Paket. From 75962cb56d127bf502315f3b043063d6118f4931 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Weberru=C3=9F?= Date: Tue, 29 Sep 2026 18:41:53 +0200 Subject: [PATCH 002/175] ci(configurator): enable plan-only validation on feature branch --- .github/workflows/configurator-bootstrap.yml | 11 ++++++++--- .github/workflows/validate-configurator.yml | 2 +- landing-zone-configurator/docs/planning.md | 2 ++ landing-zone-configurator/infra/ci/README.md | 4 ++++ .../infra/ci/configure-github.mjs | 9 ++++++--- .../infra/ci/context.mjs | 11 +++++++---- .../infra/ci/context.test.mjs | 10 +++++++++- .../infra/ci/github-environments.json | 19 ++++++++++++++++--- 8 files changed, 53 insertions(+), 15 deletions(-) diff --git a/.github/workflows/configurator-bootstrap.yml b/.github/workflows/configurator-bootstrap.yml index 3dc18ab..c13418d 100644 --- a/.github/workflows/configurator-bootstrap.yml +++ b/.github/workflows/configurator-bootstrap.yml @@ -1,6 +1,11 @@ name: Configurator Bootstrap on: + push: + branches: [feature/landing-zone-configurator] + paths: + - 'landing-zone-configurator/infra/**' + - '.github/workflows/configurator-bootstrap.yml' workflow_dispatch: inputs: root: @@ -22,11 +27,11 @@ concurrency: env: LZC_ENVIRONMENT: lzc-dev - LZC_ROOT: ${{ inputs.root }} + LZC_ROOT: ${{ inputs.root || 'bootstrap' }} jobs: plan: - if: github.ref == 'refs/heads/main' && github.run_attempt == 1 + if: github.run_attempt == 1 && ((github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main') || (github.event_name == 'push' && github.ref == 'refs/heads/feature/landing-zone-configurator')) runs-on: ubuntu-latest timeout-minutes: 20 environment: lzc-dev-bootstrap-plan @@ -68,7 +73,7 @@ jobs: apply: needs: plan - if: inputs.apply && github.run_attempt == 1 + if: github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' && inputs.apply && github.run_attempt == 1 runs-on: ubuntu-latest timeout-minutes: 20 environment: lzc-dev-bootstrap-apply diff --git a/.github/workflows/validate-configurator.yml b/.github/workflows/validate-configurator.yml index 8066754..f9903b9 100644 --- a/.github/workflows/validate-configurator.yml +++ b/.github/workflows/validate-configurator.yml @@ -7,7 +7,7 @@ on: - '.github/workflows/validate-configurator.yml' - '.github/workflows/configurator-bootstrap.yml' push: - branches: [main] + branches: [main, feature/landing-zone-configurator] paths: - 'landing-zone-configurator/**' - '.github/workflows/validate-configurator.yml' diff --git a/landing-zone-configurator/docs/planning.md b/landing-zone-configurator/docs/planning.md index bb6c294..a23bc7b 100644 --- a/landing-zone-configurator/docs/planning.md +++ b/landing-zone-configurator/docs/planning.md @@ -594,3 +594,5 @@ Die Entwicklung erfolgt auf `feature/landing-zone-configurator`; eine Übernahme Die GitHub-Environment-Regeln und der Deployment-Workflow erlauben weiterhin ausschließlich `main`. Feature-Branch-Entwicklung aktiviert daher keine Cloud-Deployments. Ein späterer Testbetrieb vom Feature-Branch braucht eine gezielte Anpassung dieser Regeln; der Branch wird dafür nicht automatisch nach main übernommen. GitHub wertet CODEOWNERS für Pull Requests aus dem jeweiligen Zielbranch aus: Für einen späteren PR nach main gilt zunächst die dortige bisherige Regel. CODEOWNERS setzt außerdem keine Branch-Protection-Regeln außer Kraft; eigene PRs können nicht selbst freigegeben werden. + +Feature-Branch-Test freigegeben: Push-Trigger für Bootstrap-Plan auf `feature/landing-zone-configurator`, entsprechende zusätzliche Branch-Regel ausschließlich im Plan-Environment. Apply/Recovery bleiben main-only. Kein Merge nach main für den Test erforderlich. diff --git a/landing-zone-configurator/infra/ci/README.md b/landing-zone-configurator/infra/ci/README.md index 2dd0b5c..c1c0024 100644 --- a/landing-zone-configurator/infra/ci/README.md +++ b/landing-zone-configurator/infra/ci/README.md @@ -42,3 +42,7 @@ Der Apply-Prüfmechanismus bindet Plan-Hash, Repository, Commit, Workflow-Lauf, - Beide Workflow-Dateien mit actionlint 1.7.7 validiert. - Zwölf Node-Tests für Planbindung, Schlüsselhaltung und Apply-Sperre erfolgreich. - Kein GitHub-Workflow gestartet und kein Plattform-Apply durchgeführt. + +## Erster Test vom Feature-Branch + +Pushes auf den exakten Branch `feature/landing-zone-configurator` starten bei Änderungen an IaC oder Bootstrap-Workflow automatisch einen Bootstrap-Plan. Nur das Plan-Environment erlaubt zusätzlich diesen Branch. Apply und Recovery bleiben auf main beschränkt; Feature-Pushes können keinen Apply-Job starten. Der CI-Einstieg prüft Branch/Event ebenfalls und bindet beides an das Planmanifest. Die Validierungs-CI läuft nun ebenfalls auf diesem Feature-Branch. diff --git a/landing-zone-configurator/infra/ci/configure-github.mjs b/landing-zone-configurator/infra/ci/configure-github.mjs index 27aaf8c..88d8b4b 100644 --- a/landing-zone-configurator/infra/ci/configure-github.mjs +++ b/landing-zone-configurator/infra/ci/configure-github.mjs @@ -44,9 +44,12 @@ for(const target of spec.environments) { api(path,"PUT",{wait_timer:0,prevent_self_review:false,can_admins_bypass:false, reviewers:target.review?[{type:"User",id:reviewer.id}]:[], deployment_branch_policy:{protected_branches:false,custom_branch_policies:true}}); + const branches=target.branches ?? [spec.branch]; const policies=api(`${path}/deployment-branch-policies`).branch_policies; - if(policies.some(p=>p.name!==spec.branch||p.type!=="branch"))throw new Error("Unexpected existing deployment policy; inspect before uploading secrets"); - if(!policies.some(p=>p.name===spec.branch&&p.type==="branch"))api(`${path}/deployment-branch-policies`,"POST",{name:spec.branch,type:"branch"}); + if(policies.some(p=>!branches.includes(p.name)||p.type!=="branch"))throw new Error("Unexpected existing deployment policy; inspect before uploading secrets"); + for(const branch of branches) { + if(!policies.some(p=>p.name===branch&&p.type==="branch"))api(`${path}/deployment-branch-policies`,"POST",{name:branch,type:"branch"}); + } const checked=api(path); if(checked.deployment_branch_policy?.custom_branch_policies!==true)throw new Error("Environment branch protection missing"); if(target.review&&!checked.protection_rules.some(r=>r.type==="required_reviewers"&&r.reviewers.some(x=>x.reviewer.id===reviewer.id)))throw new Error("Required reviewer missing"); @@ -54,5 +57,5 @@ for(const target of spec.environments) { for(const [name,value] of Object.entries(target.name.endsWith("recovery")?recoverySecrets:deploySecrets)) { gh(["secret","set",name,"--repo",spec.repository,"--env",target.name],value); } - console.log(`Configured ${target.name}: main-only, review=${target.review}; secret values not displayed.`); + console.log(`Configured ${target.name}: branches=${branches.join(",")}, review=${target.review}; secret values not displayed.`); } diff --git a/landing-zone-configurator/infra/ci/context.mjs b/landing-zone-configurator/infra/ci/context.mjs index 820e39a..09d9592 100644 --- a/landing-zone-configurator/infra/ci/context.mjs +++ b/landing-zone-configurator/infra/ci/context.mjs @@ -9,8 +9,11 @@ export function deploymentInputs(env) { const root = required("LZC_ROOT"); if (!["bootstrap", "backend"].includes(root)) throw new Error("Unsupported CI root"); if (required("LZC_ENVIRONMENT") !== "lzc-dev") throw new Error("Unsupported environment"); - if (required("GITHUB_REF") !== "refs/heads/main") throw new Error("Deployment requires main"); - if (required("GITHUB_EVENT_NAME") !== "workflow_dispatch") throw new Error("Deployment requires manual dispatch"); + const ref = required("GITHUB_REF"); + const event = required("GITHUB_EVENT_NAME"); + const manualMain = ref === "refs/heads/main" && event === "workflow_dispatch"; + const featurePlan = ref === "refs/heads/feature/landing-zone-configurator" && event === "push" && root === "bootstrap"; + if (!manualMain && !featurePlan) throw new Error("Unsupported deployment trigger or branch"); if (required("GITHUB_RUN_ATTEMPT") !== "1") throw new Error("Start a new workflow run instead of retrying a previous plan"); const commit = required("GITHUB_SHA"); if (!/^[a-f0-9]{40}$/.test(commit)) throw new Error("Invalid commit"); @@ -25,7 +28,7 @@ export function deploymentInputs(env) { const expiration = required("LZC_CREDENTIAL_EXPIRATION"); if (!Number.isFinite(Date.parse(expiration))) throw new Error("Invalid expiration"); return { root, environment:"lzc-dev", project, region, prefix, bucket, expiration, - commit, repository:required("GITHUB_REPOSITORY"), run:required("GITHUB_RUN_ID"), attempt:"1" }; + commit, ref, event, repository:required("GITHUB_REPOSITORY"), run:required("GITHUB_RUN_ID"), attempt:"1" }; } export function planContext(inputs, sourceDigest) { return digest(JSON.stringify({ inputs, sourceDigest })); @@ -36,7 +39,7 @@ export function assertApplyExecution(safety, env) { safety.concurrencyGroup !== "configurator-lzc-dev-mutation") { throw new Error("Apply requires the approved single-writer configuration"); } - if (env.GITHUB_ACTIONS !== "true" || + if (env.GITHUB_ACTIONS !== "true" || env.GITHUB_REF !== "refs/heads/main" || env.GITHUB_EVENT_NAME !== "workflow_dispatch" || env.GITHUB_WORKFLOW_REF !== "stackitcloud/stackit-landing-zone/.github/workflows/configurator-bootstrap.yml@refs/heads/main") { throw new Error("Remote apply is restricted to the serialized GitHub Actions workflow"); } diff --git a/landing-zone-configurator/infra/ci/context.test.mjs b/landing-zone-configurator/infra/ci/context.test.mjs index 534e641..22ebfe1 100644 --- a/landing-zone-configurator/infra/ci/context.test.mjs +++ b/landing-zone-configurator/infra/ci/context.test.mjs @@ -31,10 +31,18 @@ test("local invocation cannot apply even with a valid plan context",()=>{ test("single-writer apply requires approved workflow identity",()=>{ const safety={mode:"github-actions-single-writer",concurrencyGroup:"configurator-lzc-dev-mutation"}; - const trusted={GITHUB_ACTIONS:"true",GITHUB_WORKFLOW_REF:"stackitcloud/stackit-landing-zone/.github/workflows/configurator-bootstrap.yml@refs/heads/main"}; + const trusted={GITHUB_ACTIONS:"true",GITHUB_REF:"refs/heads/main",GITHUB_EVENT_NAME:"workflow_dispatch",GITHUB_WORKFLOW_REF:"stackitcloud/stackit-landing-zone/.github/workflows/configurator-bootstrap.yml@refs/heads/main"}; assert.doesNotThrow(()=>assertApplyExecution(safety,trusted)); for(const patch of [{GITHUB_ACTIONS:"false"},{GITHUB_WORKFLOW_REF:"other/workflow@refs/heads/main"}]) { assert.throws(()=>assertApplyExecution(safety,{...trusted,...patch})); } assert.throws(()=>assertApplyExecution({...safety,concurrencyGroup:"other"},trusted)); }); + +test("feature branch push can plan bootstrap but cannot apply",()=>{ + const feature={...env,GITHUB_REF:"refs/heads/feature/landing-zone-configurator",GITHUB_EVENT_NAME:"push"}; + assert.equal(deploymentInputs(feature).root,"bootstrap"); + assert.throws(()=>deploymentInputs({...feature,LZC_ROOT:"backend"})); + assert.throws(()=>deploymentInputs({...feature,GITHUB_REF:"refs/heads/feature/other"})); + assert.throws(()=>assertApplyExecution({mode:"github-actions-single-writer",concurrencyGroup:"configurator-lzc-dev-mutation"},{...feature,GITHUB_ACTIONS:"true",GITHUB_WORKFLOW_REF:"stackitcloud/stackit-landing-zone/.github/workflows/configurator-bootstrap.yml@refs/heads/main"})); +}); diff --git a/landing-zone-configurator/infra/ci/github-environments.json b/landing-zone-configurator/infra/ci/github-environments.json index 8af926a..73fc001 100644 --- a/landing-zone-configurator/infra/ci/github-environments.json +++ b/landing-zone-configurator/infra/ci/github-environments.json @@ -3,8 +3,21 @@ "branch": "main", "reviewer": "lweberru", "environments": [ - { "name": "lzc-dev-bootstrap-plan", "review": false }, - { "name": "lzc-dev-bootstrap-apply", "review": true }, - { "name": "lzc-dev-recovery", "review": true } + { + "name": "lzc-dev-bootstrap-plan", + "review": false, + "branches": [ + "main", + "feature/landing-zone-configurator" + ] + }, + { + "name": "lzc-dev-bootstrap-apply", + "review": true + }, + { + "name": "lzc-dev-recovery", + "review": true + } ] } From 59edaca69201d14083d7a6afd24cdf6cc3d7d267 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Weberru=C3=9F?= Date: Tue, 29 Sep 2026 19:01:06 +0200 Subject: [PATCH 003/175] ci(configurator): allow explicitly approved feature bootstrap apply --- .github/workflows/configurator-bootstrap.yml | 3 ++- landing-zone-configurator/infra/ci/README.md | 4 ++++ landing-zone-configurator/infra/ci/context.mjs | 7 +++++-- landing-zone-configurator/infra/ci/context.test.mjs | 9 +++++++++ .../infra/ci/github-environments.json | 6 +++++- 5 files changed, 25 insertions(+), 4 deletions(-) diff --git a/.github/workflows/configurator-bootstrap.yml b/.github/workflows/configurator-bootstrap.yml index c13418d..6055a87 100644 --- a/.github/workflows/configurator-bootstrap.yml +++ b/.github/workflows/configurator-bootstrap.yml @@ -73,7 +73,7 @@ jobs: apply: needs: plan - if: github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' && inputs.apply && github.run_attempt == 1 + if: github.run_attempt == 1 && ((github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' && inputs.apply) || (github.event_name == 'push' && github.ref == 'refs/heads/feature/landing-zone-configurator' && github.sha == vars.LZC_BOOTSTRAP_APPLY_COMMIT)) runs-on: ubuntu-latest timeout-minutes: 20 environment: lzc-dev-bootstrap-apply @@ -98,6 +98,7 @@ jobs: - name: Apply exactly the reviewed plan run: node infra/ci/run.mjs apply env: + LZC_APPROVED_APPLY_COMMIT: ${{ vars.LZC_BOOTSTRAP_APPLY_COMMIT }} LZC_PROJECT_ID: ${{ vars.LZC_PROJECT_ID }} LZC_REGION: ${{ vars.LZC_REGION }} LZC_NAME_PREFIX: ${{ vars.LZC_NAME_PREFIX }} diff --git a/landing-zone-configurator/infra/ci/README.md b/landing-zone-configurator/infra/ci/README.md index c1c0024..2d67e8c 100644 --- a/landing-zone-configurator/infra/ci/README.md +++ b/landing-zone-configurator/infra/ci/README.md @@ -46,3 +46,7 @@ Der Apply-Prüfmechanismus bindet Plan-Hash, Repository, Commit, Workflow-Lauf, ## Erster Test vom Feature-Branch Pushes auf den exakten Branch `feature/landing-zone-configurator` starten bei Änderungen an IaC oder Bootstrap-Workflow automatisch einen Bootstrap-Plan. Nur das Plan-Environment erlaubt zusätzlich diesen Branch. Apply und Recovery bleiben auf main beschränkt; Feature-Pushes können keinen Apply-Job starten. Der CI-Einstieg prüft Branch/Event ebenfalls und bindet beides an das Planmanifest. Die Validierungs-CI läuft nun ebenfalls auf diesem Feature-Branch. + +## Einmaliger Bootstrap-Apply vom Feature-Branch + +Ein Feature-Push darf zusätzlich einen Apply-Job anfordern, wenn die Repository-Variable `LZC_BOOTSTRAP_APPLY_COMMIT` exakt seiner vollständigen Commit-SHA entspricht. Der CI-Einstieg prüft diesen Wert erneut, erlaubt dabei nur Root bootstrap und verlangt die korrekte Workflow-Identität. Das Apply-Environment erlaubt den exakten Feature-Branch, behält aber seine Reviewer-Freigabe. Die Variable wird nach dem Lauf entfernt; normale Feature-Pushes bleiben Plan-only. Wiederholungen benötigen einen neuen Run/Commit und einen neu geprüften Plan. diff --git a/landing-zone-configurator/infra/ci/context.mjs b/landing-zone-configurator/infra/ci/context.mjs index 09d9592..a6389ab 100644 --- a/landing-zone-configurator/infra/ci/context.mjs +++ b/landing-zone-configurator/infra/ci/context.mjs @@ -39,8 +39,11 @@ export function assertApplyExecution(safety, env) { safety.concurrencyGroup !== "configurator-lzc-dev-mutation") { throw new Error("Apply requires the approved single-writer configuration"); } - if (env.GITHUB_ACTIONS !== "true" || env.GITHUB_REF !== "refs/heads/main" || env.GITHUB_EVENT_NAME !== "workflow_dispatch" || - env.GITHUB_WORKFLOW_REF !== "stackitcloud/stackit-landing-zone/.github/workflows/configurator-bootstrap.yml@refs/heads/main") { + const main = env.GITHUB_REF === "refs/heads/main" && env.GITHUB_EVENT_NAME === "workflow_dispatch"; + const feature = env.GITHUB_REF === "refs/heads/feature/landing-zone-configurator" && env.GITHUB_EVENT_NAME === "push" && + /^[a-f0-9]{40}$/.test(env.LZC_APPROVED_APPLY_COMMIT ?? "") && env.GITHUB_SHA === env.LZC_APPROVED_APPLY_COMMIT && env.LZC_ROOT === "bootstrap"; + const workflow = `stackitcloud/stackit-landing-zone/.github/workflows/configurator-bootstrap.yml@${env.GITHUB_REF}`; + if (env.GITHUB_ACTIONS !== "true" || (!main && !feature) || env.GITHUB_WORKFLOW_REF !== workflow) { throw new Error("Remote apply is restricted to the serialized GitHub Actions workflow"); } } diff --git a/landing-zone-configurator/infra/ci/context.test.mjs b/landing-zone-configurator/infra/ci/context.test.mjs index 22ebfe1..17e619a 100644 --- a/landing-zone-configurator/infra/ci/context.test.mjs +++ b/landing-zone-configurator/infra/ci/context.test.mjs @@ -46,3 +46,12 @@ test("feature branch push can plan bootstrap but cannot apply",()=>{ assert.throws(()=>deploymentInputs({...feature,GITHUB_REF:"refs/heads/feature/other"})); assert.throws(()=>assertApplyExecution({mode:"github-actions-single-writer",concurrencyGroup:"configurator-lzc-dev-mutation"},{...feature,GITHUB_ACTIONS:"true",GITHUB_WORKFLOW_REF:"stackitcloud/stackit-landing-zone/.github/workflows/configurator-bootstrap.yml@refs/heads/main"})); }); + +test("feature apply needs exact explicitly approved commit and correct workflow",()=>{ + const safety={mode:"github-actions-single-writer",concurrencyGroup:"configurator-lzc-dev-mutation"}; + const feature={...env,GITHUB_ACTIONS:"true",GITHUB_REF:"refs/heads/feature/landing-zone-configurator",GITHUB_EVENT_NAME:"push",LZC_APPROVED_APPLY_COMMIT:env.GITHUB_SHA,GITHUB_WORKFLOW_REF:"stackitcloud/stackit-landing-zone/.github/workflows/configurator-bootstrap.yml@refs/heads/feature/landing-zone-configurator"}; + assert.doesNotThrow(()=>assertApplyExecution(safety,feature)); + for(const patch of [{LZC_APPROVED_APPLY_COMMIT:""},{LZC_APPROVED_APPLY_COMMIT:"b".repeat(40)},{GITHUB_WORKFLOW_REF:"wrong"},{LZC_ROOT:"backend"}]) { + assert.throws(()=>assertApplyExecution(safety,{...feature,...patch})); + } +}); diff --git a/landing-zone-configurator/infra/ci/github-environments.json b/landing-zone-configurator/infra/ci/github-environments.json index 73fc001..2c69e2b 100644 --- a/landing-zone-configurator/infra/ci/github-environments.json +++ b/landing-zone-configurator/infra/ci/github-environments.json @@ -13,7 +13,11 @@ }, { "name": "lzc-dev-bootstrap-apply", - "review": true + "review": true, + "branches": [ + "main", + "feature/landing-zone-configurator" + ] }, { "name": "lzc-dev-recovery", From 171a8a33799c2fb447a546bb89c4c295220f7374 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Weberru=C3=9F?= Date: Tue, 29 Sep 2026 19:03:59 +0200 Subject: [PATCH 004/175] docs(configurator): record successful bootstrap CI apply --- landing-zone-configurator/docs/planning.md | 2 ++ landing-zone-configurator/infra/bootstrap/README.md | 6 ++++++ landing-zone-configurator/infra/ci/README.md | 10 +++++++++- 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/landing-zone-configurator/docs/planning.md b/landing-zone-configurator/docs/planning.md index a23bc7b..a7b93c0 100644 --- a/landing-zone-configurator/docs/planning.md +++ b/landing-zone-configurator/docs/planning.md @@ -596,3 +596,5 @@ Die GitHub-Environment-Regeln und der Deployment-Workflow erlauben weiterhin aus GitHub wertet CODEOWNERS für Pull Requests aus dem jeweiligen Zielbranch aus: Für einen späteren PR nach main gilt zunächst die dortige bisherige Regel. CODEOWNERS setzt außerdem keine Branch-Protection-Regeln außer Kraft; eigene PRs können nicht selbst freigegeben werden. Feature-Branch-Test freigegeben: Push-Trigger für Bootstrap-Plan auf `feature/landing-zone-configurator`, entsprechende zusätzliche Branch-Regel ausschließlich im Plan-Environment. Apply/Recovery bleiben main-only. Kein Merge nach main für den Test erforderlich. + +Bootstrap-Apply abgeschlossen: GitHub Run 36601941540 auf dem Feature-Branch, drei Ressourcen erstellt. Verschlüsselter Remote-State unabhängig geprüft; einmalige Commit-Freigabe entfernt. Nächster Schritt: separater Backend-Root zur Versionierung des Workload-State-Buckets. main bleibt unverändert. diff --git a/landing-zone-configurator/infra/bootstrap/README.md b/landing-zone-configurator/infra/bootstrap/README.md index 1c2111a..52bbe50 100644 --- a/landing-zone-configurator/infra/bootstrap/README.md +++ b/landing-zone-configurator/infra/bootstrap/README.md @@ -9,3 +9,9 @@ Erster echter Plan am 2026-09-29 für lzc-dev/eu01 erfolgreich: **3 anlegen, 0 [Operator-Anleitung](../README.md). Mock-Tests weisen die Struktur nach; Aktivierung, S3-Kompatibilität und Berechtigungen zum Schreiben sind erst durch den anschließenden Integrationstest belegt. Aktuell: Remote-Backend initialisiert, echter Plan mit 3 Create erfolgreich; für diesen Root weiterhin kein Apply. + +## Erster CI-Apply erfolgreich + +Am 2026-09-29 wurde Bootstrap auf dem Feature-Branch über [Run 36601941540](https://github.com/stackitcloud/stackit-landing-zone/actions/runs/36601941540) angewendet: drei Ressourcen erstellt, keine Änderungen oder Löschungen. Der verschlüsselte State im Verwaltungs-Bucket enthält alle drei Ressourcen. Die einmalige Commit-Freigabe wurde anschließend entfernt. Die zuvor genannten Angaben „kein Apply“ beschreiben den früheren Planungsstand. + +Der nächste Infrastruktur-Schritt ist der separate Backend-Root zur Versionierung des neu erstellten Workload-State-Buckets. Dieser Root wurde noch nicht angewendet. diff --git a/landing-zone-configurator/infra/ci/README.md b/landing-zone-configurator/infra/ci/README.md index 2d67e8c..10e43e3 100644 --- a/landing-zone-configurator/infra/ci/README.md +++ b/landing-zone-configurator/infra/ci/README.md @@ -1,6 +1,6 @@ # Bootstrap-CI -Implementiert, noch nicht auf GitHub veröffentlicht/ausgeführt. Der Workflow `.github/workflows/configurator-bootstrap.yml` wird manuell auf main gestartet: zuerst Root `bootstrap`, anschließend `backend`. Plan und Apply sind getrennte Jobs; Apply verlangt die Environment-Freigabe und verwendet das verschlüsselte Plan-Artefakt desselben Runs. Re-runs werden abgewiesen: immer einen neuen Workflow-Lauf mit neuem Plan starten. +Implementiert und auf dem Feature-Branch veröffentlicht; erster Bootstrap-Plan und Apply erfolgreich. Der Workflow `.github/workflows/configurator-bootstrap.yml` wird manuell auf main gestartet: zuerst Root `bootstrap`, anschließend `backend`. Plan und Apply sind getrennte Jobs; Apply verlangt die Environment-Freigabe und verwendet das verschlüsselte Plan-Artefakt desselben Runs. Re-runs werden abgewiesen: immer einen neuen Workflow-Lauf mit neuem Plan starten. ## Betriebsentscheidung: ausschließlich serialisierte CI-Applies @@ -50,3 +50,11 @@ Pushes auf den exakten Branch `feature/landing-zone-configurator` starten bei Ä ## Einmaliger Bootstrap-Apply vom Feature-Branch Ein Feature-Push darf zusätzlich einen Apply-Job anfordern, wenn die Repository-Variable `LZC_BOOTSTRAP_APPLY_COMMIT` exakt seiner vollständigen Commit-SHA entspricht. Der CI-Einstieg prüft diesen Wert erneut, erlaubt dabei nur Root bootstrap und verlangt die korrekte Workflow-Identität. Das Apply-Environment erlaubt den exakten Feature-Branch, behält aber seine Reviewer-Freigabe. Die Variable wird nach dem Lauf entfernt; normale Feature-Pushes bleiben Plan-only. Wiederholungen benötigen einen neuen Run/Commit und einen neu geprüften Plan. + +## Ausgeführte Läufe + +- Erster Feature-Plan: [36599754659](https://github.com/stackitcloud/stackit-landing-zone/actions/runs/36599754659), erfolgreich, Apply übersprungen. +- Erster ausdrücklich freigegebener Bootstrap-Apply: [36601941540](https://github.com/stackitcloud/stackit-landing-zone/actions/runs/36601941540), Commit `59edaca69201d14083d7a6afd24cdf6cc3d7d267`, Plan und Apply erfolgreich. Drei Ressourcen erstellt; verschlüsselter Remote-State unabhängig überprüft. Die Variable `LZC_BOOTSTRAP_APPLY_COMMIT` wurde nach Abschluss entfernt. +- Begleitende Validierung: [36601941480](https://github.com/stackitcloud/stackit-landing-zone/actions/runs/36601941480), alle Jobs erfolgreich. + +Keine Übernahme nach main. Der separate Backend-Root und Plattformdienste wurden noch nicht angewendet. From d68c0a1a1199c34cf529ef0a87d62943c35a0585 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Weberru=C3=9F?= Date: Tue, 29 Sep 2026 19:07:11 +0200 Subject: [PATCH 005/175] ci(configurator): bind feature apply approval to selected root --- .github/workflows/configurator-bootstrap.yml | 3 ++- landing-zone-configurator/infra/ci/README.md | 4 ++++ landing-zone-configurator/infra/ci/context.mjs | 4 ++-- landing-zone-configurator/infra/ci/context.test.mjs | 12 ++++++++++-- 4 files changed, 18 insertions(+), 5 deletions(-) diff --git a/.github/workflows/configurator-bootstrap.yml b/.github/workflows/configurator-bootstrap.yml index 6055a87..5278044 100644 --- a/.github/workflows/configurator-bootstrap.yml +++ b/.github/workflows/configurator-bootstrap.yml @@ -27,7 +27,7 @@ concurrency: env: LZC_ENVIRONMENT: lzc-dev - LZC_ROOT: ${{ inputs.root || 'bootstrap' }} + LZC_ROOT: ${{ inputs.root || vars.LZC_BOOTSTRAP_ROOT || 'bootstrap' }} jobs: plan: @@ -99,6 +99,7 @@ jobs: run: node infra/ci/run.mjs apply env: LZC_APPROVED_APPLY_COMMIT: ${{ vars.LZC_BOOTSTRAP_APPLY_COMMIT }} + LZC_APPROVED_APPLY_ROOT: ${{ vars.LZC_BOOTSTRAP_ROOT }} LZC_PROJECT_ID: ${{ vars.LZC_PROJECT_ID }} LZC_REGION: ${{ vars.LZC_REGION }} LZC_NAME_PREFIX: ${{ vars.LZC_NAME_PREFIX }} diff --git a/landing-zone-configurator/infra/ci/README.md b/landing-zone-configurator/infra/ci/README.md index 10e43e3..8028271 100644 --- a/landing-zone-configurator/infra/ci/README.md +++ b/landing-zone-configurator/infra/ci/README.md @@ -58,3 +58,7 @@ Ein Feature-Push darf zusätzlich einen Apply-Job anfordern, wenn die Repository - Begleitende Validierung: [36601941480](https://github.com/stackitcloud/stackit-landing-zone/actions/runs/36601941480), alle Jobs erfolgreich. Keine Übernahme nach main. Der separate Backend-Root und Plattformdienste wurden noch nicht angewendet. + +## Backend-Root über den Feature-Branch + +Für einen gezielten Backend-Lauf setzt der Operator zusätzlich `LZC_BOOTSTRAP_ROOT=backend`. Die Freigabe ist an Commit **und** Root gebunden; nur bootstrap/backend sind erlaubt. Nach dem Lauf werden Root-Auswahl und Commit-Freigabe entfernt. Ohne diese Variablen starten Feature-Pushes weiterhin ausschließlich einen Bootstrap-Plan. diff --git a/landing-zone-configurator/infra/ci/context.mjs b/landing-zone-configurator/infra/ci/context.mjs index a6389ab..c095dd2 100644 --- a/landing-zone-configurator/infra/ci/context.mjs +++ b/landing-zone-configurator/infra/ci/context.mjs @@ -12,7 +12,7 @@ export function deploymentInputs(env) { const ref = required("GITHUB_REF"); const event = required("GITHUB_EVENT_NAME"); const manualMain = ref === "refs/heads/main" && event === "workflow_dispatch"; - const featurePlan = ref === "refs/heads/feature/landing-zone-configurator" && event === "push" && root === "bootstrap"; + const featurePlan = ref === "refs/heads/feature/landing-zone-configurator" && event === "push"; if (!manualMain && !featurePlan) throw new Error("Unsupported deployment trigger or branch"); if (required("GITHUB_RUN_ATTEMPT") !== "1") throw new Error("Start a new workflow run instead of retrying a previous plan"); const commit = required("GITHUB_SHA"); @@ -41,7 +41,7 @@ export function assertApplyExecution(safety, env) { } const main = env.GITHUB_REF === "refs/heads/main" && env.GITHUB_EVENT_NAME === "workflow_dispatch"; const feature = env.GITHUB_REF === "refs/heads/feature/landing-zone-configurator" && env.GITHUB_EVENT_NAME === "push" && - /^[a-f0-9]{40}$/.test(env.LZC_APPROVED_APPLY_COMMIT ?? "") && env.GITHUB_SHA === env.LZC_APPROVED_APPLY_COMMIT && env.LZC_ROOT === "bootstrap"; + /^[a-f0-9]{40}$/.test(env.LZC_APPROVED_APPLY_COMMIT ?? "") && env.GITHUB_SHA === env.LZC_APPROVED_APPLY_COMMIT && ["bootstrap", "backend"].includes(env.LZC_ROOT) && env.LZC_ROOT === env.LZC_APPROVED_APPLY_ROOT; const workflow = `stackitcloud/stackit-landing-zone/.github/workflows/configurator-bootstrap.yml@${env.GITHUB_REF}`; if (env.GITHUB_ACTIONS !== "true" || (!main && !feature) || env.GITHUB_WORKFLOW_REF !== workflow) { throw new Error("Remote apply is restricted to the serialized GitHub Actions workflow"); diff --git a/landing-zone-configurator/infra/ci/context.test.mjs b/landing-zone-configurator/infra/ci/context.test.mjs index 17e619a..b345af5 100644 --- a/landing-zone-configurator/infra/ci/context.test.mjs +++ b/landing-zone-configurator/infra/ci/context.test.mjs @@ -42,16 +42,24 @@ test("single-writer apply requires approved workflow identity",()=>{ test("feature branch push can plan bootstrap but cannot apply",()=>{ const feature={...env,GITHUB_REF:"refs/heads/feature/landing-zone-configurator",GITHUB_EVENT_NAME:"push"}; assert.equal(deploymentInputs(feature).root,"bootstrap"); - assert.throws(()=>deploymentInputs({...feature,LZC_ROOT:"backend"})); + assert.equal(deploymentInputs({...feature,LZC_ROOT:"backend"}).root,"backend"); assert.throws(()=>deploymentInputs({...feature,GITHUB_REF:"refs/heads/feature/other"})); assert.throws(()=>assertApplyExecution({mode:"github-actions-single-writer",concurrencyGroup:"configurator-lzc-dev-mutation"},{...feature,GITHUB_ACTIONS:"true",GITHUB_WORKFLOW_REF:"stackitcloud/stackit-landing-zone/.github/workflows/configurator-bootstrap.yml@refs/heads/main"})); }); test("feature apply needs exact explicitly approved commit and correct workflow",()=>{ const safety={mode:"github-actions-single-writer",concurrencyGroup:"configurator-lzc-dev-mutation"}; - const feature={...env,GITHUB_ACTIONS:"true",GITHUB_REF:"refs/heads/feature/landing-zone-configurator",GITHUB_EVENT_NAME:"push",LZC_APPROVED_APPLY_COMMIT:env.GITHUB_SHA,GITHUB_WORKFLOW_REF:"stackitcloud/stackit-landing-zone/.github/workflows/configurator-bootstrap.yml@refs/heads/feature/landing-zone-configurator"}; + const feature={...env,GITHUB_ACTIONS:"true",GITHUB_REF:"refs/heads/feature/landing-zone-configurator",GITHUB_EVENT_NAME:"push",LZC_APPROVED_APPLY_COMMIT:env.GITHUB_SHA,LZC_APPROVED_APPLY_ROOT:"bootstrap",GITHUB_WORKFLOW_REF:"stackitcloud/stackit-landing-zone/.github/workflows/configurator-bootstrap.yml@refs/heads/feature/landing-zone-configurator"}; assert.doesNotThrow(()=>assertApplyExecution(safety,feature)); for(const patch of [{LZC_APPROVED_APPLY_COMMIT:""},{LZC_APPROVED_APPLY_COMMIT:"b".repeat(40)},{GITHUB_WORKFLOW_REF:"wrong"},{LZC_ROOT:"backend"}]) { assert.throws(()=>assertApplyExecution(safety,{...feature,...patch})); } }); + +test("backend apply requires matching root approval",()=>{ + const safety={mode:"github-actions-single-writer",concurrencyGroup:"configurator-lzc-dev-mutation"}; + const backend={...env,LZC_ROOT:"backend",GITHUB_ACTIONS:"true",GITHUB_REF:"refs/heads/feature/landing-zone-configurator",GITHUB_EVENT_NAME:"push",LZC_APPROVED_APPLY_COMMIT:env.GITHUB_SHA,LZC_APPROVED_APPLY_ROOT:"backend",GITHUB_WORKFLOW_REF:"stackitcloud/stackit-landing-zone/.github/workflows/configurator-bootstrap.yml@refs/heads/feature/landing-zone-configurator"}; + assert.doesNotThrow(()=>assertApplyExecution(safety,backend)); + assert.throws(()=>assertApplyExecution(safety,{...backend,LZC_APPROVED_APPLY_ROOT:"bootstrap"})); + assert.throws(()=>assertApplyExecution(safety,{...backend,LZC_ROOT:"platform",LZC_APPROVED_APPLY_ROOT:"platform"})); +}); From 842dc95f1e71aecb5f6d9a5ca88ccce5917415f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Weberru=C3=9F?= Date: Tue, 29 Sep 2026 19:12:22 +0200 Subject: [PATCH 006/175] docs(configurator): record backend apply and platform readiness --- landing-zone-configurator/docs/planning.md | 2 ++ .../docs/platform-readiness.md | 30 +++++++++++++++++++ 2 files changed, 32 insertions(+) create mode 100644 landing-zone-configurator/docs/platform-readiness.md diff --git a/landing-zone-configurator/docs/planning.md b/landing-zone-configurator/docs/planning.md index a7b93c0..17ac3c1 100644 --- a/landing-zone-configurator/docs/planning.md +++ b/landing-zone-configurator/docs/planning.md @@ -598,3 +598,5 @@ GitHub wertet CODEOWNERS für Pull Requests aus dem jeweiligen Zielbranch aus: F Feature-Branch-Test freigegeben: Push-Trigger für Bootstrap-Plan auf `feature/landing-zone-configurator`, entsprechende zusätzliche Branch-Regel ausschließlich im Plan-Environment. Apply/Recovery bleiben main-only. Kein Merge nach main für den Test erforderlich. Bootstrap-Apply abgeschlossen: GitHub Run 36601941540 auf dem Feature-Branch, drei Ressourcen erstellt. Verschlüsselter Remote-State unabhängig geprüft; einmalige Commit-Freigabe entfernt. Nächster Schritt: separater Backend-Root zur Versionierung des Workload-State-Buckets. main bleibt unverändert. + +Backend-Apply abgeschlossen: Run 36602706599, Versionierung für den Workload-State-Bucket aktiviert und direkt über S3 verifiziert. Verschlüsselter Backend-State geprüft; einmalige Freigaben entfernt. Die [Plattform-Vorbereitung](platform-readiness.md) dokumentiert die ausgewählten Katalogwerte und offenen Netzwerkparameter. diff --git a/landing-zone-configurator/docs/platform-readiness.md b/landing-zone-configurator/docs/platform-readiness.md new file mode 100644 index 0000000..ace898a --- /dev/null +++ b/landing-zone-configurator/docs/platform-readiness.md @@ -0,0 +1,30 @@ +# Plattform-Vorbereitung nach Bootstrap und Backend + +## Abgeschlossen + +- [x] Bootstrap-State-Bucket und S3-Zugang via CI erstellt. +- [x] Versionierung dieses Buckets via Backend-Root aktiviert: [Run 36602706599](https://github.com/stackitcloud/stackit-landing-zone/actions/runs/36602706599), Commit `d68c0a1a1199c34cf529ef0a87d62943c35a0585`. +- [x] `Enabled` direkt über S3 API sowie verschlüsselten Backend-State unabhängig geprüft. +- [x] Einmalige Commit-/Root-Freigaben nach Apply entfernt. +- [x] Begleitende App-/IaC-Validierung erfolgreich: [Run 36602706648](https://github.com/stackitcloud/stackit-landing-zone/actions/runs/36602706648). + +## Aus Katalogen vorbereitete Entwicklungsparameter + +Die folgenden Werte wurden anhand der Projektkataloge ausgewählt, noch nicht provisioniert. Eine lokale `platform-selection.tfvars.json` enthält die IDs und Datenbankparameter; sie enthält bewusst noch keine Netzwerk-ACL und ist kein vollständiger ausführbarer Plattform-Plan. + +| Parameter | Auswahl | Grund | +| --- | --- | --- | +| Foundation | Öffentliche CF `01.cf.eu01` | Geplante Anwendung für unabhängige Kunden | +| CF Quota | small: 10 GiB RAM, 10 Service-Instanzen | Raum für Web/API/Worker und zusätzliche Instanzen bei Deployments; default ist auf drei App-Instanzen begrenzt | +| PostgreSQL | Version 17, Flavor 2.16 | Verfügbar: 2 CPUs und 16 GiB RAM | +| Speicher | 20 GiB, premium-perf2-stackit | Im Katalog verfügbar, gültiger Größenbereich 5–4000 GiB | +| Backups | Täglich 02:00, Retention 32 Tage | Entwicklungsdefault; Produktivziele separat festlegen | + +## Vor dem Plattform-Plan offen + +- [ ] Verbindliche Egress-CIDRs der öffentlichen CF-Foundation ermitteln und dokumentieren. +- [ ] Zugangsweg für Migrationen/Operator-Zugriffe festlegen; dynamische GitHub-Runner-IPs nicht pauschal freigeben. +- [ ] Plattform-CI mit getrennten Environments und eigenem State-Key anbinden. +- [ ] Konkreten Plattform-Plan prüfen und anschließend deployen. + +Aktuell sind noch keine CF-Organisation, PostgreSQL-Instanz, Secrets-Manager-Instanz oder Model-Serving-Tokens durch den Plattform-Root erstellt. Alle Arbeiten verbleiben auf `feature/landing-zone-configurator`. From 3d82df9868987d362dcc1c066d64a249d0505601 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Weberru=C3=9F?= Date: Tue, 29 Sep 2026 19:53:58 +0200 Subject: [PATCH 007/175] docs(configurator): remove static CF egress assumption --- landing-zone-configurator/docs/planning.md | 2 ++ .../docs/platform-readiness.md | 16 +++++++++++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/landing-zone-configurator/docs/planning.md b/landing-zone-configurator/docs/planning.md index 17ac3c1..48d5fee 100644 --- a/landing-zone-configurator/docs/planning.md +++ b/landing-zone-configurator/docs/planning.md @@ -600,3 +600,5 @@ Feature-Branch-Test freigegeben: Push-Trigger für Bootstrap-Plan auf `feature/l Bootstrap-Apply abgeschlossen: GitHub Run 36601941540 auf dem Feature-Branch, drei Ressourcen erstellt. Verschlüsselter Remote-State unabhängig geprüft; einmalige Commit-Freigabe entfernt. Nächster Schritt: separater Backend-Root zur Versionierung des Workload-State-Buckets. main bleibt unverändert. Backend-Apply abgeschlossen: Run 36602706599, Versionierung für den Workload-State-Bucket aktiviert und direkt über S3 verifiziert. Verschlüsselter Backend-State geprüft; einmalige Freigaben entfernt. Die [Plattform-Vorbereitung](platform-readiness.md) dokumentiert die ausgewählten Katalogwerte und offenen Netzwerkparameter. + +Netzwerk-Korrektur: CF-Egress-Adressen sind nicht dokumentiert/stabil (Benutzerhinweis). Die bisherige Anforderung fester CF-CIDRs entfällt. Stattdessen dienstspezifische, unterstützte Zugangsmodelle prüfen: dokumentierte STACKIT-Netze für PostgreSQL mit Konnektivitätstest; Secrets Manager separat. Migrationen als CF-Task vorsehen. Service-Bindings nicht als Netzwerkfreigabe behandeln. Siehe [Plattform-Vorbereitung](platform-readiness.md). diff --git a/landing-zone-configurator/docs/platform-readiness.md b/landing-zone-configurator/docs/platform-readiness.md index ace898a..cafcdfa 100644 --- a/landing-zone-configurator/docs/platform-readiness.md +++ b/landing-zone-configurator/docs/platform-readiness.md @@ -22,9 +22,23 @@ Die folgenden Werte wurden anhand der Projektkataloge ausgewählt, noch nicht pr ## Vor dem Plattform-Plan offen -- [ ] Verbindliche Egress-CIDRs der öffentlichen CF-Foundation ermitteln und dokumentieren. +- [x] Netzwerkannahme korrigiert: CF-Egress-IP-Adressen sind laut Betreiberwissen nicht dokumentiert und nicht stabil; keine CF-spezifische IP-Allowlist voraussetzen. +- [ ] Dokumentierte STACKIT-Service-Netze für PostgreSQL als Zugangsmodell evaluieren und Zugriff aus CF testen. +- [ ] Zugangsmodell des Secrets Managers separat klären; PostgreSQL-Netzfreigaben nicht ungeprüft übertragen. - [ ] Zugangsweg für Migrationen/Operator-Zugriffe festlegen; dynamische GitHub-Runner-IPs nicht pauschal freigeben. - [ ] Plattform-CI mit getrennten Environments und eigenem State-Key anbinden. - [ ] Konkreten Plattform-Plan prüfen und anschließend deployen. Aktuell sind noch keine CF-Organisation, PostgreSQL-Instanz, Secrets-Manager-Instanz oder Model-Serving-Tokens durch den Plattform-Root erstellt. Alle Arbeiten verbleiben auf `feature/landing-zone-configurator`. + +## Korrigiertes Netzwerkmodell + +Die Anwendung darf nicht von einzelnen, beobachteten CF-Ausgangs-IP-Adressen abhängen. Auch eine einmalige Messung per Test-App würde keine stabile Allowlist ergeben. Service-Bindings liefern Zugangsdaten und Verbindungsparameter; sie sind kein Nachweis, dass eine Netzwerk-ACL umgangen oder automatisch gepflegt wird. + +Für PostgreSQL Flex nennt die offizielle Dokumentation `193.148.160.0/19` und `45.129.40.0/21` ausdrücklich für den Zugriff aus STACKIT-Diensten. Diese dokumentierten Anbieter-Netze sind ein Kandidat für den Konnektivitätstest, keine exklusiven Netze unserer Anwendung oder unseres Mandanten. TLS mit Zertifikatsprüfung, getrennte App-/Migrationsidentitäten und Datenbankautorisierung bleiben erforderlich. Änderungen der unterstützten Netze müssen über IaC nachvollzogen werden; die Adressen sind keine zugesicherten CF-Egress-CIDRs. + +Für den Secrets Manager muss dessen unterstütztes Zugangsmodell unabhängig geprüft werden. Der bisherige gemeinsame Parameter `service_access_cidrs` für beide Dienste darf vor dem Plattform-Apply nicht einfach mit den PostgreSQL-Netzen gefüllt werden. Separate ACL-Eingaben sind bei unterschiedlichen Dienstanforderungen vorzusehen. Ein eigener Zugangsproxy ist erst dann zu planen, wenn das unterstützte direkte Zugangsmodell nicht ausreicht. Keine pauschale Öffnung auf `0.0.0.0/0` aus dieser Korrektur ableiten. + +Datenbankmigrationen sollen später als CF-Task mit separater Migrationsidentität laufen. GitHub Actions startet und überwacht die Task; dafür muss die Datenbank nicht für wechselnde GitHub-Runner-IP-Adressen geöffnet werden. Der technische Nachweis einschließlich Credentials-Bindung steht noch aus. + +Quellen: [PostgreSQL-Instanzen und ACLs](https://docs.stackit.cloud/products/databases/postgresql-flex/how-tos/create-and-manage-instances-for-postgresql-flex/), [Secrets-Manager-Konfiguration](https://docs.stackit.cloud/products/security/secrets-manager/getting-started/configure-the-secrets-manager/), [CF-Service-Bindings](https://docs.cloudfoundry.org/devguide/services/application-binding.html). From 5048d0a08254ccd9b665e9c31129a384e5fc7352 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Weberru=C3=9F?= Date: Tue, 29 Sep 2026 20:53:45 +0200 Subject: [PATCH 008/175] feat(configurator): prepare isolated platform provisioning pipeline --- .github/workflows/configurator-platform.yml | 116 ++++++++++++++++++ .github/workflows/validate-configurator.yml | 2 + .../docs/platform-readiness.md | 10 ++ landing-zone-configurator/infra/ci/README.md | 6 + .../infra/ci/configure-github.mjs | 7 +- .../infra/ci/context.mjs | 7 +- .../infra/ci/context.test.mjs | 9 ++ .../ci/github-platform-environments.json | 23 ++++ landing-zone-configurator/infra/ci/run.mjs | 21 +++- .../environments/development.tfvars.example | 3 +- .../infra/environments/lzc-dev.tfvars.json | 20 +++ .../infra/platform/2-database.tf | 2 +- .../infra/platform/3-secrets.tf | 2 +- .../infra/platform/tests/platform.tftest.hcl | 40 ++++-- .../infra/platform/variables.tf | 19 ++- 15 files changed, 258 insertions(+), 29 deletions(-) create mode 100644 .github/workflows/configurator-platform.yml create mode 100644 landing-zone-configurator/infra/ci/github-platform-environments.json create mode 100644 landing-zone-configurator/infra/environments/lzc-dev.tfvars.json diff --git a/.github/workflows/configurator-platform.yml b/.github/workflows/configurator-platform.yml new file mode 100644 index 0000000..fbdffeb --- /dev/null +++ b/.github/workflows/configurator-platform.yml @@ -0,0 +1,116 @@ +name: Configurator Platform + +on: + push: + branches: [feature/landing-zone-configurator] + paths: + - 'landing-zone-configurator/infra/platform/**' + - 'landing-zone-configurator/infra/environments/lzc-dev.tfvars.json' + - 'landing-zone-configurator/infra/ci/**' + - '.github/workflows/configurator-platform.yml' + workflow_dispatch: + inputs: + root: + description: 'Platform services' + type: choice + options: [platform] + default: platform + apply: + description: 'Apply the reviewed plan after environment approval' + type: boolean + default: false + +permissions: + contents: read + +concurrency: + group: configurator-lzc-dev-mutation + cancel-in-progress: false + +env: + LZC_ENVIRONMENT: lzc-dev + LZC_ROOT: platform + +jobs: + plan: + if: vars.LZC_PLATFORM_CI_ENABLED == 'true' && github.run_attempt == 1 && ((github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main') || (github.event_name == 'push' && github.ref == 'refs/heads/feature/landing-zone-configurator')) + runs-on: ubuntu-latest + timeout-minutes: 45 + environment: lzc-dev-platform-plan + defaults: + run: + working-directory: landing-zone-configurator + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version-file: landing-zone-configurator/app/.node-version + - uses: opentofu/setup-opentofu@a1320f892987e89d278cc92dc5adc984fb93aca4 # v2.0.2 + with: + tofu_version: '1.12.6' + tofu_wrapper: false + - name: Create encrypted review plan + run: node infra/ci/run.mjs plan + env: + LZC_PROJECT_ID: ${{ vars.LZC_PROJECT_ID }} + LZC_REGION: ${{ vars.LZC_REGION }} + LZC_NAME_PREFIX: ${{ vars.LZC_NAME_PREFIX }} + LZC_MANAGEMENT_BUCKET: ${{ vars.LZC_MANAGEMENT_BUCKET }} + LZC_CREDENTIAL_EXPIRATION: ${{ vars.LZC_CREDENTIAL_EXPIRATION }} + LZC_MANAGEMENT_ACCESS_KEY: ${{ secrets.LZC_MANAGEMENT_ACCESS_KEY }} + LZC_MANAGEMENT_SECRET_KEY: ${{ secrets.LZC_MANAGEMENT_SECRET_KEY }} + LZC_SERVICE_ACCOUNT_KEY: ${{ secrets.LZC_SERVICE_ACCOUNT_KEY }} + LZC_STATE_KEY_BOOTSTRAP: ${{ secrets.LZC_STATE_KEY_BOOTSTRAP }} + LZC_STATE_KEY_BACKEND: ${{ secrets.LZC_STATE_KEY_BACKEND }} + LZC_STATE_KEY_PLATFORM: ${{ secrets.LZC_STATE_KEY_PLATFORM }} + - name: Upload encrypted plan and review metadata + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: platform-plan-${{ github.run_id }} + path: landing-zone-configurator/.local/ci-plan/* + include-hidden-files: true + if-no-files-found: error + retention-days: 1 + + apply: + needs: plan + if: github.run_attempt == 1 && ((github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' && inputs.apply) || (github.event_name == 'push' && github.ref == 'refs/heads/feature/landing-zone-configurator' && github.sha == vars.LZC_PLATFORM_APPLY_COMMIT)) + runs-on: ubuntu-latest + timeout-minutes: 45 + environment: lzc-dev-platform-apply + defaults: + run: + working-directory: landing-zone-configurator + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version-file: landing-zone-configurator/app/.node-version + - uses: opentofu/setup-opentofu@a1320f892987e89d278cc92dc5adc984fb93aca4 # v2.0.2 + with: + tofu_version: '1.12.6' + tofu_wrapper: false + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: platform-plan-${{ github.run_id }} + path: landing-zone-configurator/.local/ci-plan + - name: Apply exactly the reviewed plan + run: node infra/ci/run.mjs apply + env: + LZC_APPROVED_APPLY_COMMIT: ${{ vars.LZC_PLATFORM_APPLY_COMMIT }} + LZC_APPROVED_APPLY_ROOT: platform + LZC_PROJECT_ID: ${{ vars.LZC_PROJECT_ID }} + LZC_REGION: ${{ vars.LZC_REGION }} + LZC_NAME_PREFIX: ${{ vars.LZC_NAME_PREFIX }} + LZC_MANAGEMENT_BUCKET: ${{ vars.LZC_MANAGEMENT_BUCKET }} + LZC_CREDENTIAL_EXPIRATION: ${{ vars.LZC_CREDENTIAL_EXPIRATION }} + LZC_MANAGEMENT_ACCESS_KEY: ${{ secrets.LZC_MANAGEMENT_ACCESS_KEY }} + LZC_MANAGEMENT_SECRET_KEY: ${{ secrets.LZC_MANAGEMENT_SECRET_KEY }} + LZC_SERVICE_ACCOUNT_KEY: ${{ secrets.LZC_SERVICE_ACCOUNT_KEY }} + LZC_STATE_KEY_BOOTSTRAP: ${{ secrets.LZC_STATE_KEY_BOOTSTRAP }} + LZC_STATE_KEY_BACKEND: ${{ secrets.LZC_STATE_KEY_BACKEND }} + LZC_STATE_KEY_PLATFORM: ${{ secrets.LZC_STATE_KEY_PLATFORM }} diff --git a/.github/workflows/validate-configurator.yml b/.github/workflows/validate-configurator.yml index f9903b9..5f968cc 100644 --- a/.github/workflows/validate-configurator.yml +++ b/.github/workflows/validate-configurator.yml @@ -6,12 +6,14 @@ on: - 'landing-zone-configurator/**' - '.github/workflows/validate-configurator.yml' - '.github/workflows/configurator-bootstrap.yml' + - '.github/workflows/configurator-platform.yml' push: branches: [main, feature/landing-zone-configurator] paths: - 'landing-zone-configurator/**' - '.github/workflows/validate-configurator.yml' - '.github/workflows/configurator-bootstrap.yml' + - '.github/workflows/configurator-platform.yml' workflow_dispatch: permissions: diff --git a/landing-zone-configurator/docs/platform-readiness.md b/landing-zone-configurator/docs/platform-readiness.md index cafcdfa..4a88424 100644 --- a/landing-zone-configurator/docs/platform-readiness.md +++ b/landing-zone-configurator/docs/platform-readiness.md @@ -42,3 +42,13 @@ Für den Secrets Manager muss dessen unterstütztes Zugangsmodell unabhängig ge Datenbankmigrationen sollen später als CF-Task mit separater Migrationsidentität laufen. GitHub Actions startet und überwacht die Task; dafür muss die Datenbank nicht für wechselnde GitHub-Runner-IP-Adressen geöffnet werden. Der technische Nachweis einschließlich Credentials-Bindung steht noch aus. Quellen: [PostgreSQL-Instanzen und ACLs](https://docs.stackit.cloud/products/databases/postgresql-flex/how-tos/create-and-manage-instances-for-postgresql-flex/), [Secrets-Manager-Konfiguration](https://docs.stackit.cloud/products/security/secrets-manager/getting-started/configure-the-secrets-manager/), [CF-Service-Bindings](https://docs.cloudfoundry.org/devguide/services/application-binding.html). + +## Konkreter Plattform-Entwurf + +Die versionierte Datei `infra/environments/lzc-dev.tfvars.json` enthält jetzt die ausgewählten Katalogparameter. Datenbank und Secrets Manager verwenden unabhängige ACL-Variablen. Für den Entwicklungsentwurf sind beide bewusst auf die STACKIT-Netze `193.148.160.0/19` und `45.129.40.0/21` begrenzt. Für PostgreSQL sind diese Netze dokumentiert; beim Secrets Manager ist dies eine explizite Netzfreigabe als Ausgangskonfiguration, keine Behauptung einer dokumentierten CF-Netzgarantie. Erreichbarkeit und Authentifizierung sind aus einer CF-Test-App beziehungsweise CF-Task vor Nutzung mit Kundendaten zu prüfen. Bei fehlender Erreichbarkeit wird die Netzkonfiguration anhand von Nachweisen angepasst, nicht pauschal geöffnet. + +Die eigene Pipeline `configurator-platform.yml` verwendet denselben Serialisierungsmechanismus wie Bootstrap, aber eigene GitHub-Environments und einen separaten Verschlüsselungsschlüssel. Sie liest Bootstrap-/Backend-Outputs geschützt im Arbeitsspeicher, prüft den angewendeten Versionierungsstatus und verwendet anschließend den Workload-State-Bucket. Plattformparameter, Commit, Root und Plan-Hash sind an die Apply-Prüfung gebunden. + +Secret-Hinterlegung in den neuen Environments `lzc-dev-platform-plan` und `lzc-dev-platform-apply` wartet auf die gesonderte Freigabe der konkreten Ziele. Bis dahin erfolgt kein Plattform-Apply. + +Lokaler technischer Nachweis: echter Plattform-Plan erfolgreich, **9 Create / 0 Update / 0 Delete** (CF-Organisation und Manager, PostgreSQL-Instanz/Datenbank/Migrationsnutzer, Secrets-Manager-Instanz/Provisionierungsnutzer, Artefakt-Bucket und Model-Serving-Token). Sechs Plattform-Mock-Tests, 16 Node-Tests und Workflow-Prüfung erfolgreich. Der lokale Provider-Download hing; die Prüfung wurde mit bereits installierten, gegen das Lockfile geprüften Provider-Binaries wiederholt. Kein Apply durchgeführt. Die neue Plattform-Pipeline bleibt bis zur Environment-/Secret-Einrichtung über `LZC_PLATFORM_CI_ENABLED` deaktiviert. diff --git a/landing-zone-configurator/infra/ci/README.md b/landing-zone-configurator/infra/ci/README.md index 8028271..d6fb4ae 100644 --- a/landing-zone-configurator/infra/ci/README.md +++ b/landing-zone-configurator/infra/ci/README.md @@ -62,3 +62,9 @@ Keine Übernahme nach main. Der separate Backend-Root und Plattformdienste wurde ## Backend-Root über den Feature-Branch Für einen gezielten Backend-Lauf setzt der Operator zusätzlich `LZC_BOOTSTRAP_ROOT=backend`. Die Freigabe ist an Commit **und** Root gebunden; nur bootstrap/backend sind erlaubt. Nach dem Lauf werden Root-Auswahl und Commit-Freigabe entfernt. Ohne diese Variablen starten Feature-Pushes weiterhin ausschließlich einen Bootstrap-Plan. + +## Separate Plattform-Pipeline + +`configurator-platform.yml` plant ausschließlich den Plattform-Root. Plan/Apply verwenden `lzc-dev-platform-plan` und `lzc-dev-platform-apply`, gemeinsam mit Bootstrap die Concurrency-Gruppe `configurator-lzc-dev-mutation`. Für den Feature-Branch schaltet ausschließlich eine passende `LZC_PLATFORM_APPLY_COMMIT` einen reviewerpflichtigen Apply frei; Root und Workflow werden im CI-Einstieg zusätzlich geprüft. + +`node infra/ci/configure-github.mjs --platform` richtet die in `github-platform-environments.json` beschriebenen Environments ein. Es überträgt Projekt-Service-Account, Management-S3-Zugang und Bootstrap-/Backend-/Plattform-State-Schlüssel nach ausdrücklicher Ziel-Freigabe. Keine Seed-Schlüssel gelangen in diese Deployment-Jobs. Plattform-State liegt mit eigener Verschlüsselung im bereits versionierten Workload-Bucket, nicht im Verwaltungs-Bucket. diff --git a/landing-zone-configurator/infra/ci/configure-github.mjs b/landing-zone-configurator/infra/ci/configure-github.mjs index 88d8b4b..a335219 100644 --- a/landing-zone-configurator/infra/ci/configure-github.mjs +++ b/landing-zone-configurator/infra/ci/configure-github.mjs @@ -8,7 +8,9 @@ const here=dirname(fileURLToPath(import.meta.url)); const infra=resolve(here,".."); const local=resolve(infra,"../.local"); const repoPath=resolve(infra,"../.."); -const spec=JSON.parse(readFileSync(resolve(here,"github-environments.json"),"utf8")); +const platform = process.argv[2] === "--platform"; +if(process.argv.length > 2 && !platform) throw new Error("Only --platform is supported"); +const spec=JSON.parse(readFileSync(resolve(here,platform ? "github-platform-environments.json" : "github-environments.json"),"utf8")); if(spec.repository!=="stackitcloud/stackit-landing-zone")throw new Error("Unexpected secret destination"); function gh(args,input) { try {return execFileSync("gh",args,{input,encoding:"utf8",stdio:["pipe","pipe","pipe"]});} @@ -32,6 +34,7 @@ const deploySecrets={...sharedSecrets, LZC_STATE_KEY_BOOTSTRAP:readFileSync(resolve(local,"bootstrap/state.passphrase"),"utf8").trim(), LZC_STATE_KEY_BACKEND:readFileSync(resolve(local,"backend/state.passphrase"),"utf8").trim(), }; +if(platform) deploySecrets.LZC_STATE_KEY_PLATFORM=readFileSync(resolve(local,"platform/state.passphrase"),"utf8").trim(); const recoverySecrets={...sharedSecrets, LZC_STATE_KEY_SEED:readFileSync(resolve(local,"seed/state.passphrase"),"utf8").trim(), LZC_STATE_KEY_SEED_PROTECTION:readFileSync(resolve(local,"seed-protection/state.passphrase"),"utf8").trim(), @@ -59,3 +62,5 @@ for(const target of spec.environments) { } console.log(`Configured ${target.name}: branches=${branches.join(",")}, review=${target.review}; secret values not displayed.`); } + +if(platform) gh(["variable","set","LZC_PLATFORM_CI_ENABLED","--repo",spec.repository],"true"); diff --git a/landing-zone-configurator/infra/ci/context.mjs b/landing-zone-configurator/infra/ci/context.mjs index c095dd2..711fd3a 100644 --- a/landing-zone-configurator/infra/ci/context.mjs +++ b/landing-zone-configurator/infra/ci/context.mjs @@ -7,7 +7,7 @@ export function deploymentInputs(env) { }; if (required("GITHUB_REPOSITORY") !== "stackitcloud/stackit-landing-zone") throw new Error("Unexpected repository"); const root = required("LZC_ROOT"); - if (!["bootstrap", "backend"].includes(root)) throw new Error("Unsupported CI root"); + if (!["bootstrap", "backend", "platform"].includes(root)) throw new Error("Unsupported CI root"); if (required("LZC_ENVIRONMENT") !== "lzc-dev") throw new Error("Unsupported environment"); const ref = required("GITHUB_REF"); const event = required("GITHUB_EVENT_NAME"); @@ -41,8 +41,9 @@ export function assertApplyExecution(safety, env) { } const main = env.GITHUB_REF === "refs/heads/main" && env.GITHUB_EVENT_NAME === "workflow_dispatch"; const feature = env.GITHUB_REF === "refs/heads/feature/landing-zone-configurator" && env.GITHUB_EVENT_NAME === "push" && - /^[a-f0-9]{40}$/.test(env.LZC_APPROVED_APPLY_COMMIT ?? "") && env.GITHUB_SHA === env.LZC_APPROVED_APPLY_COMMIT && ["bootstrap", "backend"].includes(env.LZC_ROOT) && env.LZC_ROOT === env.LZC_APPROVED_APPLY_ROOT; - const workflow = `stackitcloud/stackit-landing-zone/.github/workflows/configurator-bootstrap.yml@${env.GITHUB_REF}`; + /^[a-f0-9]{40}$/.test(env.LZC_APPROVED_APPLY_COMMIT ?? "") && env.GITHUB_SHA === env.LZC_APPROVED_APPLY_COMMIT && ["bootstrap", "backend", "platform"].includes(env.LZC_ROOT) && env.LZC_ROOT === env.LZC_APPROVED_APPLY_ROOT; + const workflowName = env.LZC_ROOT === "platform" ? "configurator-platform.yml" : "configurator-bootstrap.yml"; + const workflow = `stackitcloud/stackit-landing-zone/.github/workflows/${workflowName}@${env.GITHUB_REF}`; if (env.GITHUB_ACTIONS !== "true" || (!main && !feature) || env.GITHUB_WORKFLOW_REF !== workflow) { throw new Error("Remote apply is restricted to the serialized GitHub Actions workflow"); } diff --git a/landing-zone-configurator/infra/ci/context.test.mjs b/landing-zone-configurator/infra/ci/context.test.mjs index b345af5..c72f485 100644 --- a/landing-zone-configurator/infra/ci/context.test.mjs +++ b/landing-zone-configurator/infra/ci/context.test.mjs @@ -63,3 +63,12 @@ test("backend apply requires matching root approval",()=>{ assert.throws(()=>assertApplyExecution(safety,{...backend,LZC_APPROVED_APPLY_ROOT:"bootstrap"})); assert.throws(()=>assertApplyExecution(safety,{...backend,LZC_ROOT:"platform",LZC_APPROVED_APPLY_ROOT:"platform"})); }); + +test("platform apply is isolated from bootstrap workflow",()=>{ + const safety={mode:"github-actions-single-writer",concurrencyGroup:"configurator-lzc-dev-mutation"}; + const platform={...env,LZC_ROOT:"platform",GITHUB_ACTIONS:"true",GITHUB_REF:"refs/heads/feature/landing-zone-configurator",GITHUB_EVENT_NAME:"push",LZC_APPROVED_APPLY_COMMIT:env.GITHUB_SHA,LZC_APPROVED_APPLY_ROOT:"platform",GITHUB_WORKFLOW_REF:"stackitcloud/stackit-landing-zone/.github/workflows/configurator-platform.yml@refs/heads/feature/landing-zone-configurator"}; + assert.equal(deploymentInputs(platform).root,"platform"); + assert.doesNotThrow(()=>assertApplyExecution(safety,platform)); + assert.throws(()=>assertApplyExecution(safety,{...platform,GITHUB_WORKFLOW_REF:"stackitcloud/stackit-landing-zone/.github/workflows/configurator-bootstrap.yml@refs/heads/feature/landing-zone-configurator"})); + assert.throws(()=>assertApplyExecution(safety,{...platform,LZC_APPROVED_APPLY_ROOT:"bootstrap"})); +}); diff --git a/landing-zone-configurator/infra/ci/github-platform-environments.json b/landing-zone-configurator/infra/ci/github-platform-environments.json new file mode 100644 index 0000000..39b0ef4 --- /dev/null +++ b/landing-zone-configurator/infra/ci/github-platform-environments.json @@ -0,0 +1,23 @@ +{ + "repository": "stackitcloud/stackit-landing-zone", + "branch": "main", + "reviewer": "lweberru", + "environments": [ + { + "name": "lzc-dev-platform-plan", + "review": false, + "branches": [ + "main", + "feature/landing-zone-configurator" + ] + }, + { + "name": "lzc-dev-platform-apply", + "review": true, + "branches": [ + "main", + "feature/landing-zone-configurator" + ] + } + ] +} diff --git a/landing-zone-configurator/infra/ci/run.mjs b/landing-zone-configurator/infra/ci/run.mjs index 1f9fef7..2cfcd39 100644 --- a/landing-zone-configurator/infra/ci/run.mjs +++ b/landing-zone-configurator/infra/ci/run.mjs @@ -33,9 +33,10 @@ function tofu(root,args,env) { try { return execFileSync(binary,[`-chdir=${resolve(infra,root)}`,...args],{env,encoding:"utf8",stdio:["ignore","pipe","pipe"],maxBuffer:16*1024*1024}); } catch { throw new Error(`OpenTofu ${root}/${args[0]} failed; raw output withheld to protect credentials`); } } -function initialize(root) { +function initialize(root, storage) { const env=environment(root); - const backend={bucket:inputs.bucket,key:`configurator/${inputs.prefix}/${root}/terraform.tfstate`,region:inputs.region, + if(storage) { env.AWS_ACCESS_KEY_ID=storage.credentials.access_key; env.AWS_SECRET_ACCESS_KEY=storage.credentials.secret_key; } + const backend={bucket:storage?.bucket ?? inputs.bucket,key:`configurator/${inputs.prefix}/${root}/terraform.tfstate`,region:inputs.region, endpoints:{s3:`https://object.storage.${inputs.region}.onstackit.cloud`},use_path_style:true,use_lockfile:true, skip_credentials_validation:true,skip_region_validation:true,skip_requesting_account_id:true,skip_metadata_api_check:true,skip_s3_checksum:true}; const file=resolve(temporary,`${root}.backend.hcl`); @@ -45,11 +46,12 @@ function initialize(root) { } function sourceDigest() { const pieces=[]; - for(const root of ["bootstrap","backend"]) { + for(const root of ["bootstrap","backend","platform"]) { for(const file of readdirSync(resolve(infra,root)).filter(f=>f.endsWith(".tf")||f===".terraform.lock.hcl").sort()) { pieces.push(`${root}/${file}:${digest(readFileSync(resolve(infra,root,file)))}`); } } + if(inputs.root === "platform") pieces.push(digest(readFileSync(resolve(infra,"environments/lzc-dev.tfvars.json")))); return digest(pieces.join("\n")); } try { @@ -58,7 +60,15 @@ try { const credentialJson=secret("LZC_SERVICE_ACCOUNT_KEY"); try { JSON.parse(credentialJson); } catch { throw new Error("Invalid service-account JSON"); } writeFileSync(credentialsPath,credentialJson,{mode:0o600}); - const env=initialize(inputs.root); + let storage; + if(inputs.root === "platform") { + const bootstrapEnv=initialize("bootstrap"); + const outputs=JSON.parse(tofu("bootstrap",["output","-json"],bootstrapEnv)); + const protection=JSON.parse(tofu("backend",["output","-json"],initialize("backend"))); + if(outputs.backend?.value?.region !== inputs.region || protection.versioning_enabled?.value !== true || protection.state_bucket_name?.value !== outputs.backend.value.bucket) throw new Error("Versioned platform backend is not ready"); + storage={bucket:outputs.backend.value.bucket,credentials:outputs.backend_credentials.value}; + } + const env=initialize(inputs.root,storage); if(inputs.root==="backend") { const bootstrapEnv=initialize("bootstrap"); const outputs=JSON.parse(tofu("bootstrap",["output","-json"],bootstrapEnv)); @@ -72,7 +82,8 @@ try { const manifest=resolve(artifact,"review.json"); if(command==="plan") { writeFileSync(manifest,JSON.stringify({status:"planning"})); - tofu(inputs.root,["plan",`-out=${plan}`,"-input=false","-lock-timeout=60s","-no-color"],env); + const parameters=inputs.root === "platform" ? [`-var-file=${resolve(infra,"environments/lzc-dev.tfvars.json")}`] : []; + tofu(inputs.root,["plan",...parameters,`-out=${plan}`,"-input=false","-lock-timeout=60s","-no-color"],env); const envelope=JSON.parse(readFileSync(plan,"utf8")); if(!envelope.encrypted_data||!envelope.encryption_version)throw new Error("Refusing an unencrypted plan artifact"); const details=JSON.parse(tofu(inputs.root,["show","-json",plan],env)); diff --git a/landing-zone-configurator/infra/environments/development.tfvars.example b/landing-zone-configurator/infra/environments/development.tfvars.example index 6cfe221..b72256c 100644 --- a/landing-zone-configurator/infra/environments/development.tfvars.example +++ b/landing-zone-configurator/infra/environments/development.tfvars.example @@ -4,7 +4,8 @@ cf_platform_id = "00000000-0000-4000-8000-000000000001" cf_quota_id = "00000000-0000-4000-8000-000000000002" # RFC 5737 documentation address; not a real CF egress address. -service_access_cidrs = ["192.0.2.10/32"] +database_access_cidrs = ["192.0.2.10/32"] +secrets_access_cidrs = ["198.51.100.10/32"] database = { flavor_id = "REPLACE_FROM_CATALOG" diff --git a/landing-zone-configurator/infra/environments/lzc-dev.tfvars.json b/landing-zone-configurator/infra/environments/lzc-dev.tfvars.json new file mode 100644 index 0000000..33fef0a --- /dev/null +++ b/landing-zone-configurator/infra/environments/lzc-dev.tfvars.json @@ -0,0 +1,20 @@ +{ + "cf_platform_id": "0a3d1188-353a-4004-832c-53039c0e3868", + "cf_quota_id": "e22cfe1a-0318-473f-88db-61d62dc629c0", + "database": { + "flavor_id": "2.16", + "version": "17", + "storage_class": "premium-perf2-stackit", + "storage_size_gb": 20, + "backup_schedule": "0 2 * * *", + "retention_days": 32 + }, + "database_access_cidrs": [ + "193.148.160.0/19", + "45.129.40.0/21" + ], + "secrets_access_cidrs": [ + "193.148.160.0/19", + "45.129.40.0/21" + ] +} diff --git a/landing-zone-configurator/infra/platform/2-database.tf b/landing-zone-configurator/infra/platform/2-database.tf index 3529230..599ab19 100644 --- a/landing-zone-configurator/infra/platform/2-database.tf +++ b/landing-zone-configurator/infra/platform/2-database.tf @@ -6,7 +6,7 @@ resource "stackit_postgresflex_instance" "configurator" { backup_schedule = var.database.backup_schedule retention_days = var.database.retention_days network = { - acl = sort(tolist(var.service_access_cidrs)) + acl = sort(tolist(var.database_access_cidrs)) } storage = { class = var.database.storage_class diff --git a/landing-zone-configurator/infra/platform/3-secrets.tf b/landing-zone-configurator/infra/platform/3-secrets.tf index 53aa56d..0605b7c 100644 --- a/landing-zone-configurator/infra/platform/3-secrets.tf +++ b/landing-zone-configurator/infra/platform/3-secrets.tf @@ -1,7 +1,7 @@ resource "stackit_secretsmanager_instance" "configurator" { project_id = var.project_id name = "${var.name_prefix}-secrets" - acls = var.service_access_cidrs + acls = var.secrets_access_cidrs lifecycle { prevent_destroy = true } diff --git a/landing-zone-configurator/infra/platform/tests/platform.tftest.hcl b/landing-zone-configurator/infra/platform/tests/platform.tftest.hcl index c01f0a0..96af2c2 100644 --- a/landing-zone-configurator/infra/platform/tests/platform.tftest.hcl +++ b/landing-zone-configurator/infra/platform/tests/platform.tftest.hcl @@ -17,12 +17,13 @@ mock_provider "stackit" { } variables { - project_id = "00000000-0000-4000-8000-000000000001" - region = "eu01" - name_prefix = "lzc-test" - cf_platform_id = "00000000-0000-4000-8000-000000000002" - cf_quota_id = "00000000-0000-4000-8000-000000000003" - service_access_cidrs = ["192.0.2.10/32"] + project_id = "00000000-0000-4000-8000-000000000001" + region = "eu01" + name_prefix = "lzc-test" + cf_platform_id = "00000000-0000-4000-8000-000000000002" + cf_quota_id = "00000000-0000-4000-8000-000000000003" + database_access_cidrs = ["192.0.2.10/32"] + secrets_access_cidrs = ["198.51.100.10/32"] database = { flavor_id = "test-flavor" version = "17" @@ -36,7 +37,7 @@ variables { run "service_boundaries" { command = plan assert { - condition = stackit_secretsmanager_instance.configurator.acls == toset(["192.0.2.10/32"]) + condition = stackit_secretsmanager_instance.configurator.acls == toset(["198.51.100.10/32"]) error_message = "Secrets must be restricted to the configured egress ranges." } assert { @@ -48,23 +49,36 @@ run "service_boundaries" { run "reject_public_database_and_secrets" { command = plan variables { - service_access_cidrs = ["0.0.0.0/0"] + database_access_cidrs = ["0.0.0.0/0"] } - expect_failures = [var.service_access_cidrs] + expect_failures = [var.database_access_cidrs] } run "reject_empty_access_list" { command = plan variables { - service_access_cidrs = [] + database_access_cidrs = [] } - expect_failures = [var.service_access_cidrs] + expect_failures = [var.database_access_cidrs] } run "reject_noncanonical_default_route" { command = plan variables { - service_access_cidrs = ["192.0.2.1/0"] + database_access_cidrs = ["192.0.2.1/0"] + } + expect_failures = [var.database_access_cidrs] +} + +run "reject_public_secrets" { + command = plan + variables { secrets_access_cidrs = ["0.0.0.0/0"] } + expect_failures = [var.secrets_access_cidrs] +} +run "separate_service_networks" { + command = plan + assert { + condition = toset(stackit_postgresflex_instance.configurator.network.acl) == toset(["192.0.2.10/32"]) + error_message = "Database must use its own ACL, independently of Secrets Manager." } - expect_failures = [var.service_access_cidrs] } diff --git a/landing-zone-configurator/infra/platform/variables.tf b/landing-zone-configurator/infra/platform/variables.tf index 2a97e3a..2db0126 100644 --- a/landing-zone-configurator/infra/platform/variables.tf +++ b/landing-zone-configurator/infra/platform/variables.tf @@ -35,12 +35,23 @@ variable "cf_quota_id" { description = "Explicitly chosen quota ID for this CF foundation." } -variable "service_access_cidrs" { +variable "database_access_cidrs" { type = set(string) - description = "Verified CF egress and operator CIDRs permitted to access PostgreSQL and Secrets Manager." + description = "Explicit source networks allowed to access PostgreSQL; not dedicated CF egress IPs." validation { - condition = length(var.service_access_cidrs) > 0 && alltrue([ - for cidr in var.service_access_cidrs : can(cidrhost(cidr, 0)) && try(tonumber(split("/", cidr)[1]) > 0, false) + condition = length(var.database_access_cidrs) > 0 && alltrue([ + for cidr in var.database_access_cidrs : can(cidrhost(cidr, 0)) && try(tonumber(split("/", cidr)[1]) > 0, false) + ]) + error_message = "Supply explicit valid access CIDRs; unrestricted internet access is not accepted." + } +} + +variable "secrets_access_cidrs" { + type = set(string) + description = "Explicit source networks allowed to access Secrets Manager; validate connectivity separately." + validation { + condition = length(var.secrets_access_cidrs) > 0 && alltrue([ + for cidr in var.secrets_access_cidrs : can(cidrhost(cidr, 0)) && try(tonumber(split("/", cidr)[1]) > 0, false) ]) error_message = "Supply explicit valid access CIDRs; unrestricted internet access is not accepted." } From 3322dd99a37a495fda8ba46fc4eb6284895d4316 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Weberru=C3=9F?= Date: Tue, 29 Sep 2026 23:22:47 +0200 Subject: [PATCH 009/175] docs(configurator): record platform deployment authorization --- landing-zone-configurator/docs/platform-readiness.md | 2 +- landing-zone-configurator/infra/ci/README.md | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/landing-zone-configurator/docs/platform-readiness.md b/landing-zone-configurator/docs/platform-readiness.md index 4a88424..1ab5e35 100644 --- a/landing-zone-configurator/docs/platform-readiness.md +++ b/landing-zone-configurator/docs/platform-readiness.md @@ -49,6 +49,6 @@ Die versionierte Datei `infra/environments/lzc-dev.tfvars.json` enthält jetzt d Die eigene Pipeline `configurator-platform.yml` verwendet denselben Serialisierungsmechanismus wie Bootstrap, aber eigene GitHub-Environments und einen separaten Verschlüsselungsschlüssel. Sie liest Bootstrap-/Backend-Outputs geschützt im Arbeitsspeicher, prüft den angewendeten Versionierungsstatus und verwendet anschließend den Workload-State-Bucket. Plattformparameter, Commit, Root und Plan-Hash sind an die Apply-Prüfung gebunden. -Secret-Hinterlegung in den neuen Environments `lzc-dev-platform-plan` und `lzc-dev-platform-apply` wartet auf die gesonderte Freigabe der konkreten Ziele. Bis dahin erfolgt kein Plattform-Apply. +Die gesonderte Freigabe zur Secret-Hinterlegung in `lzc-dev-platform-plan` und `lzc-dev-platform-apply` liegt vor. Die Bereitstellung wird nun über einen frischen CI-Plan und einen daran gebundenen Apply ausgeführt. Lokaler technischer Nachweis: echter Plattform-Plan erfolgreich, **9 Create / 0 Update / 0 Delete** (CF-Organisation und Manager, PostgreSQL-Instanz/Datenbank/Migrationsnutzer, Secrets-Manager-Instanz/Provisionierungsnutzer, Artefakt-Bucket und Model-Serving-Token). Sechs Plattform-Mock-Tests, 16 Node-Tests und Workflow-Prüfung erfolgreich. Der lokale Provider-Download hing; die Prüfung wurde mit bereits installierten, gegen das Lockfile geprüften Provider-Binaries wiederholt. Kein Apply durchgeführt. Die neue Plattform-Pipeline bleibt bis zur Environment-/Secret-Einrichtung über `LZC_PLATFORM_CI_ENABLED` deaktiviert. diff --git a/landing-zone-configurator/infra/ci/README.md b/landing-zone-configurator/infra/ci/README.md index d6fb4ae..78d2e34 100644 --- a/landing-zone-configurator/infra/ci/README.md +++ b/landing-zone-configurator/infra/ci/README.md @@ -68,3 +68,7 @@ Für einen gezielten Backend-Lauf setzt der Operator zusätzlich `LZC_BOOTSTRAP_ `configurator-platform.yml` plant ausschließlich den Plattform-Root. Plan/Apply verwenden `lzc-dev-platform-plan` und `lzc-dev-platform-apply`, gemeinsam mit Bootstrap die Concurrency-Gruppe `configurator-lzc-dev-mutation`. Für den Feature-Branch schaltet ausschließlich eine passende `LZC_PLATFORM_APPLY_COMMIT` einen reviewerpflichtigen Apply frei; Root und Workflow werden im CI-Einstieg zusätzlich geprüft. `node infra/ci/configure-github.mjs --platform` richtet die in `github-platform-environments.json` beschriebenen Environments ein. Es überträgt Projekt-Service-Account, Management-S3-Zugang und Bootstrap-/Backend-/Plattform-State-Schlüssel nach ausdrücklicher Ziel-Freigabe. Keine Seed-Schlüssel gelangen in diese Deployment-Jobs. Plattform-State liegt mit eigener Verschlüsselung im bereits versionierten Workload-Bucket, nicht im Verwaltungs-Bucket. + +## Plattform-Bereitstellung freigegeben + +Der Benutzer hat am 2026-09-29 ausdrücklich die Secret-Hinterlegung in `lzc-dev-platform-plan` und `lzc-dev-platform-apply` freigegeben und die Fortsetzung der Plattform-Bereitstellung beauftragt. Der erste CI-Apply wird über eine einzelne Commit-SHA in `LZC_PLATFORM_APPLY_COMMIT` angefordert; die Environment-Freigabe erfolgt erst nach Prüfung des konkreten Plans und erfolgreicher Validierung. From 7a6ff1e3f3a4c9b11b9325deab402f8e89b8a2fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Weberru=C3=9F?= Date: Wed, 30 Sep 2026 00:13:28 +0200 Subject: [PATCH 010/175] docs(configurator): record interrupted platform deployment and recovery --- landing-zone-configurator/docs/planning.md | 3 ++ .../docs/platform-readiness.md | 36 +++++++++++++++---- 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/landing-zone-configurator/docs/planning.md b/landing-zone-configurator/docs/planning.md index 48d5fee..a8b15bc 100644 --- a/landing-zone-configurator/docs/planning.md +++ b/landing-zone-configurator/docs/planning.md @@ -602,3 +602,6 @@ Bootstrap-Apply abgeschlossen: GitHub Run 36601941540 auf dem Feature-Branch, dr Backend-Apply abgeschlossen: Run 36602706599, Versionierung für den Workload-State-Bucket aktiviert und direkt über S3 verifiziert. Verschlüsselter Backend-State geprüft; einmalige Freigaben entfernt. Die [Plattform-Vorbereitung](platform-readiness.md) dokumentiert die ausgewählten Katalogwerte und offenen Netzwerkparameter. Netzwerk-Korrektur: CF-Egress-Adressen sind nicht dokumentiert/stabil (Benutzerhinweis). Die bisherige Anforderung fester CF-CIDRs entfällt. Stattdessen dienstspezifische, unterstützte Zugangsmodelle prüfen: dokumentierte STACKIT-Netze für PostgreSQL mit Konnektivitätstest; Secrets Manager separat. Migrationen als CF-Task vorsehen. Service-Bindings nicht als Netzwerkfreigabe behandeln. Siehe [Plattform-Vorbereitung](platform-readiness.md). + + +Plattform-Versuch 2026-09-30: Plan mit neun Creates und Validierung erfolgreich; Apply-Run 36632950493 nach 45 Minuten abgebrochen. Teilbestand existiert (CF aktiv, Secrets Manager Running, PostgreSQL zuletzt PROGRESSING), Plattform-State fehlt. Einmalige Freigabe entfernt und Plattform-CI vorübergehend deaktiviert. Nächster Schritt ist die kontrollierte State-Recovery und verbesserte Abbruchbehandlung, nicht ein erneuter ursprünglicher Apply. Details und Checkliste: [Plattform-Betriebsstand](platform-readiness.md#betriebsstand-2026-09-30-erster-plattform-apply-unvollständig). diff --git a/landing-zone-configurator/docs/platform-readiness.md b/landing-zone-configurator/docs/platform-readiness.md index 1ab5e35..65a56a5 100644 --- a/landing-zone-configurator/docs/platform-readiness.md +++ b/landing-zone-configurator/docs/platform-readiness.md @@ -10,7 +10,7 @@ ## Aus Katalogen vorbereitete Entwicklungsparameter -Die folgenden Werte wurden anhand der Projektkataloge ausgewählt, noch nicht provisioniert. Eine lokale `platform-selection.tfvars.json` enthält die IDs und Datenbankparameter; sie enthält bewusst noch keine Netzwerk-ACL und ist kein vollständiger ausführbarer Plattform-Plan. +Die folgenden Werte wurden anhand der Projektkataloge ausgewählt. Verbindliche Deployment-Eingaben einschließlich getrennter Netzwerk-ACLs stehen in `infra/environments/lzc-dev.tfvars.json`. | Parameter | Auswahl | Grund | | --- | --- | --- | @@ -20,16 +20,16 @@ Die folgenden Werte wurden anhand der Projektkataloge ausgewählt, noch nicht pr | Speicher | 20 GiB, premium-perf2-stackit | Im Katalog verfügbar, gültiger Größenbereich 5–4000 GiB | | Backups | Täglich 02:00, Retention 32 Tage | Entwicklungsdefault; Produktivziele separat festlegen | -## Vor dem Plattform-Plan offen +## Plattform und verbleibende Abnahmen - [x] Netzwerkannahme korrigiert: CF-Egress-IP-Adressen sind laut Betreiberwissen nicht dokumentiert und nicht stabil; keine CF-spezifische IP-Allowlist voraussetzen. - [ ] Dokumentierte STACKIT-Service-Netze für PostgreSQL als Zugangsmodell evaluieren und Zugriff aus CF testen. - [ ] Zugangsmodell des Secrets Managers separat klären; PostgreSQL-Netzfreigaben nicht ungeprüft übertragen. -- [ ] Zugangsweg für Migrationen/Operator-Zugriffe festlegen; dynamische GitHub-Runner-IPs nicht pauschal freigeben. -- [ ] Plattform-CI mit getrennten Environments und eigenem State-Key anbinden. +- [x] Migrationen als CF-Tasks vorgesehen; technische Umsetzung und Operator-Zugriff noch offen. +- [x] Plattform-CI mit getrennten Environments und eigenem State-Key anbinden. - [ ] Konkreten Plattform-Plan prüfen und anschließend deployen. -Aktuell sind noch keine CF-Organisation, PostgreSQL-Instanz, Secrets-Manager-Instanz oder Model-Serving-Tokens durch den Plattform-Root erstellt. Alle Arbeiten verbleiben auf `feature/landing-zone-configurator`. +Der erste Plattform-Apply wurde gestartet, aber wegen des Job-Zeitlimits abgebrochen. Es existiert ein Teilbestand; siehe den aktuellen Betriebsstand unten. Alle Arbeiten verbleiben auf `feature/landing-zone-configurator`. ## Korrigiertes Netzwerkmodell @@ -51,4 +51,28 @@ Die eigene Pipeline `configurator-platform.yml` verwendet denselben Serialisieru Die gesonderte Freigabe zur Secret-Hinterlegung in `lzc-dev-platform-plan` und `lzc-dev-platform-apply` liegt vor. Die Bereitstellung wird nun über einen frischen CI-Plan und einen daran gebundenen Apply ausgeführt. -Lokaler technischer Nachweis: echter Plattform-Plan erfolgreich, **9 Create / 0 Update / 0 Delete** (CF-Organisation und Manager, PostgreSQL-Instanz/Datenbank/Migrationsnutzer, Secrets-Manager-Instanz/Provisionierungsnutzer, Artefakt-Bucket und Model-Serving-Token). Sechs Plattform-Mock-Tests, 16 Node-Tests und Workflow-Prüfung erfolgreich. Der lokale Provider-Download hing; die Prüfung wurde mit bereits installierten, gegen das Lockfile geprüften Provider-Binaries wiederholt. Kein Apply durchgeführt. Die neue Plattform-Pipeline bleibt bis zur Environment-/Secret-Einrichtung über `LZC_PLATFORM_CI_ENABLED` deaktiviert. +Lokaler technischer Nachweis: echter Plattform-Plan erfolgreich, **9 Create / 0 Update / 0 Delete** (CF-Organisation und Manager, PostgreSQL-Instanz/Datenbank/Migrationsnutzer, Secrets-Manager-Instanz/Provisionierungsnutzer, Artefakt-Bucket und Model-Serving-Token). Sechs Plattform-Mock-Tests, 16 Node-Tests und Workflow-Prüfung erfolgreich. Der lokale Provider-Download hing; die Prüfung wurde mit bereits installierten, gegen das Lockfile geprüften Provider-Binaries wiederholt. Dieser lokale Nachweis führte keinen Apply aus. Die Plattform-Pipeline ist nach der genehmigten Environment-/Secret-Einrichtung über `LZC_PLATFORM_CI_ENABLED` aktiviert. + +## Betriebsstand 2026-09-30: erster Plattform-Apply unvollständig + +- [x] Secrets mit ausdrücklicher Benutzerfreigabe in die beiden Plattform-Environments übertragen; Werte nicht protokolliert. +- [x] CI-Plan und verschlüsseltes Artefakt geprüft: Commit `3322dd99a37a495fda8ba46fc4eb6284895d4316`, neun Create, keine Update/Delete; Hash und Run-Zuordnung geprüft. +- [x] [Validierung 36632950763](https://github.com/stackitcloud/stackit-landing-zone/actions/runs/36632950763) erfolgreich. +- [x] Genehmigten Apply gestartet: [Run 36632950493](https://github.com/stackitcloud/stackit-landing-zone/actions/runs/36632950493). +- [ ] Plattform-Apply erfolgreich abschließen: Job nach 45 Minuten durch GitHub abgebrochen (`maximum execution time of 45m0s`). +- [x] Einmalige Variable `LZC_PLATFORM_APPLY_COMMIT` entfernt und `LZC_PLATFORM_CI_ENABLED=false` gesetzt; keine automatischen Wiederholungen. + +Direkte API-Prüfung nach dem Abbruch: PostgreSQL `lzc-dev-db` wechselte von `PENDING` nach `PROGRESSING`; Secrets Manager `lzc-dev-secrets` meldete `Running`, die CF-Organisation `active`. Das belegt einen Teilbestand, nicht die Vollständigkeit aller neun Ressourcen oder die Nutzbarkeit der Anwendung. Die serverseitige Datenbankbereitstellung läuft unabhängig vom beendeten Runner weiter. + +Der erwartete Plattform-State im Workload-Bucket ist nicht vorhanden (`NoSuchKey`). Auch die Objektversionsliste enthält keinen Plattform-State, aber ein verbliebenes `.tflock`-Objekt. Dieses ist kein Ersatz für den State und wurde nicht entfernt. Es wurde kein weiterer Apply gestartet. Der ursprüngliche Plan darf nicht wiederverwendet werden: Er plant neue Ressourcen und kennt den inzwischen vorhandenen Teilbestand nicht. + +### Wiederaufnahme und Abnahme + +- [ ] PostgreSQL-Endzustand über die API prüfen; bei weiterhin festhängender Bereitstellung STACKIT-Service-Diagnose mit Instanz-ID aus dem lokalen Inventar veranlassen. +- [ ] Vollständiges Inventar aller neun geplanten Ressourcen einschließlich technischer Benutzer und Model-Serving-Token erstellen; IDs und Zugangsdaten geschützt behandeln. +- [ ] State-Recovery über einen dedizierten, serialisierten CI-Weg vorbereiten: vorhandene importierbare Ressourcen importieren, nicht wiederherstellbare Einmal-Credentials kontrolliert rotieren. Keine Doppelanlage oder ungeprüfte Löschung. +- [ ] Verbliebenes Lock erst nach Prüfung auf beendete Runner und vor kontrollierter Recovery behandeln; native S3-Sperre bleibt als unzuverlässig dokumentiert. +- [ ] CI-Prozesssteuerung verbessern: rechtzeitiger geordneter OpenTofu-Abbruch vor dem äußeren Job-Zeitlimit, geschützte Diagnose und verschlüsselte Recovery-Artefakte. Nur das Timeout zu erhöhen behebt den fehlenden State nicht. +- [ ] Nach Recovery frischen Plan prüfen; erst dann Plattform-CI wieder aktivieren und neue konkrete Apply-Freigabe setzen. +- [ ] Verschlüsselten Remote-State, vollständige Ressourcen und anschließenden No-op-Plan unabhängig verifizieren. +- [ ] CF-Runtime (Space, Apps, Bindings, separate Laufzeitidentitäten) per IaC erstellen und PostgreSQL-/Secrets-Zugriff aus CF testen. From 1843069ce6914c26d04a7461d0799fc1c58794d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Weberru=C3=9F?= Date: Wed, 30 Sep 2026 07:50:33 +0200 Subject: [PATCH 011/175] fix(configurator): recover platform state and allow graceful apply timeouts --- .github/workflows/configurator-platform.yml | 65 +++++++++++++++++-- .github/workflows/validate-configurator.yml | 2 +- landing-zone-configurator/infra/ci/README.md | 15 +++++ .../infra/ci/process.mjs | 46 +++++++++++++ .../infra/ci/process.test.mjs | 28 ++++++++ .../infra/ci/recovery.mjs | 48 ++++++++++++++ .../infra/ci/recovery.test.mjs | 21 ++++++ .../infra/ci/recovery/lzc-dev-20260930.json | 17 +++++ landing-zone-configurator/infra/ci/run.mjs | 62 +++++++++++++----- 9 files changed, 281 insertions(+), 23 deletions(-) create mode 100644 landing-zone-configurator/infra/ci/process.mjs create mode 100644 landing-zone-configurator/infra/ci/process.test.mjs create mode 100644 landing-zone-configurator/infra/ci/recovery.mjs create mode 100644 landing-zone-configurator/infra/ci/recovery.test.mjs create mode 100644 landing-zone-configurator/infra/ci/recovery/lzc-dev-20260930.json diff --git a/.github/workflows/configurator-platform.yml b/.github/workflows/configurator-platform.yml index fbdffeb..49c805a 100644 --- a/.github/workflows/configurator-platform.yml +++ b/.github/workflows/configurator-platform.yml @@ -32,10 +32,57 @@ env: LZC_ROOT: platform jobs: + recover: + if: vars.LZC_PLATFORM_CI_ENABLED == 'true' && github.run_attempt == 1 && github.event_name == 'push' && github.ref == 'refs/heads/feature/landing-zone-configurator' && github.sha == vars.LZC_PLATFORM_RECOVERY_COMMIT + runs-on: ubuntu-latest + timeout-minutes: 100 + environment: lzc-dev-platform-apply + defaults: + run: + working-directory: landing-zone-configurator + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version-file: landing-zone-configurator/app/.node-version + - uses: opentofu/setup-opentofu@a1320f892987e89d278cc92dc5adc984fb93aca4 # v2.0.2 + with: + tofu_version: '1.12.6' + tofu_wrapper: false + - name: Import inventoried resources and revoke orphan token + run: node infra/ci/run.mjs recover + env: + LZC_APPROVED_APPLY_COMMIT: ${{ vars.LZC_PLATFORM_RECOVERY_COMMIT }} + LZC_APPROVED_APPLY_ROOT: platform + LZC_RECOVERY_COMMIT: ${{ vars.LZC_PLATFORM_RECOVERY_COMMIT }} + LZC_PROJECT_ID: ${{ vars.LZC_PROJECT_ID }} + LZC_REGION: ${{ vars.LZC_REGION }} + LZC_NAME_PREFIX: ${{ vars.LZC_NAME_PREFIX }} + LZC_MANAGEMENT_BUCKET: ${{ vars.LZC_MANAGEMENT_BUCKET }} + LZC_CREDENTIAL_EXPIRATION: ${{ vars.LZC_CREDENTIAL_EXPIRATION }} + LZC_MANAGEMENT_ACCESS_KEY: ${{ secrets.LZC_MANAGEMENT_ACCESS_KEY }} + LZC_MANAGEMENT_SECRET_KEY: ${{ secrets.LZC_MANAGEMENT_SECRET_KEY }} + LZC_SERVICE_ACCOUNT_KEY: ${{ secrets.LZC_SERVICE_ACCOUNT_KEY }} + LZC_STATE_KEY_BOOTSTRAP: ${{ secrets.LZC_STATE_KEY_BOOTSTRAP }} + LZC_STATE_KEY_BACKEND: ${{ secrets.LZC_STATE_KEY_BACKEND }} + LZC_STATE_KEY_PLATFORM: ${{ secrets.LZC_STATE_KEY_PLATFORM }} + - name: Upload protected recovery evidence + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: platform-recovery-${{ github.run_id }} + path: landing-zone-configurator/.local/ci-recovery/*.enc.json + include-hidden-files: true + if-no-files-found: warn + retention-days: 7 + plan: - if: vars.LZC_PLATFORM_CI_ENABLED == 'true' && github.run_attempt == 1 && ((github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main') || (github.event_name == 'push' && github.ref == 'refs/heads/feature/landing-zone-configurator')) + needs: recover + if: always() && (needs.recover.result == 'success' || needs.recover.result == 'skipped') && vars.LZC_PLATFORM_CI_ENABLED == 'true' && github.run_attempt == 1 && ((github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main') || (github.event_name == 'push' && github.ref == 'refs/heads/feature/landing-zone-configurator')) runs-on: ubuntu-latest - timeout-minutes: 45 + timeout-minutes: 100 environment: lzc-dev-platform-plan defaults: run: @@ -54,6 +101,7 @@ jobs: - name: Create encrypted review plan run: node infra/ci/run.mjs plan env: + LZC_RECOVERY_COMMIT: ${{ vars.LZC_PLATFORM_RECOVERY_COMMIT }} LZC_PROJECT_ID: ${{ vars.LZC_PROJECT_ID }} LZC_REGION: ${{ vars.LZC_REGION }} LZC_NAME_PREFIX: ${{ vars.LZC_NAME_PREFIX }} @@ -76,9 +124,9 @@ jobs: apply: needs: plan - if: github.run_attempt == 1 && ((github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' && inputs.apply) || (github.event_name == 'push' && github.ref == 'refs/heads/feature/landing-zone-configurator' && github.sha == vars.LZC_PLATFORM_APPLY_COMMIT)) + if: always() && needs.plan.result == 'success' && github.run_attempt == 1 && ((github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' && inputs.apply) || (github.event_name == 'push' && github.ref == 'refs/heads/feature/landing-zone-configurator' && github.sha == vars.LZC_PLATFORM_APPLY_COMMIT)) runs-on: ubuntu-latest - timeout-minutes: 45 + timeout-minutes: 100 environment: lzc-dev-platform-apply defaults: run: @@ -114,3 +162,12 @@ jobs: LZC_STATE_KEY_BOOTSTRAP: ${{ secrets.LZC_STATE_KEY_BOOTSTRAP }} LZC_STATE_KEY_BACKEND: ${{ secrets.LZC_STATE_KEY_BACKEND }} LZC_STATE_KEY_PLATFORM: ${{ secrets.LZC_STATE_KEY_PLATFORM }} + - name: Upload protected recovery evidence + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: platform-apply-evidence-${{ github.run_id }} + path: landing-zone-configurator/.local/ci-recovery/*.enc.json + include-hidden-files: true + if-no-files-found: warn + retention-days: 7 diff --git a/.github/workflows/validate-configurator.yml b/.github/workflows/validate-configurator.yml index 5f968cc..bed8ce9 100644 --- a/.github/workflows/validate-configurator.yml +++ b/.github/workflows/validate-configurator.yml @@ -73,7 +73,7 @@ jobs: tofu_version: '1.12.6' tofu_wrapper: false - name: Check operator plan guard - run: node --test infra/plan-guard.test.mjs infra/state-key.test.mjs infra/ci/context.test.mjs + run: node --test infra/plan-guard.test.mjs infra/state-key.test.mjs infra/ci/context.test.mjs infra/ci/process.test.mjs infra/ci/recovery.test.mjs - name: Check formatting run: tofu -chdir=infra fmt -check -recursive - name: Initialize without backend diff --git a/landing-zone-configurator/infra/ci/README.md b/landing-zone-configurator/infra/ci/README.md index 78d2e34..9993dfb 100644 --- a/landing-zone-configurator/infra/ci/README.md +++ b/landing-zone-configurator/infra/ci/README.md @@ -72,3 +72,18 @@ Für einen gezielten Backend-Lauf setzt der Operator zusätzlich `LZC_BOOTSTRAP_ ## Plattform-Bereitstellung freigegeben Der Benutzer hat am 2026-09-29 ausdrücklich die Secret-Hinterlegung in `lzc-dev-platform-plan` und `lzc-dev-platform-apply` freigegeben und die Fortsetzung der Plattform-Bereitstellung beauftragt. Der erste CI-Apply wird über eine einzelne Commit-SHA in `LZC_PLATFORM_APPLY_COMMIT` angefordert; die Environment-Freigabe erfolgt erst nach Prüfung des konkreten Plans und erfolgreicher Validierung. + + +## Plattform-Recovery nach abgebrochener Erstbereitstellung + +Der freigegebene Lauf vom 2026-09-29 wurde nach 45 Minuten beendet, bevor ein Plattform-State gespeichert war. Die am 2026-09-30 geprüfte Datenbank ist `READY`. Das fest eingegrenzte Inventar in `recovery/lzc-dev-20260930.json` enthält sechs importierbare Ressourcen und genau einen verwaisten Model-Serving-Token. Ressourcen-IDs sind keine Zugangsdaten. + +Der optionale `recover`-Job in `configurator-platform.yml` benötigt `LZC_PLATFORM_RECOVERY_COMMIT` gleich dem aktuellen Feature-Commit, `LZC_PLATFORM_CI_ENABLED=true` und das geschützte Apply-Environment. Er prüft den abgeschlossenen Quell-Run, entfernt dessen konkretes verwaistes Lock, importiert die sechs Ressourcen und widerruft den eindeutig identifizierten Token, dessen Wert nicht wiederherstellbar ist. Der STACKIT-Provider 0.114.0 unterstützt keinen Model-Serving-Token-Import. Der anschließende Plan ersetzt explizit die beiden importierten technischen Benutzer, deren Passwörter ebenfalls verloren sind. Instanzen, Organisation und Bucket bleiben bestehen. Der eigentliche Apply erfordert weiterhin einen getrennten, konkret geprüften Plan und die normale Commit-Freigabe. + +Diese Recovery ist absichtlich ein einmaliger Vorgang. Bei einem Teilfehler zuerst Inventar und State neu prüfen; nicht denselben Import blind wiederholen. Nach Abschluss beide Einmalvariablen entfernen. Normale Plan-Läufe überspringen Recovery. + +### Zeitlimits und Diagnose + +Plattform-Jobs haben 100 Minuten statt 45 Minuten Laufzeit. Ein Plattform-Apply erhält maximal 70 Minuten; spätestens nach insgesamt 80 Minuten Skriptlaufzeit beginnt ein geordneter Abbruch. Der Prozess erhält genau ein `SIGINT` und bis zu zehn Minuten für Provider-Abbruch und State-Persistierung. Erst danach wird ein nicht reagierender Prozess beendet. Andere Roots behalten kürzere interne Grenzen. Ein Interrupt gilt auch bei Exit-Code 0 als fehlgeschlagener Versuch und darf nicht als erfolgreicher Apply gemeldet werden. + +Nach Apply/Recovery wird ein Remote-State-Snapshot versucht; ein lokaler `errored.tfstate` wird ebenfalls geschützt gesichert. Diagnose und Snapshots liegen ausschließlich als AES-256-GCM-Envelope in `.local/ci-recovery/*.enc.json` und werden auch bei fehlgeschlagenen Jobs für sieben Tage als GitHub-Artefakt hochgeladen. Schlüssel ist der jeweilige State-Schlüssel, daraus wird per scrypt mit zufälligem Salt ein Artefaktschlüssel abgeleitet. Der Envelope enthält `salt`, `nonce`, `tag` und `data` als Base64; Klartext darf nur lokal mit restriktiven Dateirechten entschlüsselt werden. Ein Snapshot kann zusätzlich OpenTofu-verschlüsselt sein. Abrupte Runner-Verluste können weiterhin Recovery erfordern; die Prozesssteuerung ersetzt keine erfolgreiche State-Persistierung. diff --git a/landing-zone-configurator/infra/ci/process.mjs b/landing-zone-configurator/infra/ci/process.mjs new file mode 100644 index 0000000..44007eb --- /dev/null +++ b/landing-zone-configurator/infra/ci/process.mjs @@ -0,0 +1,46 @@ +import { spawn } from "node:child_process"; +import { randomBytes, scryptSync, createCipheriv } from "node:crypto"; +import { writeFileSync } from "node:fs"; + +// One interrupt lets OpenTofu stop providers and persist state. Never use a second +// interrupt as a normal timeout: OpenTofu treats it as an immediate abort. +export function runProcess(binary, args, { env, timeoutMs = 600_000, graceMs = 600_000, heartbeatMs = 30_000 } = {}) { + return new Promise((resolve, reject) => { + const child = spawn(binary, args, { env, stdio: ["ignore", "pipe", "pipe"] }); + const chunks = { stdout: [], stderr: [] }; + let size = 0, interrupted = false, overflow = false, killTimer; + const interrupt = () => { + if (interrupted) return; + interrupted = true; + child.kill("SIGINT"); + killTimer = setTimeout(() => child.kill("SIGKILL"), graceMs); + }; + const timeout = setTimeout(interrupt, timeoutMs); + const heartbeat = heartbeatMs ? setInterval(() => console.log(interrupted ? "OpenTofu stopping; waiting for state persistence." : "OpenTofu operation still running."), heartbeatMs) : undefined; + const cleanup = () => { + clearTimeout(timeout); clearTimeout(killTimer); clearInterval(heartbeat); + process.off("SIGINT", interrupt); process.off("SIGTERM", interrupt); + }; + process.on("SIGINT", interrupt); process.on("SIGTERM", interrupt); + for (const stream of ["stdout", "stderr"]) child[stream].on("data", data => { + size += data.length; + if (size <= 16 * 1024 * 1024) chunks[stream].push(data); + else { overflow = true; interrupt(); } + }); + child.once("error", () => { cleanup(); reject(new Error("Unable to start OpenTofu process")); }); + child.once("close", (code, signal) => { + cleanup(); + resolve({ code, signal, interrupted, overflow, stdout: Buffer.concat(chunks.stdout).toString(), stderr: Buffer.concat(chunks.stderr).toString() }); + }); + }); +} + +// Separate envelope from OpenTofu's state encryption; it also protects diagnostic +// text and any plaintext emergency state produced by a failed backend write. +export function writeProtectedArtifact(path, content, passphrase) { + if (passphrase.length < 32) throw new Error("Invalid artifact encryption key"); + const salt = randomBytes(16), nonce = randomBytes(12); + const cipher = createCipheriv("aes-256-gcm", scryptSync(passphrase, salt, 32), nonce); + const data = Buffer.concat([cipher.update(content), cipher.final()]); + writeFileSync(path, JSON.stringify({ version: 1, kdf: "scrypt", cipher: "aes-256-gcm", salt: salt.toString("base64"), nonce: nonce.toString("base64"), tag: cipher.getAuthTag().toString("base64"), data: data.toString("base64") }), { mode: 0o600 }); +} diff --git a/landing-zone-configurator/infra/ci/process.test.mjs b/landing-zone-configurator/infra/ci/process.test.mjs new file mode 100644 index 0000000..1bf1cfc --- /dev/null +++ b/landing-zone-configurator/infra/ci/process.test.mjs @@ -0,0 +1,28 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createDecipheriv, scryptSync } from "node:crypto"; +import { runProcess, writeProtectedArtifact } from "./process.mjs"; +test("timeout interrupts once and waits for child cleanup", async () => { + const r = await runProcess(process.execPath, ["-e", 'process.on("SIGINT",()=>setTimeout(()=>{console.log("saved");process.exit(0)},80));setInterval(()=>{},20)'], { timeoutMs: 500, graceMs: 2000, heartbeatMs: 0 }); + assert.equal(r.interrupted, true); assert.equal(r.code, 0); assert.equal(r.stdout.trim(), "saved"); +}); +test("unresponsive child is killed after grace period", async () => { + const r = await runProcess(process.execPath, ["-e", 'process.on("SIGINT",()=>{});setInterval(()=>{},20)'], { timeoutMs: 500, graceMs: 100, heartbeatMs: 0 }); + assert.equal(r.signal, "SIGKILL"); assert.equal(r.interrupted, true); +}); +test("diagnostics are authenticated ciphertext decryptable with the state key", () => { + const dir = mkdtempSync(join(tmpdir(), "lzc-artifact-")); + try { + const path = join(dir, "log.enc"), key = "k".repeat(40), secret = "example-sensitive-output"; + writeProtectedArtifact(path, secret, key); const raw = readFileSync(path, "utf8"); assert.ok(!raw.includes(secret)); + const e = JSON.parse(raw), decode = s => Buffer.from(s, "base64"); + const d = createDecipheriv("aes-256-gcm", scryptSync(key, decode(e.salt), 32), decode(e.nonce));d.setAuthTag(decode(e.tag)); + assert.equal(Buffer.concat([d.update(decode(e.data)), d.final()]).toString(), secret); + e.data = Buffer.from("corrupt").toString("base64"); + const bad = createDecipheriv("aes-256-gcm", scryptSync(key, decode(e.salt), 32), decode(e.nonce)); bad.setAuthTag(decode(e.tag)); + assert.throws(()=>{bad.update(decode(e.data));bad.final()}); + } finally { rmSync(dir, { recursive: true, force: true }); } +}); diff --git a/landing-zone-configurator/infra/ci/recovery.mjs b/landing-zone-configurator/infra/ci/recovery.mjs new file mode 100644 index 0000000..939d290 --- /dev/null +++ b/landing-zone-configurator/infra/ci/recovery.mjs @@ -0,0 +1,48 @@ +import { readFileSync } from "node:fs"; +import { randomUUID, sign } from "node:crypto"; + +// STACKIT SDK key-flow: RS512 JWT bearer exchange, no token or assertion logging. +// https://github.com/stackitcloud/stackit-sdk-go/blob/main/core/clients/key_flow.go +export async function accessToken(keyFile) { + const { credentials: c } = JSON.parse(readFileSync(keyFile, "utf8")); + if (c.tokenEndpoint !== "https://accounts.stackit.cloud/oauth/v2/token") throw new Error("Unexpected token endpoint"); + const b64 = value => Buffer.from(JSON.stringify(value)).toString("base64url"); + const now = Math.floor(Date.now() / 1000); + const unsigned = `${b64({ alg: "RS512", typ: "JWT", kid: c.kid })}.${b64({ iss: c.iss, sub: c.sub, aud: c.aud, jti: randomUUID(), iat: now, exp: now + 600 })}`; + const assertion = `${unsigned}.${sign("RSA-SHA512", Buffer.from(unsigned), c.privateKey).toString("base64url")}`; + const r = await fetch(c.tokenEndpoint, { method: "POST", redirect: "error", signal: AbortSignal.timeout(30_000), headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer", assertion }) }); + if (!r.ok) throw new Error(`Recovery authentication failed (HTTP ${r.status})`); + const result = await r.json(); + if (!result.access_token) throw new Error("Missing access token"); + return result.access_token; +} + +export async function recoverPlatform({ manifest, inputs, credentialsPath, tofu, env }) { + if (inputs.root !== "platform" || manifest.project !== inputs.project || process.env.LZC_RECOVERY_COMMIT !== inputs.commit) throw new Error("Recovery requires an exact approved platform commit"); + const previousRun = await fetch(`https://api.github.com/repos/stackitcloud/stackit-landing-zone/actions/runs/${manifest.failedRun}`, { redirect: "error", signal: AbortSignal.timeout(30_000), headers: { Accept: "application/vnd.github+json" } }); + if (!previousRun.ok) throw new Error("Cannot verify failed runner completion"); + const previous = await previousRun.json(); + if (previous.status !== "completed" || !["cancelled", "failure", "timed_out"].includes(previous.conclusion)) throw new Error("Recovery source run is not completed with failure"); + // This one-off manifest is reviewed alongside the code. The protected job shares + // the global mutation group; the failed runner must have completed beforehand. + await tofu("platform", ["force-unlock", "-force", manifest.lockId], env); + for (const [address, id] of Object.entries(manifest.imports)) { + if (!id.startsWith(`${inputs.project},`)) throw new Error("Import project mismatch"); + await tofu("platform", ["import", "-input=false", "-lock-timeout=60s", "-var-file=../environments/lzc-dev.tfvars.json", address, id], env); + console.log(`Recovered ${address} into encrypted remote state.`); + } + // Tokens have no provider importer, and their value is returned only at creation. + // Revoke only the explicitly inventoried orphan; a fresh reviewed plan creates its successor. + const token = await accessToken(credentialsPath); + const url = `https://model-serving.api.stackit.cloud/v1/projects/${inputs.project}/regions/${inputs.region}/tokens/${manifest.unrecoverableToken.id}`; + const options = { headers: { Authorization: `Bearer ${token}` }, redirect: "error", signal: AbortSignal.timeout(30_000) }; + const current = await fetch(url, options); + if (current.status === 404) { console.log("Orphan token already absent."); return; } + if (!current.ok) throw new Error(`Token inventory failed (HTTP ${current.status})`); + const body = await current.json(); + const details = body.token; + if (details?.id !== manifest.unrecoverableToken.id || details?.name !== manifest.unrecoverableToken.name) throw new Error("Orphan token identity mismatch"); + const revoked = await fetch(url, { ...options, method: "DELETE", signal: AbortSignal.timeout(30_000) }); + if (!revoked.ok) throw new Error(`Orphan token revocation failed (HTTP ${revoked.status})`); + console.log("Revoked the inventoried orphan Model Serving token."); +} diff --git a/landing-zone-configurator/infra/ci/recovery.test.mjs b/landing-zone-configurator/infra/ci/recovery.test.mjs new file mode 100644 index 0000000..6d92b77 --- /dev/null +++ b/landing-zone-configurator/infra/ci/recovery.test.mjs @@ -0,0 +1,21 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { recoverPlatform } from "./recovery.mjs"; +const inputs={root:"platform",project:"project",commit:"a".repeat(40)}; +const manifest={project:"project",failedRun:"123",lockId:"known-lock",imports:{"stackit_scf_organization.configurator":"project,eu01,id"}}; +test("recovery rejects unapproved commit before any cloud access",async()=>{ + await assert.rejects(recoverPlatform({manifest,inputs}),/exact approved/); +}); +test("active prior runner blocks unlocking and imports",async()=>{ + const original=globalThis.fetch;process.env.LZC_RECOVERY_COMMIT=inputs.commit; + try { globalThis.fetch=async()=>({ok:true,json:async()=>({status:"in_progress",conclusion:null})}); + await assert.rejects(recoverPlatform({manifest,inputs,tofu:()=>assert.fail("must not mutate")}),/not completed/); + }finally{globalThis.fetch=original;delete process.env.LZC_RECOVERY_COMMIT;} +}); +test("failed import stops before credential revocation",async()=>{ + const original=globalThis.fetch;process.env.LZC_RECOVERY_COMMIT=inputs.commit;const calls=[]; + try { globalThis.fetch=async()=>({ok:true,json:async()=>({status:"completed",conclusion:"cancelled"})}); + await assert.rejects(recoverPlatform({manifest,inputs,tofu:async(_root,args)=>{calls.push(args);if(args[0]==="import")throw new Error("import failed")}}),/import failed/); + assert.deepEqual(calls.map(x=>x[0]),["force-unlock","import"]);assert.equal(calls[0][2],"known-lock"); + }finally{globalThis.fetch=original;delete process.env.LZC_RECOVERY_COMMIT;} +}); diff --git a/landing-zone-configurator/infra/ci/recovery/lzc-dev-20260930.json b/landing-zone-configurator/infra/ci/recovery/lzc-dev-20260930.json new file mode 100644 index 0000000..cf96f5f --- /dev/null +++ b/landing-zone-configurator/infra/ci/recovery/lzc-dev-20260930.json @@ -0,0 +1,17 @@ +{ + "project": "7dbff805-6788-4d5d-88c0-a5212ac5e90f", + "failedRun": "36632950493", + "lockId": "7fb299bb-d53b-d726-3322-243a1f8ada25", + "imports": { + "stackit_scf_organization.configurator": "7dbff805-6788-4d5d-88c0-a5212ac5e90f,eu01,a4514c42-d378-4e36-90eb-a3a1e3017e65", + "stackit_scf_organization_manager.configurator": "7dbff805-6788-4d5d-88c0-a5212ac5e90f,eu01,a4514c42-d378-4e36-90eb-a3a1e3017e65,f26a6306-8639-4807-82ab-4a2563985bb3", + "stackit_postgresflex_instance.configurator": "7dbff805-6788-4d5d-88c0-a5212ac5e90f,eu01,fd1451c7-6da1-4e4d-a2b2-416e640ce70c", + "stackit_secretsmanager_instance.configurator": "7dbff805-6788-4d5d-88c0-a5212ac5e90f,d1331c32-8371-4a53-91f1-0229865db45f", + "stackit_secretsmanager_user.provisioner": "7dbff805-6788-4d5d-88c0-a5212ac5e90f,d1331c32-8371-4a53-91f1-0229865db45f,0ce4930e-3dac-4161-a3fe-23385c46a04f", + "stackit_objectstorage_bucket.artifacts": "7dbff805-6788-4d5d-88c0-a5212ac5e90f,eu01,lzc-dev-artifacts-7dbff805" + }, + "unrecoverableToken": { + "id": "b1ffd4b1-639c-4b58-b08f-f66a69a36117", + "name": "lzc-dev-chat" + } +} diff --git a/landing-zone-configurator/infra/ci/run.mjs b/landing-zone-configurator/infra/ci/run.mjs index 2cfcd39..ec417d4 100644 --- a/landing-zone-configurator/infra/ci/run.mjs +++ b/landing-zone-configurator/infra/ci/run.mjs @@ -1,22 +1,27 @@ // CI-only entrypoint. No local .env, seed output, state or encryption key is required. import { execFileSync } from "node:child_process"; -import { readFileSync, writeFileSync, mkdirSync, rmSync, readdirSync, appendFileSync } from "node:fs"; +import { readFileSync, writeFileSync, mkdirSync, rmSync, readdirSync, appendFileSync, existsSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { deploymentInputs, digest, planContext, assertApplyExecution } from "./context.mjs"; import { isApplicablePlan } from "../plan-guard.mjs"; +import { runProcess, writeProtectedArtifact } from "./process.mjs"; +import { recoverPlatform } from "./recovery.mjs"; const infra = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const [command, ...extra] = process.argv.slice(2); -if (!["plan", "apply"].includes(command) || extra.length) throw new Error("Usage: node infra/ci/run.mjs plan|apply"); +if (!["plan", "apply", "recover"].includes(command) || extra.length) throw new Error("Usage: node infra/ci/run.mjs plan|apply|recover"); const inputs = deploymentInputs(process.env); const safety = JSON.parse(readFileSync(resolve(infra,"ci/backend-safety.json"),"utf8")); -if(command === "apply") assertApplyExecution(safety, process.env); +if(command !== "plan") assertApplyExecution(safety, process.env); +const startedAt = Date.now(); const binary = process.env.LZC_TOFU_BIN ?? "tofu"; const temporary = resolve(process.env.RUNNER_TEMP ?? infra, `lzc-ci-${inputs.run}-${command}`); const artifact = resolve(infra,"../.local/ci-plan"); process.umask(0o077); mkdirSync(temporary,{recursive:true,mode:0o700}); mkdirSync(artifact,{recursive:true,mode:0o700}); +const recoveryArtifact = resolve(infra,"../.local/ci-recovery"); +mkdirSync(recoveryArtifact,{recursive:true,mode:0o700}); const cleanEnv = Object.fromEntries(Object.entries(process.env).filter(([k]) => !/^(AWS_|TF_|STACKIT_|LZC_)/.test(k))); const secret = (name) => { if(!process.env[name])throw new Error(`Missing ${name}`); return process.env[name]; }; const credentialsPath=resolve(temporary,"service-account.json"); @@ -29,11 +34,27 @@ const environment = (root) => ({...cleanEnv, TF_DATA_DIR:resolve(temporary,root) AWS_ACCESS_KEY_ID:secret("LZC_MANAGEMENT_ACCESS_KEY"), AWS_SECRET_ACCESS_KEY:secret("LZC_MANAGEMENT_SECRET_KEY"), STACKIT_SERVICE_ACCOUNT_KEY_PATH:credentialsPath, TF_VAR_project_id:inputs.project, TF_VAR_region:inputs.region, TF_VAR_name_prefix:inputs.prefix, TF_VAR_state_credential_expiration:inputs.expiration }); -function tofu(root,args,env) { - try { return execFileSync(binary,[`-chdir=${resolve(infra,root)}`,...args],{env,encoding:"utf8",stdio:["ignore","pipe","pipe"],maxBuffer:16*1024*1024}); } - catch { throw new Error(`OpenTofu ${root}/${args[0]} failed; raw output withheld to protect credentials`); } +async function tofu(root,args,env, snapshot = false) { + const remaining = Math.max(1, (inputs.root === "platform" ? 80 : 25)*60_000 - (Date.now()-startedAt)); + const timeoutMs = snapshot ? 60_000 : Math.min(remaining, args[0] === "apply" && root === "platform" ? 70*60_000 : 10*60_000); + const result = await runProcess(binary,[`-chdir=${resolve(infra,root)}`,...args],{env, timeoutMs, graceMs: snapshot ? 30_000 : 10*60_000}); + if (args[0] === "apply" || result.code !== 0 || result.interrupted) { + writeProtectedArtifact(resolve(recoveryArtifact,`${root}-${args[0]}-${Date.now()}.enc.json`),JSON.stringify(result),secret(`LZC_STATE_KEY_${root.toUpperCase()}`).trim()); + } + if(result.code !== 0 || result.interrupted || result.overflow) throw new Error(`OpenTofu ${root}/${args[0]} failed or was interrupted; inspect encrypted diagnostics`); + return result.stdout; +} +async function preserveState(env) { + const key = secret(`LZC_STATE_KEY_${inputs.root.toUpperCase()}`).trim(); + const emergency = resolve(infra,inputs.root,"errored.tfstate"); + if(existsSync(emergency)) writeProtectedArtifact(resolve(recoveryArtifact,"emergency-state.enc.json"),readFileSync(emergency),key); + try { + const state = await tofu(inputs.root,["state","pull"],env,true); + writeProtectedArtifact(resolve(recoveryArtifact,"remote-state.enc.json"),state,key); + console.log("Protected remote-state snapshot saved."); + } catch { console.log("Remote-state snapshot unavailable; inspect protected diagnostics and emergency artifact."); } } -function initialize(root, storage) { +async function initialize(root, storage) { const env=environment(root); if(storage) { env.AWS_ACCESS_KEY_ID=storage.credentials.access_key; env.AWS_SECRET_ACCESS_KEY=storage.credentials.secret_key; } const backend={bucket:storage?.bucket ?? inputs.bucket,key:`configurator/${inputs.prefix}/${root}/terraform.tfstate`,region:inputs.region, @@ -41,7 +62,7 @@ function initialize(root, storage) { skip_credentials_validation:true,skip_region_validation:true,skip_requesting_account_id:true,skip_metadata_api_check:true,skip_s3_checksum:true}; const file=resolve(temporary,`${root}.backend.hcl`); writeFileSync(file,Object.entries(backend).map(([k,v])=>`${k} = ${JSON.stringify(v)}`).join("\n")); - tofu(root,["init",`-backend-config=${file}`,"-lockfile=readonly","-input=false","-no-color"],env); + await tofu(root,["init",`-backend-config=${file}`,"-lockfile=readonly","-input=false","-no-color"],env); return env; } function sourceDigest() { @@ -62,16 +83,16 @@ try { writeFileSync(credentialsPath,credentialJson,{mode:0o600}); let storage; if(inputs.root === "platform") { - const bootstrapEnv=initialize("bootstrap"); - const outputs=JSON.parse(tofu("bootstrap",["output","-json"],bootstrapEnv)); - const protection=JSON.parse(tofu("backend",["output","-json"],initialize("backend"))); + const bootstrapEnv=await initialize("bootstrap"); + const outputs=JSON.parse(await tofu("bootstrap",["output","-json"],bootstrapEnv)); + const protection=JSON.parse(await tofu("backend",["output","-json"],await initialize("backend"))); if(outputs.backend?.value?.region !== inputs.region || protection.versioning_enabled?.value !== true || protection.state_bucket_name?.value !== outputs.backend.value.bucket) throw new Error("Versioned platform backend is not ready"); storage={bucket:outputs.backend.value.bucket,credentials:outputs.backend_credentials.value}; } - const env=initialize(inputs.root,storage); + const env=await initialize(inputs.root,storage); if(inputs.root==="backend") { - const bootstrapEnv=initialize("bootstrap"); - const outputs=JSON.parse(tofu("bootstrap",["output","-json"],bootstrapEnv)); + const bootstrapEnv=await initialize("bootstrap"); + const outputs=JSON.parse(await tofu("bootstrap",["output","-json"],bootstrapEnv)); if(outputs.backend?.value?.region!==inputs.region)throw new Error("Bootstrap region mismatch"); env.TF_VAR_state_bucket_name=outputs.backend.value.bucket; env.TF_VAR_storage_access_key=outputs.backend_credentials.value.access_key; @@ -80,13 +101,17 @@ try { const contextHash=planContext(inputs,sourceDigest()); const plan=resolve(artifact,"review.tfplan"); const manifest=resolve(artifact,"review.json"); - if(command==="plan") { + if(command==="recover") { + try { await recoverPlatform({manifest:JSON.parse(readFileSync(resolve(infra,"ci/recovery/lzc-dev-20260930.json"),"utf8")), inputs, credentialsPath, tofu, env}); } + finally { await preserveState(env); } + } else if(command==="plan") { writeFileSync(manifest,JSON.stringify({status:"planning"})); const parameters=inputs.root === "platform" ? [`-var-file=${resolve(infra,"environments/lzc-dev.tfvars.json")}`] : []; - tofu(inputs.root,["plan",...parameters,`-out=${plan}`,"-input=false","-lock-timeout=60s","-no-color"],env); + if(inputs.root === "platform" && process.env.LZC_RECOVERY_COMMIT === inputs.commit) parameters.push("-replace=stackit_scf_organization_manager.configurator","-replace=stackit_secretsmanager_user.provisioner"); + await tofu(inputs.root,["plan",...parameters,`-out=${plan}`,"-input=false","-lock-timeout=60s","-no-color"],env); const envelope=JSON.parse(readFileSync(plan,"utf8")); if(!envelope.encrypted_data||!envelope.encryption_version)throw new Error("Refusing an unencrypted plan artifact"); - const details=JSON.parse(tofu(inputs.root,["show","-json",plan],env)); + const details=JSON.parse(await tofu(inputs.root,["show","-json",plan],env)); const summary=(details.resource_changes??[]).map(r=>({address:r.address,actions:r.change.actions})); const review={status:"ready",contextHash,planHash:digest(readFileSync(plan)),createdAt:new Date().toISOString(),...inputs}; writeFileSync(manifest,JSON.stringify(review,null,2)); @@ -99,7 +124,8 @@ try { if(!isApplicablePlan(review,contextHash,digest(readFileSync(plan))))throw new Error("Plan mismatch or expiry; start a fresh workflow run"); // Saved plan applies exactly the reviewed actions. No fresh plan is generated here. writeFileSync(manifest,JSON.stringify({...review,status:"consumed"})); - tofu(inputs.root,["apply","-lock-timeout=60s","-no-color",plan],env); + try { await tofu(inputs.root,["apply","-lock-timeout=60s","-no-color",plan],env); } + finally { await preserveState(env); } console.log(`Applied reviewed ${inputs.root} plan successfully.`); } } finally { From 44fe846f1e702a05def013eec14535ef57475715 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Weberru=C3=9F?= Date: Wed, 30 Sep 2026 07:58:23 +0200 Subject: [PATCH 012/175] docs(configurator): record successful platform recovery and verification --- landing-zone-configurator/docs/planning.md | 5 +- .../docs/platform-readiness.md | 47 ++++++++++++++----- landing-zone-configurator/infra/ci/README.md | 3 ++ 3 files changed, 43 insertions(+), 12 deletions(-) diff --git a/landing-zone-configurator/docs/planning.md b/landing-zone-configurator/docs/planning.md index a8b15bc..86dddca 100644 --- a/landing-zone-configurator/docs/planning.md +++ b/landing-zone-configurator/docs/planning.md @@ -604,4 +604,7 @@ Backend-Apply abgeschlossen: Run 36602706599, Versionierung für den Workload-St Netzwerk-Korrektur: CF-Egress-Adressen sind nicht dokumentiert/stabil (Benutzerhinweis). Die bisherige Anforderung fester CF-CIDRs entfällt. Stattdessen dienstspezifische, unterstützte Zugangsmodelle prüfen: dokumentierte STACKIT-Netze für PostgreSQL mit Konnektivitätstest; Secrets Manager separat. Migrationen als CF-Task vorsehen. Service-Bindings nicht als Netzwerkfreigabe behandeln. Siehe [Plattform-Vorbereitung](platform-readiness.md). -Plattform-Versuch 2026-09-30: Plan mit neun Creates und Validierung erfolgreich; Apply-Run 36632950493 nach 45 Minuten abgebrochen. Teilbestand existiert (CF aktiv, Secrets Manager Running, PostgreSQL zuletzt PROGRESSING), Plattform-State fehlt. Einmalige Freigabe entfernt und Plattform-CI vorübergehend deaktiviert. Nächster Schritt ist die kontrollierte State-Recovery und verbesserte Abbruchbehandlung, nicht ein erneuter ursprünglicher Apply. Details und Checkliste: [Plattform-Betriebsstand](platform-readiness.md#betriebsstand-2026-09-30-erster-plattform-apply-unvollständig). +Plattform-Versuch 2026-09-30: Plan mit neun Creates und Validierung erfolgreich; Apply-Run 36632950493 nach 45 Minuten abgebrochen. Teilbestand existiert (CF aktiv, Secrets Manager Running, PostgreSQL zuletzt PROGRESSING), Plattform-State fehlt. Einmalige Freigabe entfernt und Plattform-CI vorübergehend deaktiviert. Nächster Schritt ist die kontrollierte State-Recovery und verbesserte Abbruchbehandlung, nicht ein erneuter ursprünglicher Apply. Details und Checkliste: [Plattform-Betriebsstand](platform-readiness.md#vorfall-2026-09-30-erster-plattform-apply-unvollständig). + + +Plattform-Recovery abgeschlossen (2026-09-30): Run 36675347654 importierte den Teilbestand und vervollständigte die Plattform. Neun Ressourcen im verschlüsselten Remote-State unabhängig geprüft; PostgreSQL READY, CF aktiv, Secrets Manager Running. Verlorene technische Credentials kontrolliert ersetzt. Job-Timeout auf 100 Minuten erhöht, geordneten OpenTofu-Abbruch und verschlüsselte Diagnose-/State-Artefakte ergänzt. Einmalfreigaben entfernt. Nächster Ausbauschritt nach No-op-Abnahme: CF-Runtime/Bindings und Zugriffstests aus CF. [Aktuelle Abnahme](platform-readiness.md#erfolgreiche-recovery-und-plattform-abnahme). diff --git a/landing-zone-configurator/docs/platform-readiness.md b/landing-zone-configurator/docs/platform-readiness.md index 65a56a5..5c571c4 100644 --- a/landing-zone-configurator/docs/platform-readiness.md +++ b/landing-zone-configurator/docs/platform-readiness.md @@ -1,4 +1,6 @@ -# Plattform-Vorbereitung nach Bootstrap und Backend +# Plattform-Betriebsstand nach Bootstrap und Backend + +**Aktuell (2026-09-30):** Plattform-Recovery und Apply erfolgreich. Neun Ressourcen sind im verschlüsselten Remote-State erfasst. PostgreSQL ist `READY`, CF aktiv, Secrets Manager `Running`. [Recovery-/Apply-Run 36675347654](https://github.com/stackitcloud/stackit-landing-zone/actions/runs/36675347654). CF-Runtime und Zugriffstests sind weiterhin offen. ## Abgeschlossen @@ -27,9 +29,9 @@ Die folgenden Werte wurden anhand der Projektkataloge ausgewählt. Verbindliche - [ ] Zugangsmodell des Secrets Managers separat klären; PostgreSQL-Netzfreigaben nicht ungeprüft übertragen. - [x] Migrationen als CF-Tasks vorgesehen; technische Umsetzung und Operator-Zugriff noch offen. - [x] Plattform-CI mit getrennten Environments und eigenem State-Key anbinden. -- [ ] Konkreten Plattform-Plan prüfen und anschließend deployen. +- [x] Konkreten Plattform-Plan prüfen und anschließend deployen. -Der erste Plattform-Apply wurde gestartet, aber wegen des Job-Zeitlimits abgebrochen. Es existiert ein Teilbestand; siehe den aktuellen Betriebsstand unten. Alle Arbeiten verbleiben auf `feature/landing-zone-configurator`. +Der erste Plattform-Apply wurde wegen des Job-Zeitlimits abgebrochen. Der Teilbestand wurde inzwischen erfolgreich importiert und das Deployment vervollständigt; siehe die Recovery-Abnahme unten. Alle Arbeiten verbleiben auf `feature/landing-zone-configurator`. ## Korrigiertes Netzwerkmodell @@ -53,7 +55,7 @@ Die gesonderte Freigabe zur Secret-Hinterlegung in `lzc-dev-platform-plan` und ` Lokaler technischer Nachweis: echter Plattform-Plan erfolgreich, **9 Create / 0 Update / 0 Delete** (CF-Organisation und Manager, PostgreSQL-Instanz/Datenbank/Migrationsnutzer, Secrets-Manager-Instanz/Provisionierungsnutzer, Artefakt-Bucket und Model-Serving-Token). Sechs Plattform-Mock-Tests, 16 Node-Tests und Workflow-Prüfung erfolgreich. Der lokale Provider-Download hing; die Prüfung wurde mit bereits installierten, gegen das Lockfile geprüften Provider-Binaries wiederholt. Dieser lokale Nachweis führte keinen Apply aus. Die Plattform-Pipeline ist nach der genehmigten Environment-/Secret-Einrichtung über `LZC_PLATFORM_CI_ENABLED` aktiviert. -## Betriebsstand 2026-09-30: erster Plattform-Apply unvollständig +## Vorfall 2026-09-30: erster Plattform-Apply unvollständig - [x] Secrets mit ausdrücklicher Benutzerfreigabe in die beiden Plattform-Environments übertragen; Werte nicht protokolliert. - [x] CI-Plan und verschlüsseltes Artefakt geprüft: Commit `3322dd99a37a495fda8ba46fc4eb6284895d4316`, neun Create, keine Update/Delete; Hash und Run-Zuordnung geprüft. @@ -66,13 +68,36 @@ Direkte API-Prüfung nach dem Abbruch: PostgreSQL `lzc-dev-db` wechselte von `PE Der erwartete Plattform-State im Workload-Bucket ist nicht vorhanden (`NoSuchKey`). Auch die Objektversionsliste enthält keinen Plattform-State, aber ein verbliebenes `.tflock`-Objekt. Dieses ist kein Ersatz für den State und wurde nicht entfernt. Es wurde kein weiterer Apply gestartet. Der ursprüngliche Plan darf nicht wiederverwendet werden: Er plant neue Ressourcen und kennt den inzwischen vorhandenen Teilbestand nicht. -### Wiederaufnahme und Abnahme +### Wiederaufnahme und Abnahme (abgeschlossen, Details unten) -- [ ] PostgreSQL-Endzustand über die API prüfen; bei weiterhin festhängender Bereitstellung STACKIT-Service-Diagnose mit Instanz-ID aus dem lokalen Inventar veranlassen. -- [ ] Vollständiges Inventar aller neun geplanten Ressourcen einschließlich technischer Benutzer und Model-Serving-Token erstellen; IDs und Zugangsdaten geschützt behandeln. -- [ ] State-Recovery über einen dedizierten, serialisierten CI-Weg vorbereiten: vorhandene importierbare Ressourcen importieren, nicht wiederherstellbare Einmal-Credentials kontrolliert rotieren. Keine Doppelanlage oder ungeprüfte Löschung. -- [ ] Verbliebenes Lock erst nach Prüfung auf beendete Runner und vor kontrollierter Recovery behandeln; native S3-Sperre bleibt als unzuverlässig dokumentiert. -- [ ] CI-Prozesssteuerung verbessern: rechtzeitiger geordneter OpenTofu-Abbruch vor dem äußeren Job-Zeitlimit, geschützte Diagnose und verschlüsselte Recovery-Artefakte. Nur das Timeout zu erhöhen behebt den fehlenden State nicht. -- [ ] Nach Recovery frischen Plan prüfen; erst dann Plattform-CI wieder aktivieren und neue konkrete Apply-Freigabe setzen. +- [x] PostgreSQL-Endzustand über die API prüfen (`READY`); bei weiterhin festhängender Bereitstellung STACKIT-Service-Diagnose mit Instanz-ID aus dem lokalen Inventar veranlassen. +- [x] Vollständiges Inventar aller neun geplanten Ressourcen einschließlich technischer Benutzer und Model-Serving-Token erstellen; IDs und Zugangsdaten geschützt behandeln. +- [x] State-Recovery über einen dedizierten, serialisierten CI-Weg durchführen: vorhandene importierbare Ressourcen importieren, nicht wiederherstellbare Einmal-Credentials kontrolliert rotieren. Keine Doppelanlage oder ungeprüfte Löschung. +- [x] Verbliebenes Lock erst nach Prüfung auf beendete Runner und vor kontrollierter Recovery behandeln; native S3-Sperre bleibt als unzuverlässig dokumentiert. +- [x] CI-Prozesssteuerung verbessern: rechtzeitiger geordneter OpenTofu-Abbruch vor dem äußeren Job-Zeitlimit, geschützte Diagnose und verschlüsselte Recovery-Artefakte. Nur das Timeout zu erhöhen behebt den fehlenden State nicht. +- [x] Nach Recovery frischen Plan prüfen; erst dann Plattform-CI wieder aktivieren und neue konkrete Apply-Freigabe setzen. - [ ] Verschlüsselten Remote-State, vollständige Ressourcen und anschließenden No-op-Plan unabhängig verifizieren. - [ ] CF-Runtime (Space, Apps, Bindings, separate Laufzeitidentitäten) per IaC erstellen und PostgreSQL-/Secrets-Zugriff aus CF testen. + + +## Erfolgreiche Recovery und Plattform-Abnahme + +Commit `1843069ce6914c26d04a7461d0799fc1c58794d7`, [Run 36675347654](https://github.com/stackitcloud/stackit-landing-zone/actions/runs/36675347654): Recovery, Plan und Apply erfolgreich. [Validierungs-Run 36675347681](https://github.com/stackitcloud/stackit-landing-zone/actions/runs/36675347681) ebenfalls erfolgreich. + +Sechs vorhandene Ressourcen wurden importiert, ohne die Instanzen, CF-Organisation oder den Artefakt-Bucket neu anzulegen. Der verwaiste Model-Serving-Token wurde widerrufen. Der frisch geprüfte Plan erstellte Datenbank, Migrationsbenutzer und neuen Model-Serving-Token; CF-Manager und Secrets-Provisionierungsbenutzer wurden wegen der verlorenen Passwörter ersetzt. Das PostgreSQL-Update betraf berechnete Flavor-Metadaten, bei unveränderter Flavor-ID und unveränderten Infrastrukturparametern. + +Unabhängige Abnahme: + +- [x] Verschlüsseltes State-Objekt im Workload-Bucket vorhanden; neun verwaltete Ressourcen geprüft. +- [x] Beide getrennten Netzwerk-ACLs entsprechen den geplanten Netzen. +- [x] PostgreSQL `READY`, Datenbank `configurator` mit Owner `configurator_migration` vorhanden. +- [x] CF-Organisation aktiv und Secrets Manager `Running`. +- [x] Neue technische Benutzer und genau ein neuer aktiver Configurator-Model-Serving-Token in der API bestätigt; alte Identitäten ersetzt. +- [x] Erforderliche Passwörter und Token im verschlüsselten State vorhanden, ohne Werte auszugeben. +- [x] Geschützte Diagnose und State-Snapshot aus GitHub heruntergeladen; authentifizierte Entschlüsselung und JSON-Integrität geprüft. +- [x] Beide Einmalvariablen `LZC_PLATFORM_RECOVERY_COMMIT` und `LZC_PLATFORM_APPLY_COMMIT` entfernt. Plattform-CI wieder für Plan-Läufe aktiv. +- [ ] Abschließenden Plan ohne Änderungen bestätigen. + +Plattform-Jobs haben jetzt ein äußeres Zeitlimit von 100 Minuten. OpenTofu-Apply erhält maximal 70 Minuten und danach bis zu zehn Minuten zum geordneten Beenden; eine zusätzliche Gesamtlaufzeitgrenze des Skripts hält Puffer für Artefakte frei. 22 lokale Tests einschließlich Abbruchverhalten, verschlüsselter Diagnose und Recovery-Sperren sowie Workflow-Prüfung bestanden. + +Nächster Ausbau: CF-Space und App-/Task-Runtime per IaC, getrennte Laufzeitidentitäten/Bindings und echte TLS-/Authentifizierungstests aus CF. Die Infrastrukturabnahme belegt noch keinen erfolgreichen App-Zugriff auf Datenbank oder Secrets Manager. diff --git a/landing-zone-configurator/infra/ci/README.md b/landing-zone-configurator/infra/ci/README.md index 9993dfb..c28cfc0 100644 --- a/landing-zone-configurator/infra/ci/README.md +++ b/landing-zone-configurator/infra/ci/README.md @@ -87,3 +87,6 @@ Diese Recovery ist absichtlich ein einmaliger Vorgang. Bei einem Teilfehler zuer Plattform-Jobs haben 100 Minuten statt 45 Minuten Laufzeit. Ein Plattform-Apply erhält maximal 70 Minuten; spätestens nach insgesamt 80 Minuten Skriptlaufzeit beginnt ein geordneter Abbruch. Der Prozess erhält genau ein `SIGINT` und bis zu zehn Minuten für Provider-Abbruch und State-Persistierung. Erst danach wird ein nicht reagierender Prozess beendet. Andere Roots behalten kürzere interne Grenzen. Ein Interrupt gilt auch bei Exit-Code 0 als fehlgeschlagener Versuch und darf nicht als erfolgreicher Apply gemeldet werden. Nach Apply/Recovery wird ein Remote-State-Snapshot versucht; ein lokaler `errored.tfstate` wird ebenfalls geschützt gesichert. Diagnose und Snapshots liegen ausschließlich als AES-256-GCM-Envelope in `.local/ci-recovery/*.enc.json` und werden auch bei fehlgeschlagenen Jobs für sieben Tage als GitHub-Artefakt hochgeladen. Schlüssel ist der jeweilige State-Schlüssel, daraus wird per scrypt mit zufälligem Salt ein Artefaktschlüssel abgeleitet. Der Envelope enthält `salt`, `nonce`, `tag` und `data` als Base64; Klartext darf nur lokal mit restriktiven Dateirechten entschlüsselt werden. Ein Snapshot kann zusätzlich OpenTofu-verschlüsselt sein. Abrupte Runner-Verluste können weiterhin Recovery erfordern; die Prozesssteuerung ersetzt keine erfolgreiche State-Persistierung. + + +Recovery-Abnahme 2026-09-30: [Run 36675347654](https://github.com/stackitcloud/stackit-landing-zone/actions/runs/36675347654) erfolgreich. Alle sechs Imports, gezielter Token-Widerruf, frischer Plan und Apply abgeschlossen. Neun Ressourcen im verschlüsselten Remote-State unabhängig verifiziert; geschützte Apply-Diagnose und State-Snapshot erfolgreich entschlüsselt und geprüft. Beide Einmalvariablen wurden entfernt. `LZC_PLATFORM_CI_ENABLED=true` erlaubt wieder normale Plan-Läufe; der historische Recovery-Job bleibt ohne explizite Commit-Freigabe übersprungen. From 0f45fc0e128b44ac951490c52f19007303fc0773 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Weberru=C3=9F?= Date: Wed, 30 Sep 2026 07:59:49 +0200 Subject: [PATCH 013/175] docs(configurator): confirm platform no-op acceptance --- landing-zone-configurator/docs/planning.md | 3 +++ landing-zone-configurator/docs/platform-readiness.md | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/landing-zone-configurator/docs/planning.md b/landing-zone-configurator/docs/planning.md index 86dddca..08099bc 100644 --- a/landing-zone-configurator/docs/planning.md +++ b/landing-zone-configurator/docs/planning.md @@ -608,3 +608,6 @@ Plattform-Versuch 2026-09-30: Plan mit neun Creates und Validierung erfolgreich; Plattform-Recovery abgeschlossen (2026-09-30): Run 36675347654 importierte den Teilbestand und vervollständigte die Plattform. Neun Ressourcen im verschlüsselten Remote-State unabhängig geprüft; PostgreSQL READY, CF aktiv, Secrets Manager Running. Verlorene technische Credentials kontrolliert ersetzt. Job-Timeout auf 100 Minuten erhöht, geordneten OpenTofu-Abbruch und verschlüsselte Diagnose-/State-Artefakte ergänzt. Einmalfreigaben entfernt. Nächster Ausbauschritt nach No-op-Abnahme: CF-Runtime/Bindings und Zugriffstests aus CF. [Aktuelle Abnahme](platform-readiness.md#erfolgreiche-recovery-und-plattform-abnahme). + + +No-op-Abnahme abgeschlossen: Plattform-Run 36675895279 zeigt für alle neun Ressourcen `no-op`; Recovery/Apply korrekt übersprungen. Validierung 36675895374 erfolgreich. Die Plattform ist damit konsistent unter IaC-Verwaltung. diff --git a/landing-zone-configurator/docs/platform-readiness.md b/landing-zone-configurator/docs/platform-readiness.md index 5c571c4..8f177c5 100644 --- a/landing-zone-configurator/docs/platform-readiness.md +++ b/landing-zone-configurator/docs/platform-readiness.md @@ -76,7 +76,7 @@ Der erwartete Plattform-State im Workload-Bucket ist nicht vorhanden (`NoSuchKey - [x] Verbliebenes Lock erst nach Prüfung auf beendete Runner und vor kontrollierter Recovery behandeln; native S3-Sperre bleibt als unzuverlässig dokumentiert. - [x] CI-Prozesssteuerung verbessern: rechtzeitiger geordneter OpenTofu-Abbruch vor dem äußeren Job-Zeitlimit, geschützte Diagnose und verschlüsselte Recovery-Artefakte. Nur das Timeout zu erhöhen behebt den fehlenden State nicht. - [x] Nach Recovery frischen Plan prüfen; erst dann Plattform-CI wieder aktivieren und neue konkrete Apply-Freigabe setzen. -- [ ] Verschlüsselten Remote-State, vollständige Ressourcen und anschließenden No-op-Plan unabhängig verifizieren. +- [x] Verschlüsselten Remote-State, vollständige Ressourcen und anschließenden No-op-Plan unabhängig verifizieren. - [ ] CF-Runtime (Space, Apps, Bindings, separate Laufzeitidentitäten) per IaC erstellen und PostgreSQL-/Secrets-Zugriff aus CF testen. @@ -96,7 +96,7 @@ Unabhängige Abnahme: - [x] Erforderliche Passwörter und Token im verschlüsselten State vorhanden, ohne Werte auszugeben. - [x] Geschützte Diagnose und State-Snapshot aus GitHub heruntergeladen; authentifizierte Entschlüsselung und JSON-Integrität geprüft. - [x] Beide Einmalvariablen `LZC_PLATFORM_RECOVERY_COMMIT` und `LZC_PLATFORM_APPLY_COMMIT` entfernt. Plattform-CI wieder für Plan-Läufe aktiv. -- [ ] Abschließenden Plan ohne Änderungen bestätigen. +- [x] Abschließenden Plan ohne Änderungen bestätigen: [Run 36675895279](https://github.com/stackitcloud/stackit-landing-zone/actions/runs/36675895279), alle neun Ressourcen `no-op`; Recovery/Apply übersprungen. Commit, Run und Plan-Hash unabhängig geprüft. [Begleitende Validierung](https://github.com/stackitcloud/stackit-landing-zone/actions/runs/36675895374) erfolgreich. Plattform-Jobs haben jetzt ein äußeres Zeitlimit von 100 Minuten. OpenTofu-Apply erhält maximal 70 Minuten und danach bis zu zehn Minuten zum geordneten Beenden; eine zusätzliche Gesamtlaufzeitgrenze des Skripts hält Puffer für Artefakte frei. 22 lokale Tests einschließlich Abbruchverhalten, verschlüsselter Diagnose und Recovery-Sperren sowie Workflow-Prüfung bestanden. From b49ac30dafe1a7b123b22002359addf9e766f68d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Weberru=C3=9F?= Date: Wed, 30 Sep 2026 08:13:03 +0200 Subject: [PATCH 014/175] refactor(configurator): run OpenTofu directly in deployment workflows --- .github/workflows/configurator-bootstrap.yml | 113 +++++++++-- .github/workflows/configurator-platform.yml | 177 ++++++++++------- .github/workflows/validate-configurator.yml | 12 +- landing-zone-configurator/README.md | 2 +- landing-zone-configurator/docs/cicd.md | 73 +++---- .../2026-09-30-recovery-inventory.json} | 0 landing-zone-configurator/docs/planning.md | 3 + .../docs/platform-readiness.md | 5 + landing-zone-configurator/infra/README.md | 151 +++++---------- landing-zone-configurator/infra/ci/README.md | 111 +++++------ .../infra/ci/artifact.mjs | 10 + .../{process.test.mjs => artifact.test.mjs} | 12 +- .../infra/ci/context.test.mjs | 2 +- .../infra/ci/prepare.mjs | 87 +++++++++ .../infra/ci/prepare.test.mjs | 16 ++ .../infra/ci/process.mjs | 46 ----- .../infra/ci/protect-state.mjs | 16 ++ .../infra/ci/recovery.mjs | 48 ----- .../infra/ci/recovery.test.mjs | 21 -- landing-zone-configurator/infra/ci/review.mjs | 35 ++++ .../infra/ci/review.test.mjs | 21 ++ landing-zone-configurator/infra/ci/run.mjs | 133 ------------- .../infra/ci/timeout.test.mjs | 8 + .../infra/platform/README.md | 2 +- .../infra/prepare-local.mjs | 41 ++++ .../infra/prepare-local.test.mjs | 19 ++ landing-zone-configurator/infra/tofu.mjs | 181 ------------------ 27 files changed, 607 insertions(+), 738 deletions(-) rename landing-zone-configurator/{infra/ci/recovery/lzc-dev-20260930.json => docs/incidents/2026-09-30-recovery-inventory.json} (100%) create mode 100644 landing-zone-configurator/infra/ci/artifact.mjs rename landing-zone-configurator/infra/ci/{process.test.mjs => artifact.test.mjs} (57%) create mode 100644 landing-zone-configurator/infra/ci/prepare.mjs create mode 100644 landing-zone-configurator/infra/ci/prepare.test.mjs delete mode 100644 landing-zone-configurator/infra/ci/process.mjs create mode 100644 landing-zone-configurator/infra/ci/protect-state.mjs delete mode 100644 landing-zone-configurator/infra/ci/recovery.mjs delete mode 100644 landing-zone-configurator/infra/ci/recovery.test.mjs create mode 100644 landing-zone-configurator/infra/ci/review.mjs create mode 100644 landing-zone-configurator/infra/ci/review.test.mjs delete mode 100644 landing-zone-configurator/infra/ci/run.mjs create mode 100644 landing-zone-configurator/infra/ci/timeout.test.mjs create mode 100644 landing-zone-configurator/infra/prepare-local.mjs create mode 100644 landing-zone-configurator/infra/prepare-local.test.mjs delete mode 100644 landing-zone-configurator/infra/tofu.mjs diff --git a/.github/workflows/configurator-bootstrap.yml b/.github/workflows/configurator-bootstrap.yml index 5278044..56dbd59 100644 --- a/.github/workflows/configurator-bootstrap.yml +++ b/.github/workflows/configurator-bootstrap.yml @@ -33,7 +33,7 @@ jobs: plan: if: github.run_attempt == 1 && ((github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main') || (github.event_name == 'push' && github.ref == 'refs/heads/feature/landing-zone-configurator')) runs-on: ubuntu-latest - timeout-minutes: 20 + timeout-minutes: 30 environment: lzc-dev-bootstrap-plan defaults: run: @@ -44,13 +44,14 @@ jobs: persist-credentials: false - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 with: - node-version-file: landing-zone-configurator/app/.node-version + node-version: '24.21.0' - uses: opentofu/setup-opentofu@a1320f892987e89d278cc92dc5adc984fb93aca4 # v2.0.2 with: tofu_version: '1.12.6' tofu_wrapper: false - - name: Create encrypted review plan - run: node infra/ci/run.mjs plan + - name: Prepare credentials and backend configuration + id: prepare + run: node infra/ci/prepare.mjs management env: LZC_PROJECT_ID: ${{ vars.LZC_PROJECT_ID }} LZC_REGION: ${{ vars.LZC_REGION }} @@ -62,20 +63,50 @@ jobs: LZC_SERVICE_ACCOUNT_KEY: ${{ secrets.LZC_SERVICE_ACCOUNT_KEY }} LZC_STATE_KEY_BOOTSTRAP: ${{ secrets.LZC_STATE_KEY_BOOTSTRAP }} LZC_STATE_KEY_BACKEND: ${{ secrets.LZC_STATE_KEY_BACKEND }} - - name: Upload encrypted plan and review metadata + - name: Read bootstrap backend outputs (private file) + if: env.LZC_ROOT != 'bootstrap' + timeout-minutes: 3 + run: | + umask 077 + export TF_DATA_DIR="$LZC_PRIVATE/bootstrap-data" + export TF_ENCRYPTION="$(cat "$LZC_PRIVATE/bootstrap.encryption.json")" + tofu -chdir=infra/bootstrap init -input=false -no-color -lockfile=readonly -backend-config="$LZC_PRIVATE/bootstrap.backend.hcl" + tofu -chdir=infra/bootstrap output -json > "$LZC_PRIVATE/bootstrap-outputs.json" + - name: Prepare workload credentials + if: env.LZC_ROOT != 'bootstrap' + run: node infra/ci/prepare.mjs workload + - name: OpenTofu init + timeout-minutes: 3 + run: tofu -chdir="infra/$LZC_ROOT" init -input=false -no-color -lockfile=readonly -backend-config="$LZC_PRIVATE/$LZC_ROOT.backend.hcl" + - name: OpenTofu validate + run: tofu -chdir="infra/$LZC_ROOT" validate -no-color + - name: OpenTofu plan + run: >- + timeout --foreground --signal=INT --kill-after=2m 15m + tofu -chdir="infra/$LZC_ROOT" plan + -input=false -no-color -lock-timeout=60s + -out="$LZC_PLAN" + - name: Bind encrypted plan to commit and workflow run + run: node infra/ci/review.mjs seal + - name: Upload encrypted plan uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: bootstrap-plan-${{ github.run_id }} - path: landing-zone-configurator/.local/ci-plan/* + path: | + landing-zone-configurator/.local/ci-plan/review.tfplan + landing-zone-configurator/.local/ci-plan/review.json include-hidden-files: true if-no-files-found: error retention-days: 1 + - name: Remove temporary credentials + if: always() + run: rm -rf -- "$RUNNER_TEMP/lzc-private" apply: needs: plan if: github.run_attempt == 1 && ((github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' && inputs.apply) || (github.event_name == 'push' && github.ref == 'refs/heads/feature/landing-zone-configurator' && github.sha == vars.LZC_BOOTSTRAP_APPLY_COMMIT)) runs-on: ubuntu-latest - timeout-minutes: 20 + timeout-minutes: 45 environment: lzc-dev-bootstrap-apply defaults: run: @@ -86,20 +117,15 @@ jobs: persist-credentials: false - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 with: - node-version-file: landing-zone-configurator/app/.node-version + node-version: '24.21.0' - uses: opentofu/setup-opentofu@a1320f892987e89d278cc92dc5adc984fb93aca4 # v2.0.2 with: tofu_version: '1.12.6' tofu_wrapper: false - - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 - with: - name: bootstrap-plan-${{ github.run_id }} - path: landing-zone-configurator/.local/ci-plan - - name: Apply exactly the reviewed plan - run: node infra/ci/run.mjs apply + - name: Prepare credentials and backend configuration + id: prepare + run: node infra/ci/prepare.mjs management env: - LZC_APPROVED_APPLY_COMMIT: ${{ vars.LZC_BOOTSTRAP_APPLY_COMMIT }} - LZC_APPROVED_APPLY_ROOT: ${{ vars.LZC_BOOTSTRAP_ROOT }} LZC_PROJECT_ID: ${{ vars.LZC_PROJECT_ID }} LZC_REGION: ${{ vars.LZC_REGION }} LZC_NAME_PREFIX: ${{ vars.LZC_NAME_PREFIX }} @@ -110,3 +136,58 @@ jobs: LZC_SERVICE_ACCOUNT_KEY: ${{ secrets.LZC_SERVICE_ACCOUNT_KEY }} LZC_STATE_KEY_BOOTSTRAP: ${{ secrets.LZC_STATE_KEY_BOOTSTRAP }} LZC_STATE_KEY_BACKEND: ${{ secrets.LZC_STATE_KEY_BACKEND }} + - name: Read bootstrap backend outputs (private file) + if: env.LZC_ROOT != 'bootstrap' + timeout-minutes: 3 + run: | + umask 077 + export TF_DATA_DIR="$LZC_PRIVATE/bootstrap-data" + export TF_ENCRYPTION="$(cat "$LZC_PRIVATE/bootstrap.encryption.json")" + tofu -chdir=infra/bootstrap init -input=false -no-color -lockfile=readonly -backend-config="$LZC_PRIVATE/bootstrap.backend.hcl" + tofu -chdir=infra/bootstrap output -json > "$LZC_PRIVATE/bootstrap-outputs.json" + - name: Prepare workload credentials + if: env.LZC_ROOT != 'bootstrap' + run: node infra/ci/prepare.mjs workload + - name: OpenTofu init + timeout-minutes: 3 + run: tofu -chdir="infra/$LZC_ROOT" init -input=false -no-color -lockfile=readonly -backend-config="$LZC_PRIVATE/$LZC_ROOT.backend.hcl" + - name: OpenTofu validate + run: tofu -chdir="infra/$LZC_ROOT" validate -no-color + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: bootstrap-plan-${{ github.run_id }} + path: landing-zone-configurator/.local/ci-plan + - name: Verify reviewed plan and apply authorization + run: node infra/ci/review.mjs verify + env: + LZC_APPROVED_APPLY_COMMIT: ${{ vars.LZC_BOOTSTRAP_APPLY_COMMIT }} + LZC_APPROVED_APPLY_ROOT: ${{ vars.LZC_BOOTSTRAP_ROOT || 'bootstrap' }} + - name: OpenTofu apply reviewed plan + id: apply + # One SIGINT allows OpenTofu to save state before the outer job deadline. + run: >- + timeout --foreground --signal=INT --kill-after=5m 20m + tofu -chdir="infra/$LZC_ROOT" apply + -input=false -no-color -lock-timeout=60s "$LZC_PLAN" + - name: Capture and encrypt recovery state + if: always() && steps.apply.outcome != '' && steps.apply.outcome != 'skipped' + run: | + umask 077 + if ! timeout --foreground --signal=INT --kill-after=10s 60s \ + tofu -chdir="infra/$LZC_ROOT" state pull > "$LZC_PRIVATE/state.snapshot"; then + echo "::warning::Remote state snapshot unavailable; checking for emergency state." + rm -f -- "$LZC_PRIVATE/state.snapshot" + fi + node infra/ci/protect-state.mjs + - name: Upload encrypted recovery state + if: always() && steps.apply.outcome != '' && steps.apply.outcome != 'skipped' + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: bootstrap-state-${{ github.run_id }} + path: landing-zone-configurator/.local/ci-recovery/*.enc.json + include-hidden-files: true + if-no-files-found: warn + retention-days: 7 + - name: Remove temporary credentials + if: always() + run: rm -rf -- "$RUNNER_TEMP/lzc-private" diff --git a/.github/workflows/configurator-platform.yml b/.github/workflows/configurator-platform.yml index 49c805a..612246d 100644 --- a/.github/workflows/configurator-platform.yml +++ b/.github/workflows/configurator-platform.yml @@ -32,57 +32,10 @@ env: LZC_ROOT: platform jobs: - recover: - if: vars.LZC_PLATFORM_CI_ENABLED == 'true' && github.run_attempt == 1 && github.event_name == 'push' && github.ref == 'refs/heads/feature/landing-zone-configurator' && github.sha == vars.LZC_PLATFORM_RECOVERY_COMMIT - runs-on: ubuntu-latest - timeout-minutes: 100 - environment: lzc-dev-platform-apply - defaults: - run: - working-directory: landing-zone-configurator - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 - with: - node-version-file: landing-zone-configurator/app/.node-version - - uses: opentofu/setup-opentofu@a1320f892987e89d278cc92dc5adc984fb93aca4 # v2.0.2 - with: - tofu_version: '1.12.6' - tofu_wrapper: false - - name: Import inventoried resources and revoke orphan token - run: node infra/ci/run.mjs recover - env: - LZC_APPROVED_APPLY_COMMIT: ${{ vars.LZC_PLATFORM_RECOVERY_COMMIT }} - LZC_APPROVED_APPLY_ROOT: platform - LZC_RECOVERY_COMMIT: ${{ vars.LZC_PLATFORM_RECOVERY_COMMIT }} - LZC_PROJECT_ID: ${{ vars.LZC_PROJECT_ID }} - LZC_REGION: ${{ vars.LZC_REGION }} - LZC_NAME_PREFIX: ${{ vars.LZC_NAME_PREFIX }} - LZC_MANAGEMENT_BUCKET: ${{ vars.LZC_MANAGEMENT_BUCKET }} - LZC_CREDENTIAL_EXPIRATION: ${{ vars.LZC_CREDENTIAL_EXPIRATION }} - LZC_MANAGEMENT_ACCESS_KEY: ${{ secrets.LZC_MANAGEMENT_ACCESS_KEY }} - LZC_MANAGEMENT_SECRET_KEY: ${{ secrets.LZC_MANAGEMENT_SECRET_KEY }} - LZC_SERVICE_ACCOUNT_KEY: ${{ secrets.LZC_SERVICE_ACCOUNT_KEY }} - LZC_STATE_KEY_BOOTSTRAP: ${{ secrets.LZC_STATE_KEY_BOOTSTRAP }} - LZC_STATE_KEY_BACKEND: ${{ secrets.LZC_STATE_KEY_BACKEND }} - LZC_STATE_KEY_PLATFORM: ${{ secrets.LZC_STATE_KEY_PLATFORM }} - - name: Upload protected recovery evidence - if: always() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - with: - name: platform-recovery-${{ github.run_id }} - path: landing-zone-configurator/.local/ci-recovery/*.enc.json - include-hidden-files: true - if-no-files-found: warn - retention-days: 7 - plan: - needs: recover - if: always() && (needs.recover.result == 'success' || needs.recover.result == 'skipped') && vars.LZC_PLATFORM_CI_ENABLED == 'true' && github.run_attempt == 1 && ((github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main') || (github.event_name == 'push' && github.ref == 'refs/heads/feature/landing-zone-configurator')) + if: vars.LZC_PLATFORM_CI_ENABLED == 'true' && github.run_attempt == 1 && ((github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main') || (github.event_name == 'push' && github.ref == 'refs/heads/feature/landing-zone-configurator')) runs-on: ubuntu-latest - timeout-minutes: 100 + timeout-minutes: 30 environment: lzc-dev-platform-plan defaults: run: @@ -93,15 +46,15 @@ jobs: persist-credentials: false - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 with: - node-version-file: landing-zone-configurator/app/.node-version + node-version: '24.21.0' - uses: opentofu/setup-opentofu@a1320f892987e89d278cc92dc5adc984fb93aca4 # v2.0.2 with: tofu_version: '1.12.6' tofu_wrapper: false - - name: Create encrypted review plan - run: node infra/ci/run.mjs plan + - name: Prepare credentials and backend configuration + id: prepare + run: node infra/ci/prepare.mjs management env: - LZC_RECOVERY_COMMIT: ${{ vars.LZC_PLATFORM_RECOVERY_COMMIT }} LZC_PROJECT_ID: ${{ vars.LZC_PROJECT_ID }} LZC_REGION: ${{ vars.LZC_REGION }} LZC_NAME_PREFIX: ${{ vars.LZC_NAME_PREFIX }} @@ -113,18 +66,57 @@ jobs: LZC_STATE_KEY_BOOTSTRAP: ${{ secrets.LZC_STATE_KEY_BOOTSTRAP }} LZC_STATE_KEY_BACKEND: ${{ secrets.LZC_STATE_KEY_BACKEND }} LZC_STATE_KEY_PLATFORM: ${{ secrets.LZC_STATE_KEY_PLATFORM }} - - name: Upload encrypted plan and review metadata + - name: Read bootstrap backend outputs (private file) + if: env.LZC_ROOT != 'bootstrap' + timeout-minutes: 3 + run: | + umask 077 + export TF_DATA_DIR="$LZC_PRIVATE/bootstrap-data" + export TF_ENCRYPTION="$(cat "$LZC_PRIVATE/bootstrap.encryption.json")" + tofu -chdir=infra/bootstrap init -input=false -no-color -lockfile=readonly -backend-config="$LZC_PRIVATE/bootstrap.backend.hcl" + tofu -chdir=infra/bootstrap output -json > "$LZC_PRIVATE/bootstrap-outputs.json" + - name: Read bucket versioning outputs (private file) + timeout-minutes: 3 + run: | + umask 077 + export TF_DATA_DIR="$LZC_PRIVATE/backend-data" + export TF_ENCRYPTION="$(cat "$LZC_PRIVATE/backend.encryption.json")" + tofu -chdir=infra/backend init -input=false -no-color -lockfile=readonly -backend-config="$LZC_PRIVATE/backend.backend.hcl" + tofu -chdir=infra/backend output -json > "$LZC_PRIVATE/backend-outputs.json" + - name: Prepare workload credentials + if: env.LZC_ROOT != 'bootstrap' + run: node infra/ci/prepare.mjs workload + - name: OpenTofu init + timeout-minutes: 3 + run: tofu -chdir="infra/$LZC_ROOT" init -input=false -no-color -lockfile=readonly -backend-config="$LZC_PRIVATE/$LZC_ROOT.backend.hcl" + - name: OpenTofu validate + run: tofu -chdir="infra/$LZC_ROOT" validate -no-color + - name: OpenTofu plan + run: >- + timeout --foreground --signal=INT --kill-after=2m 15m + tofu -chdir="infra/$LZC_ROOT" plan + -input=false -no-color -lock-timeout=60s + -var-file=../environments/lzc-dev.tfvars.json + -out="$LZC_PLAN" + - name: Bind encrypted plan to commit and workflow run + run: node infra/ci/review.mjs seal + - name: Upload encrypted plan uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: platform-plan-${{ github.run_id }} - path: landing-zone-configurator/.local/ci-plan/* + path: | + landing-zone-configurator/.local/ci-plan/review.tfplan + landing-zone-configurator/.local/ci-plan/review.json include-hidden-files: true if-no-files-found: error retention-days: 1 + - name: Remove temporary credentials + if: always() + run: rm -rf -- "$RUNNER_TEMP/lzc-private" apply: needs: plan - if: always() && needs.plan.result == 'success' && github.run_attempt == 1 && ((github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' && inputs.apply) || (github.event_name == 'push' && github.ref == 'refs/heads/feature/landing-zone-configurator' && github.sha == vars.LZC_PLATFORM_APPLY_COMMIT)) + if: github.run_attempt == 1 && ((github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' && inputs.apply) || (github.event_name == 'push' && github.ref == 'refs/heads/feature/landing-zone-configurator' && github.sha == vars.LZC_PLATFORM_APPLY_COMMIT)) runs-on: ubuntu-latest timeout-minutes: 100 environment: lzc-dev-platform-apply @@ -137,20 +129,15 @@ jobs: persist-credentials: false - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 with: - node-version-file: landing-zone-configurator/app/.node-version + node-version: '24.21.0' - uses: opentofu/setup-opentofu@a1320f892987e89d278cc92dc5adc984fb93aca4 # v2.0.2 with: tofu_version: '1.12.6' tofu_wrapper: false - - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 - with: - name: platform-plan-${{ github.run_id }} - path: landing-zone-configurator/.local/ci-plan - - name: Apply exactly the reviewed plan - run: node infra/ci/run.mjs apply + - name: Prepare credentials and backend configuration + id: prepare + run: node infra/ci/prepare.mjs management env: - LZC_APPROVED_APPLY_COMMIT: ${{ vars.LZC_PLATFORM_APPLY_COMMIT }} - LZC_APPROVED_APPLY_ROOT: platform LZC_PROJECT_ID: ${{ vars.LZC_PROJECT_ID }} LZC_REGION: ${{ vars.LZC_REGION }} LZC_NAME_PREFIX: ${{ vars.LZC_NAME_PREFIX }} @@ -162,12 +149,66 @@ jobs: LZC_STATE_KEY_BOOTSTRAP: ${{ secrets.LZC_STATE_KEY_BOOTSTRAP }} LZC_STATE_KEY_BACKEND: ${{ secrets.LZC_STATE_KEY_BACKEND }} LZC_STATE_KEY_PLATFORM: ${{ secrets.LZC_STATE_KEY_PLATFORM }} - - name: Upload protected recovery evidence - if: always() + - name: Read bootstrap backend outputs (private file) + if: env.LZC_ROOT != 'bootstrap' + timeout-minutes: 3 + run: | + umask 077 + export TF_DATA_DIR="$LZC_PRIVATE/bootstrap-data" + export TF_ENCRYPTION="$(cat "$LZC_PRIVATE/bootstrap.encryption.json")" + tofu -chdir=infra/bootstrap init -input=false -no-color -lockfile=readonly -backend-config="$LZC_PRIVATE/bootstrap.backend.hcl" + tofu -chdir=infra/bootstrap output -json > "$LZC_PRIVATE/bootstrap-outputs.json" + - name: Read bucket versioning outputs (private file) + timeout-minutes: 3 + run: | + umask 077 + export TF_DATA_DIR="$LZC_PRIVATE/backend-data" + export TF_ENCRYPTION="$(cat "$LZC_PRIVATE/backend.encryption.json")" + tofu -chdir=infra/backend init -input=false -no-color -lockfile=readonly -backend-config="$LZC_PRIVATE/backend.backend.hcl" + tofu -chdir=infra/backend output -json > "$LZC_PRIVATE/backend-outputs.json" + - name: Prepare workload credentials + if: env.LZC_ROOT != 'bootstrap' + run: node infra/ci/prepare.mjs workload + - name: OpenTofu init + timeout-minutes: 3 + run: tofu -chdir="infra/$LZC_ROOT" init -input=false -no-color -lockfile=readonly -backend-config="$LZC_PRIVATE/$LZC_ROOT.backend.hcl" + - name: OpenTofu validate + run: tofu -chdir="infra/$LZC_ROOT" validate -no-color + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: platform-plan-${{ github.run_id }} + path: landing-zone-configurator/.local/ci-plan + - name: Verify reviewed plan and apply authorization + run: node infra/ci/review.mjs verify + env: + LZC_APPROVED_APPLY_COMMIT: ${{ vars.LZC_PLATFORM_APPLY_COMMIT }} + LZC_APPROVED_APPLY_ROOT: platform + - name: OpenTofu apply reviewed plan + id: apply + # One SIGINT allows OpenTofu to save state before the outer job deadline. + run: >- + timeout --foreground --signal=INT --kill-after=10m 70m + tofu -chdir="infra/$LZC_ROOT" apply + -input=false -no-color -lock-timeout=60s "$LZC_PLAN" + - name: Capture and encrypt recovery state + if: always() && steps.apply.outcome != '' && steps.apply.outcome != 'skipped' + run: | + umask 077 + if ! timeout --foreground --signal=INT --kill-after=10s 60s \ + tofu -chdir="infra/$LZC_ROOT" state pull > "$LZC_PRIVATE/state.snapshot"; then + echo "::warning::Remote state snapshot unavailable; checking for emergency state." + rm -f -- "$LZC_PRIVATE/state.snapshot" + fi + node infra/ci/protect-state.mjs + - name: Upload encrypted recovery state + if: always() && steps.apply.outcome != '' && steps.apply.outcome != 'skipped' uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: - name: platform-apply-evidence-${{ github.run_id }} + name: platform-state-${{ github.run_id }} path: landing-zone-configurator/.local/ci-recovery/*.enc.json include-hidden-files: true if-no-files-found: warn retention-days: 7 + - name: Remove temporary credentials + if: always() + run: rm -rf -- "$RUNNER_TEMP/lzc-private" diff --git a/.github/workflows/validate-configurator.yml b/.github/workflows/validate-configurator.yml index bed8ce9..e9200d0 100644 --- a/.github/workflows/validate-configurator.yml +++ b/.github/workflows/validate-configurator.yml @@ -73,12 +73,16 @@ jobs: tofu_version: '1.12.6' tofu_wrapper: false - name: Check operator plan guard - run: node --test infra/plan-guard.test.mjs infra/state-key.test.mjs infra/ci/context.test.mjs infra/ci/process.test.mjs infra/ci/recovery.test.mjs + run: node --test infra/prepare-local.test.mjs infra/plan-guard.test.mjs infra/state-key.test.mjs infra/ci/context.test.mjs infra/ci/artifact.test.mjs infra/ci/prepare.test.mjs infra/ci/review.test.mjs infra/ci/timeout.test.mjs - name: Check formatting run: tofu -chdir=infra fmt -check -recursive + - name: Prepare disposable validation encryption + run: node infra/ci/prepare.mjs validation + env: + LZC_ROOT: ${{ matrix.root }} - name: Initialize without backend - run: node infra/tofu.mjs ${{ matrix.root }} init -backend=false -lockfile=readonly -input=false + run: tofu -chdir=infra/${{ matrix.root }} init -backend=false -lockfile=readonly -input=false - name: Validate - run: node infra/tofu.mjs ${{ matrix.root }} validate -no-color + run: tofu -chdir=infra/${{ matrix.root }} validate -no-color - name: Test plans with mock providers - run: node infra/tofu.mjs ${{ matrix.root }} test -no-color + run: tofu -chdir=infra/${{ matrix.root }} test -no-color diff --git a/landing-zone-configurator/README.md b/landing-zone-configurator/README.md index c976b9c..982d4f6 100644 --- a/landing-zone-configurator/README.md +++ b/landing-zone-configurator/README.md @@ -2,7 +2,7 @@ Zentral gehostete, mandantenfähige Anwendung zum Erstellen, Bearbeiten und Deployen von Konfigurationen des STACKIT Landing Zone Accelerators. -Status: lokale Entwicklungsbasis mit React-Startseite, Fastify-API, Tests und getrennter CI. Bootstrap-, Backend- und Plattform-IaC sind implementiert und lokal geprüft; Der separate Verwaltungs-Bucket samt Zugang und Versionierung ist per IaC provisioniert; die übrigen Plattformdienste stehen noch aus. +Status: React-/Fastify-Entwicklungsbasis mit Tests. Verwaltungs-Backend, Bootstrap und neun Plattform-Ressourcen sind per IaC provisioniert und geprüft. Die Pipelines führen OpenTofu direkt mit sichtbaren CLI-Logs aus. CF-Runtime und App-Deployment stehen noch aus. [IaC-Bedienung](infra/README.md), [CI-Betrieb](infra/ci/README.md). Start und Prüfungen: [Entwicklungsanleitung](app/README.md). Ergebnisse der lesenden Bestandsaufnahme: [Plattformprüfung](docs/platform-check.md). diff --git a/landing-zone-configurator/docs/cicd.md b/landing-zone-configurator/docs/cicd.md index 08f8249..44ad423 100644 --- a/landing-zone-configurator/docs/cicd.md +++ b/landing-zone-configurator/docs/cicd.md @@ -1,39 +1,37 @@ # CI/CD für den Landing Zone Configurator -Status: Architektur und Umsetzungsliste; die vorhandene Validierungs-CI ist implementiert. Die hier beschriebenen Deployment-Workflows sind noch nicht implementiert oder ausgeführt. Erste Umgebung: **lzc-dev / eu01**. +Status: Validierung, Bootstrap-/Backend- und Plattform-Pipelines sind implementiert und erfolgreich ausgeführt. OpenTofu wird direkt in den Workflows aufgerufen, mit normalen Live-Logs. App-Release, Runtime und Drift-Workflow sind noch geplant. Erste Umgebung: **lzc-dev / eu01**. Aktuelle Bedienung: [CI-Betrieb](../infra/ci/README.md). ## Getrennte Lebenszyklen -| Workflow (geplanter Dateiname) | Auslöser | Verantwortung | +| Workflow | Auslöser | Verantwortung | | --- | --- | --- | -| `validate-configurator.yml` (vorhanden) | PR, main, manuell | App-Prüfungen, IaC-Validierung und Mock-Tests ohne Cloud-Zugang | -| `configurator-bootstrap.yml` | Ausschließlich manuell, Umgebung und Phase auswählen | State-Infrastruktur erstellen, Versionierung konfigurieren, später Credentials rotieren | -| `configurator-platform.yml` | Infrastrukturänderungen auf main: Plan; manuell | Plattform- und Runtime-IaC planen, geprüften Plan anwenden | +| `validate-configurator.yml` (vorhanden) | PR, main, Feature-Branch, manuell | App-Prüfungen, IaC-Validierung und Mock-Tests ohne Cloud-Zugang | +| `configurator-bootstrap.yml` | Feature-Push: Plan; manuell auf main vorbereitet | State-Infrastruktur erstellen, Versionierung konfigurieren, später Credentials rotieren | +| `configurator-platform.yml` | Infrastrukturänderungen auf Feature-Branch: Plan; manuell auf main vorbereitet | Plattform-IaC planen, geprüften Plan anwenden; Runtime folgt | | `configurator-release.yml` | App-/Release-Dateien auf main; manuell für Redeploy/Rollback | Einmal bauen, prüfen, unveränderliches Release nach CF deployen | | `configurator-drift.yml` | Optional zeitgesteuert/manuell | Infrastruktur-Drift erkennen und melden; zunächst kein automatisches Apply | Ein normales App-Release startet weder Bootstrap noch Plattform-Apply. Relevante Pfade umfassen auch gemeinsame Deployment-Skripte, Lockfiles und die jeweilige Workflow-Datei. Änderungen ausschließlich am Accelerator lösen keinen Configurator-Release aus. Kunden-Landing-Zone-Deployments bleiben ein eigener, mandantenisolierter Runner-Ablauf und erhalten keine Betreiber-CI-Credentials. -## State vor Automatisierung lösen +## State und Credentials -Der aktuelle Operator-Wrapper nutzt lokale Bootstrap-/Backend-States und lokal erzeugte Schlüssel. Flüchtige GitHub-Runner dürfen dieses Verhalten nicht unverändert übernehmen: Ein neuer Schlüssel oder verlorener State nach einem Lauf wäre kein belastbarer Betrieb. +Ein unabhängiger Verwaltungs-Bucket wurde durch `seed` und `seed-protection` angelegt und versioniert. Bootstrap und Backend speichern dort ihre verschlüsselten States. Die Plattform verwendet den ebenfalls versionierten Workload-State-Bucket. Jeder Root hat einen eigenen dauerhaft verwahrten Schlüssel; ein fehlender Deployment-Schlüssel führt zum Abbruch. Geschützte Environment-Secrets liefern die vorhandenen Schlüssel und Credentials an CI, keine lokale Seed-Datei wird benötigt. -Ziel ist ein **unabhängiges, dauerhaftes Verwaltungs-Backend** für Bootstrap und Backend-Root, getrennt vom Bucket, den diese verwalten. Es muss vor deren erstem CI-Apply existieren, verschlüsselte States, Sperren und Wiederherstellung unterstützen. Bevorzugt wird ein vorhandenes Organisations-Backend. Falls keines existiert, wird dessen Einrichtung als eigene IaC-Seed-Stufe mit gesicherter initialer State-Übergabe geplant. Entscheidung: separater Verwaltungs-Bucket im bestehenden STACKIT-Projekt, erstellt durch die lokalen IaC-Roots `seed` und `seed-protection`. Die dauerhafte Schlüsselübergabe an CI bleibt offen; der Configurator darf sein einziges Recovery-Backend nicht selbst voraussetzen. +Native S3-Sperren haben den Integrationstest nicht bestanden. Die bestätigte Übergangslösung erlaubt ausschließlich gemeinsam serialisierte CI-Applies; direkte parallele Zugriffe durch andere Credential-Inhaber sind betrieblich ausgeschlossen. GitHub-Artefakte dienen nur für Review und Recovery, nicht als dauerhaftes State-Backend. -Platform und Runtime erhalten getrennte State-Keys im Configurator-State-Bucket. Jeder Root und jede Umgebung bekommt einen dauerhaft verwahrten Verschlüsselungsschlüssel. CI lädt Schlüssel aus geschützten Environment-Secrets oder dem unabhängigen Secret-System; ein fehlender Schlüssel führt zum Abbruch. GitHub-Artefakte und Caches dienen nicht als dauerhaftes State-Backend. Bestehende States werden migriert, nicht durch erneutes Anlegen ersetzt. - -Der seltene Bootstrap-Workflow benötigt trotzdem einen regelmäßigen Betriebsprozess für die befristeten S3-Credentials: Ablaufüberwachung, Rotation vor Ablauf, Aktualisierung aller Consumer und Test mit dem neuen Zugang vor Widerruf des alten. +Der seltene Bootstrap benötigt weiterhin Ablaufüberwachung und Rotation der befristeten S3-Credentials einschließlich Aktualisierung aller Consumer. Unabhängige Schlüsselverwahrung und vollständige Restore-Abnahme bleiben eigene Betriebsaufgaben. ## Infrastruktur-Pipeline -1. Vertrauenswürdigen Commit von main auschecken; gepinnte Tools und Provider-Lockfiles verwenden. +1. Vertrauenswürdigen Commit des freigegebenen Branches auschecken; gepinnte Tools und Provider-Lockfiles verwenden. 2. Root und Umgebung aus fester Allowlist wählen; Remote-State und bestehende Schlüssel laden. -3. Plan erstellen und verschlüsselt speichern. Manifest bindet Commit, Umgebung, Root, Eingabedigest, Lockfile-Digest, Plan-Hash und kurze Gültigkeit. Nur bereinigte Ressourcenaktionen als Review-Zusammenfassung veröffentlichen, keine vollständigen State-/Plan-JSONs. +3. Plan erstellen und verschlüsselt speichern. Manifest bindet Commit, Umgebung, Root, Eingabedigest, Lockfile-Digest, Plan-Hash und kurze Gültigkeit. Normale OpenTofu-Plan-Ausgabe live anzeigen; keine vollständigen State-/Plan-JSONs oder Credential-Outputs veröffentlichen. 4. Apply-Job nutzt ein geschütztes GitHub Environment und exakt das geprüfte Plan-Artefakt desselben vertrauenswürdigen Runs. Freigabe gilt für diesen Plan. Abgelaufene oder veraltete Plans werden neu erstellt und erneut geprüft. -5. Backend-Locking verhindert parallele State-Schreiber. Übergreifende GitHub-Concurrency pro Umgebung serialisiert mutierende Plattform-/Runtime-/Release-Jobs; `cancel-in-progress: false` für laufende Mutationen. Keine Fairness oder FIFO-Reihenfolge voraussetzen, ältere Releases vor Deployment abweisen. +5. Da natives S3-Locking nicht zuverlässig funktioniert, übernimmt übergreifende GitHub-Concurrency pro Umgebung serialisiert mutierende Plattform-/Runtime-/Release-Jobs; `cancel-in-progress: false` für laufende Mutationen. Keine Fairness oder FIFO-Reihenfolge voraussetzen, ältere Releases vor Deployment abweisen. 6. Outputs nur gezielt und geschützt an Consumer übergeben, Recovery und Audit protokollieren. -Bootstrap und Backend-Versionierung laufen nacheinander. Nach Bootstrap-Apply wird der Backend-Plan mit den jetzt vorhandenen Zugangsdaten erstellt. Plattform folgt erst nach nachgewiesener Versionierung und erfolgreichem Locking-/Restore-Test. Runtime benötigt anschließend die CF-Zugänge der Plattform. Kein Plan über noch unbekannte Provider-Credentials und kein routinemäßiges `-target`. +Bootstrap und Backend-Versionierung laufen nacheinander. Nach Bootstrap-Apply wird der Backend-Plan mit den jetzt vorhandenen Zugangsdaten erstellt. Plattform folgt nach nachgewiesener Versionierung unter dem bestätigten CI-only-Betriebsmodell. Runtime benötigt anschließend die CF-Zugänge der Plattform. Kein Plan über noch unbekannte Provider-Credentials und kein routinemäßiges `-target`. ## Anwendung häufig und unabhängig ausliefern @@ -59,20 +57,19 @@ Die Verfügbarkeit von Environment-Freigaberegeln hängt vom GitHub-Tarif und de ## Umsetzungsreihenfolge und Abnahme -- [x] Workflows nach Lebenszyklus und Credentials getrennt planen. -- [x] Validierungs-CI für App und die drei vorhandenen IaC-Roots angelegt. -- [ ] Unabhängiges Verwaltungs-Backend auswählen/einrichten; Schlüsselhaltung und Recovery testen. -- [x] CI-Schutz gegen lokale Cloud-States und automatische Erzeugung von Deployment-Schlüsseln; vier Tests in der Validierungs-CI. -- [ ] CI-Modus des Wrappers: explizite Umgebung, Remote-State, bestehende Schlüssel, Plan-Bindung an Commit/Eingaben. -- [ ] GitHub-Environments, Schutzregeln und getrennte Deployment-Identitäten als Code definieren. -- [ ] Bootstrap-/Backend-Pipeline implementieren; Wiederholung ohne Änderungen und Credential-Rotation nachweisen. -- [ ] S3-Versionierung, konkurrierendes Locking und Wiederherstellung praktisch testen. -- [ ] Plattform-/Runtime-Pipeline implementieren und konkrete Foundation, Quota, DB-Größe und Netzfreigaben festlegen. -- [ ] CF-Release-Manifeste, Paketierung, Migrationen, Readiness und Worker-Drain implementieren. -- [ ] App-Release-Pipeline: zwei Releases, unabhängiger Redeploy und Rollback nachweisen, ohne Infrastrukturänderung. +- [x] Lebenszyklen und Credentials trennen; Validierung aller fünf IaC-Roots und der App. +- [x] Verwaltungs- und Workload-Backend einrichten, Versionierung und State-Verschlüsselung prüfen. +- [x] GitHub-Environments und Freigaben einrichten; bestehende Deployment-Schlüssel geschützt übergeben. +- [x] Bootstrap-/Backend- und Plattform-Pipelines ausführen; Plattform-Recovery und anschließenden No-op nachweisen. +- [x] Native S3-Sperren testen und als nicht zuverlässig dokumentieren; CI-only-Betrieb explizit festlegen. +- [x] OpenTofu direkt in den Workflows aufrufen; Live-Logs, Standard-Timeout und geschützte State-Sicherung. +- [ ] Unabhängige Schlüsselverwahrung, vollständiger Restore-Prozess und automatisierte Credential-Rotation. +- [ ] CF-Runtime, Bindings und Konnektivitätstests aus CF. +- [ ] Release-Manifeste, Paketierung, Migrationen, Readiness und Worker-Drain. +- [ ] Zwei App-Releases, unabhängigen Redeploy und Rollback ohne Infrastrukturänderung nachweisen. - [ ] Optionalen Drift-Workflow und Alarmierung aktivieren. -Nächster Umsetzungsschritt ist das dauerhafte CI-State-/Secret-Fundament. Anschließend kann der erste Bootstrap-Apply direkt aus CI erfolgen; ein lokales Apply ist dafür keine Voraussetzung. +Nächster Ausbau ist die CF-Runtime mit Zugriffstests zu Datenbank und Secrets Manager. Die bisherige einmalige Recovery wurde aus dem normalen Deployment-Pfad entfernt; Vorfall und Wiederherstellung stehen im [Plattform-Betriebsstand](platform-readiness.md). ## Quellen @@ -80,25 +77,3 @@ Nächster Umsetzungsschritt ist das dauerhafte CI-State-/Secret-Fundament. Ansch - [GitHub Deployment-Steuerung und Concurrency](https://docs.github.com/en/actions/how-tos/deploy/configure-and-manage-deployments/control-deployments) - [GitHub Environment-Verfügbarkeit und Einrichtung](https://docs.github.com/en/actions/how-tos/deploy/configure-and-manage-deployments/manage-environments) - [Cloud Foundry Rolling Deployments](https://docs.cloudfoundry.org/devguide/deploy-apps/rolling-deploy.html) - -### Aktueller technischer Fortschritt - -Der Wrapper erkennt GitHub Actions/CI und erlaubt dort derzeit ausschließlich lokale Validierungsbefehle einschließlich `init -backend=false`. Cloud-Operationen brechen ab, solange die Remote-State-Anbindung fehlt. Die extrahierte Schlüsselverwaltung unterstützt bestehende, im Speicher übergebene Deployment-Schlüssel ohne Dateipersistierung; der Wrapper entfernt die Schlüsselvariablen anderer Roots aus der Kindprozess-Umgebung. Diese Vorarbeit aktiviert noch keine Deployment-Pipeline. - -### Verwaltungs-Backend bereitgestellt - -Der separate STACKIT-Verwaltungs-Bucket wurde per IaC erstellt und versioniert. Bootstrap nutzt nun dessen S3-Backend; der Backend-Root ist ebenfalls darauf umgestellt. Seed-States bleiben verschlüsselt lokal und erhalten verschlüsselte Recovery-Kopien im Bucket. Vor Aktivierung der Cloud-Pipelines fehlen noch unabhängige Schlüsselverwahrung, CI-Zugangsinjektion ohne lokale Seed-Outputs und vollständige Lock-/Recovery-Abnahme. Keine GitHub-Secrets oder Deployment-Workflows wurden aktiviert. - -### CI-Implementierung und fehlgeschlagene Locking-Abnahme - -Plan-/Apply-Workflow für Bootstrap und Backend sowie deklarative GitHub-Environment-Einrichtung sind implementiert, lokal geprüft und noch nicht veröffentlicht. Details: [CI-Betrieb](../infra/ci/README.md). Secret-Upload wartet auf ausdrückliche Freigabe nach automatischer Sicherheitsprüfung. - -Der echte Parallelitätstest zeigt: Eine vorhandene `.tflock` blockiert einen zweiten OpenTofu-Plan nicht. Ein zusätzlicher S3-Test bestätigt, dass ein zweites PUT mit `If-None-Match: *` auf dasselbe existierende Objekt akzeptiert wird. Remote-Applies bleiben daher technisch gesperrt. GitHub-Concurrency allein schützt nur kooperierende Workflows desselben Repositories; lokale oder andere Clients sind davon nicht erfasst. Als Alternativen stehen ein PostgreSQL-Backend mit nativem Locking oder ein ausdrücklich begrenzter CI-only-Betrieb zur Entscheidung. Der Bucket kann weiter für verschlüsselte Backups verwendet werden. - -Quellen: [STACKIT Issue 1534](https://github.com/stackitcloud/terraform-provider-stackit/issues/1534), [OpenTofu S3-Lock-Implementierung](https://github.com/opentofu/opentofu/blob/v1.12.6/internal/backend/remote-state/s3/client.go). - -### Bestätigte Übergangslösung: ein schreibender CI-Lauf - -Der Benutzer hat den CI-only-Betrieb trotz fehlender nativer S3-Sperren bestätigt. Alle mutierenden Configurator-Workflows verwenden repositoryweit `configurator-lzc-dev-mutation` mit `cancel-in-progress: false`; lokale Remote-Applies bleiben gesperrt. Diese Entscheidung ersetzt die vorherige vollständige Apply-Sperre und die offene PostgreSQL-Alternative. Sie gilt für kooperierende CI-Läufe, nicht für direkte Zugriffe anderer Credential-Inhaber. Die ausdrückliche Freigabe der benannten Secrets und GitHub-Ziel-Environments wurde erteilt. - -GitHub-Einrichtung abgeschlossen: `lzc-dev-bootstrap-plan`, `lzc-dev-bootstrap-apply` und `lzc-dev-recovery` existieren mit main-Beschränkung. Apply/Recovery erfordern Freigabe; Admin-Bypass ist deaktiviert. Die freigegebenen Secrets sind hinterlegt und ihre Namen über die API verifiziert. Noch kein Workflow veröffentlicht oder gestartet. diff --git a/landing-zone-configurator/infra/ci/recovery/lzc-dev-20260930.json b/landing-zone-configurator/docs/incidents/2026-09-30-recovery-inventory.json similarity index 100% rename from landing-zone-configurator/infra/ci/recovery/lzc-dev-20260930.json rename to landing-zone-configurator/docs/incidents/2026-09-30-recovery-inventory.json diff --git a/landing-zone-configurator/docs/planning.md b/landing-zone-configurator/docs/planning.md index 08099bc..06665a1 100644 --- a/landing-zone-configurator/docs/planning.md +++ b/landing-zone-configurator/docs/planning.md @@ -611,3 +611,6 @@ Plattform-Recovery abgeschlossen (2026-09-30): Run 36675347654 importierte den T No-op-Abnahme abgeschlossen: Plattform-Run 36675895279 zeigt für alle neun Ressourcen `no-op`; Recovery/Apply korrekt übersprungen. Validierung 36675895374 erfolgreich. Die Plattform ist damit konsistent unter IaC-Verwaltung. + + +CI-Entscheidung 2026-09-30: Auf Benutzerwunsch den eigenen JavaScript-Deployment-Runner durch direkte, sichtbare OpenTofu-Schritte in GitHub Actions ersetzen. Kleine Hilfsprogramme bereiten ausschließlich Credentials vor, prüfen Planintegrität und schützen State-Snapshots. Normale CLI-Logs werden wieder live ausgegeben; JSON-Credential-/State-Ausgaben bleiben privat. Abgeschlossene Recovery vom regulären Ablauf trennen. Auch lokale Validierung und Seed-Bedienung verwenden direkte `tofu`-Befehle. [Aktuelle Betriebsanleitung](../infra/ci/README.md). diff --git a/landing-zone-configurator/docs/platform-readiness.md b/landing-zone-configurator/docs/platform-readiness.md index 8f177c5..fc6fb01 100644 --- a/landing-zone-configurator/docs/platform-readiness.md +++ b/landing-zone-configurator/docs/platform-readiness.md @@ -101,3 +101,8 @@ Unabhängige Abnahme: Plattform-Jobs haben jetzt ein äußeres Zeitlimit von 100 Minuten. OpenTofu-Apply erhält maximal 70 Minuten und danach bis zu zehn Minuten zum geordneten Beenden; eine zusätzliche Gesamtlaufzeitgrenze des Skripts hält Puffer für Artefakte frei. 22 lokale Tests einschließlich Abbruchverhalten, verschlüsselter Diagnose und Recovery-Sperren sowie Workflow-Prüfung bestanden. Nächster Ausbau: CF-Space und App-/Task-Runtime per IaC, getrennte Laufzeitidentitäten/Bindings und echte TLS-/Authentifizierungstests aus CF. Die Infrastrukturabnahme belegt noch keinen erfolgreichen App-Zugriff auf Datenbank oder Secrets Manager. + + +## CI-Vereinfachung 2026-09-30 + +Die Workflows führen OpenTofu jetzt direkt aus. Der bisherige Deployment-Runner, Prozessmanager und automatische Einmal-Recovery-Pfad sind entfernt. Normale Plan-/Apply-Logs erscheinen live; Credential-Outputs und State-Snapshots bleiben gezielt geschützt. GNU `timeout` übernimmt SIGINT und die anschließende Wartezeit; keine eigene Signalsteuerung. Die geltenden Zeitlimits und Befehle stehen in der [CI-Betriebsanleitung](../infra/ci/README.md). Das historische Recovery-Inventar liegt unter `docs/incidents`, ohne aktive Ausführung. diff --git a/landing-zone-configurator/infra/README.md b/landing-zone-configurator/infra/README.md index 9901878..74df298 100644 --- a/landing-zone-configurator/infra/README.md +++ b/landing-zone-configurator/infra/README.md @@ -1,125 +1,80 @@ # Configurator-Infrastruktur -Alle Dienste und deren Aktivierung werden über IaC verwaltet. Keine manuelle Erstellung im Portal. Operator-Eingaben und sichere Übergabe von Credentials sind Konfiguration, keine alternative Provisionierung. +Alle Configurator-Dienste werden per OpenTofu verwaltet. Die Infrastruktur bleibt vom eigentlichen Landing Zone Accelerator getrennt. OpenTofu **1.12.6** und Provider-Versionen sind gepinnt; jeder ausführbare Root hat ein versioniertes Lockfile. ## Roots und Reihenfolge -0. **seed/** und **seed-protection/**: separater Verwaltungs-Bucket im bestehenden Projekt, Zugang und Versionierung; verschlüsselter lokaler Seed-State mit verschlüsselter Remote-Sicherung. -1. **bootstrap/**: aktiviert Object Storage über den ersten Bucket, erstellt State-Bucket, Credential-Gruppe und befristeten S3-Zugang. Verschlüsselter S3-State im Verwaltungs-Bucket. -2. **backend/**: aktiviert Bucket-Versionierung über die STACKIT-S3-API mit den nun existierenden Credentials; eigener verschlüsselter S3-State im Verwaltungs-Bucket. -3. **platform/**: S3-Backend im Bootstrap-Bucket, CF-Organisation und technischer Manager, PostgreSQL mit Datenbank/Migrationsnutzer, Secrets Manager mit Provisionierungsnutzer, Artefakt-Bucket und Model-Serving-Aktivierung/Token. -4. **runtime/** (noch zu implementieren): CF-Spaces, Rollen, gemeinsame Service-Instanzen, eingeschränkter DB-App-Zugang/RLS, Secret-Policies und Artefaktzugriffe. Eigener State; nur benötigte Outputs aus der vorigen Schicht im Speicher übergeben. +| Root | Aufgabe | State | +| --- | --- | --- | +| `seed` | Verwaltungs-Bucket und dessen S3-Zugang | lokal verschlüsselt, mit verschlüsselter Remote-Sicherung | +| `seed-protection` | Versionierung des Verwaltungs-Buckets | lokal verschlüsselt, mit verschlüsselter Remote-Sicherung | +| `bootstrap` | Workload-State-Bucket und dessen S3-Zugang | Verwaltungs-Bucket | +| `backend` | Versionierung des Workload-State-Buckets | Verwaltungs-Bucket | +| `platform` | CF-Organisation/Manager, PostgreSQL/Datenbank/Migrationsnutzer, Secrets Manager/Provisionierungsnutzer, Artefakt-Bucket und Model-Serving-Token | Workload-Bucket | +| `runtime` (geplant) | CF-Spaces, Apps, Bindings, eingeschränkte Laufzeitidentitäten | separater State | -Diese Trennung verhindert, dass der CF-Provider einen noch nicht erzeugten Runtime-Zugang benötigt. Kein allgemeiner `-target`-Workflow und kein manueller Portal-Bootstrap. Weitere Dienste, etwa Observability, folgen ebenfalls als IaC-Ressourcen. +Bootstrap, Backend und Plattform sind in `lzc-dev/eu01` provisioniert. Die Plattform enthält neun verwaltete Ressourcen; der [Betriebsstand](../docs/platform-readiness.md) dokumentiert Abnahme und offene Runtime-Arbeiten. -## Nachgewiesene Aktivierung im Provider +Der STACKIT-Provider aktiviert Object Storage und Model Serving bei der ersten Ressourcenerstellung. Für Bucket-Versionierung spricht der AWS-Provider ausschließlich die STACKIT-S3-API an; es werden keine AWS-Ressourcen angelegt. -Der gepinnte STACKIT-Provider `0.114.0` ruft beim Erstellen von `stackit_objectstorage_bucket` zuerst `EnableProject` auf. Für `stackit_modelserving_token` aktiviert er zunächst den regionalen Model-Serving-Service und erstellt anschließend den Token. Beide Aktivierungen sind damit Bestandteil des jeweiligen OpenTofu-Apply. +## Deployment: direkte OpenTofu-Schritte in CI -Quellen: [Bucket-Create](https://github.com/stackitcloud/terraform-provider-stackit/blob/v0.114.0/stackit/internal/services/objectstorage/bucket/resource.go), [Model-Serving-Create](https://github.com/stackitcloud/terraform-provider-stackit/blob/v0.114.0/stackit/internal/services/modelserving/token/resource.go). +[Bootstrap-Workflow](../../.github/workflows/configurator-bootstrap.yml) und [Plattform-Workflow](../../.github/workflows/configurator-platform.yml) führen `tofu init`, `validate`, `plan` und `apply` direkt aus. Normale CLI-Ausgaben sind live sichtbar. Kleine Hilfsprogramme bereiten Credentials vor, prüfen das gespeicherte Plan-Artefakt und verschlüsseln Recovery-Snapshots; sie führen keine Deployments aus. -Für Bucket-Versionierung fehlt in diesem Provider eine eigene Ressource. `aws_s3_bucket_versioning` aus `hashicorp/aws` spricht ausschließlich den STACKIT-S3-Endpunkt an; es werden keine Ressourcen bei AWS angelegt. Tatsächliche API-Kompatibilität und Locking bleiben bis zum ersten Apply/Integrationstest unbestätigt. +[CI-Betriebsanleitung](ci/README.md): Environments, Planfreigabe, Timeouts, State-Sicherung und Feature-Branch-Tests. Remote-Applies ausschließlich über die gemeinsam serialisierte CI; keine parallelen lokalen Applies. S3-Locking hat den Integrationstest nicht bestanden und wird nicht als funktionierender Schutz behauptet. -## Werkzeug und lokale Dateien +## Lokal validieren, ohne Cloud-Zugriff -OpenTofu **1.12.6**, Node.js **24.21.0**. Provider sind exakt gepinnt, alle drei `.terraform.lock.hcl` werden versioniert. Der Wrapper nutzt standardmäßig `tofu`; alternativ `LZC_TOFU_BIN=/absoluter/pfad/tofu` setzen. - -Aus dem Verzeichnis `landing-zone-configurator/`: +Benötigt werden OpenTofu 1.12.6 und Node.js 24.21.0 für die reine Umgebungs-Vorbereitung. Aus `landing-zone-configurator/`, beispielhaft für `platform`: ```sh -node infra/tofu.mjs bootstrap init -backend=false -node infra/tofu.mjs bootstrap validate -node infra/tofu.mjs bootstrap test -node infra/tofu.mjs backend init -backend=false -node infra/tofu.mjs backend validate -node infra/tofu.mjs backend test -node infra/tofu.mjs platform init -backend=false -node infra/tofu.mjs platform validate -node infra/tofu.mjs platform test +node infra/prepare-local.mjs platform --validation +( + . .local/validation/platform/environment.sh + tofu -chdir=infra/platform init -backend=false -lockfile=readonly -input=false + tofu -chdir=infra/platform validate -no-color + tofu -chdir=infra/platform test -no-color +) +tofu -chdir=infra fmt -check -recursive +node --test infra/prepare-local.test.mjs infra/plan-guard.test.mjs infra/state-key.test.mjs infra/ci/*.test.mjs ``` -Tests nutzen gemockte Provider; sie legen keine Cloud-Ressourcen an. Bei abweichender globaler Node-Version kann der aus der [App-Anleitung](../app/README.md) bekannte `npm exec`-Präfix verwendet werden. - -Der Wrapper erzeugt root-spezifische Verschlüsselungs-Passphrases unter `../.local//state.passphrase`, Dateirechte 0600. State und gespeicherte Plans sind durch OpenTofu AES-GCM verschlüsselt; Passphrases werden nur über `TF_ENCRYPTION` übergeben. Nicht in tfvars, Git oder CF-Variablen übernehmen. **Vor dem ersten Apply die Schlüssel unabhängig und sicher sichern.** `.gitignore` ersetzt keine Sicherung und verhindert auch keine Betriebssystem-/Cloud-Drive-Synchronisation. - -Bootstrap und Backend verwenden nun den separaten Verwaltungs-Bucket. Nur die Seed-Stufen verbleiben lokal: Sie erstellen den Bucket und dessen Versionierung selbst. `check-management-backend.mjs` sichert ihre verschlüsselten States unter `recovery/` im Bucket und prüft den Download per Hash. Die Schlüssel bleiben separat lokal; ihre dauerhafte unabhängige Hinterlegung im betrieblichen Secret-System/GitHub Environment ist noch offen. Die Kopie im selben Bucket schützt nicht vor Verlust des gesamten Buckets. Seed-Änderungen erfolgen bis zur Betriebsübergabe durch einen einzigen Operator; danach die Sicherung erneuern. - -Der bisherige Bootstrap hatte nur einen Plan, keinen angewendeten State. Daher wurde kein existierender Ressourcen-State migriert. Bei künftigen Backend-Wechseln bestehende States ausdrücklich migrieren und niemals durch `init -reconfigure` verwerfen. - -## Operator-Eingaben und Plan +Die Vorbereitung erzeugt nur eine private Umgebungsdatei und einen lokalen Testschlüssel. Sie führt keinen OpenTofu-Befehl aus. Die Testumgebung liegt separat unter `.local/validation/` und verwendet keine produktiven Backend-Schlüssel. Für andere Roots `platform` durch den jeweiligen Namen ersetzen. Die Tests verwenden Mock-Provider. CI macht dieselben OpenTofu-Aufrufe direkt mit temporären Testschlüsseln. -Die erste Entwicklungsumgebung **lzc-dev in eu01** ist bestätigt und in der vorhandenen, ignorierten Root-Datei eingetragen. Der erste Backend-Zugang ist mit ungefähr 90 Tagen Laufzeit geplant. Beispiel für die relevanten Eingaben: +## Seltene lokale Seed-Operationen -```dotenv -# PROJECT_ID und ORGANISATION_ID sind bereits vorhanden. -REGION=eu01 -NAME_PREFIX=lzc-dev -STATE_CREDENTIAL_EXPIRATION=2026-12-28T00:00:00Z -``` - -Dies ist die bestätigte Entwicklungsumgebung, keine Produktionskonfiguration. Ablaufdatum bewusst wählen und vor Ablauf rotieren. `ORGANISATION_ID` wird nicht zur Erstellung einer neuen Organisation verwendet; dieses Vorhaben arbeitet im bestehenden Projekt. +Der Verwaltungs-Seed existiert bereits. Folgende Befehle sind der dokumentierte Bedienweg für bewusst geplante Seed-Änderungen, kein notwendiger Schritt vor einem App-Deployment. Nur ein Operator arbeitet am Seed. Vor Änderungen unabhängige Schlüsselsicherung prüfen. -Der Wrapper führt die Datei nicht als Shellcode aus und verwendet ausschließlich die zugehörige Service-Account-Datei. Für den Plattform-Root zusätzlich eine geprüfte lokale tfvars-Datei unter `.local/` bereitstellen; siehe [Parameterbeispiel](environments/development.tfvars.example). Keine Platzhalter in einen echten Apply übernehmen. +Eingaben bleiben in den ignorierten Dateien `landing-zone-configurator.env` und `landing-zone-configurator-credentials.json` im Repository-Root. Die env-Datei verwendet `NAME=WERT`: `PROJECT_ID`, `REGION`, `NAME_PREFIX`, `STATE_CREDENTIAL_EXPIRATION`. Credentials und State-Schlüssel niemals in Git übernehmen. ```sh -node infra/tofu.mjs bootstrap init -node infra/tofu.mjs bootstrap plan -# Nach Prüfung des angezeigten Plans und unabhängiger Schlüsselsicherung: -node infra/tofu.mjs bootstrap apply - -node infra/tofu.mjs backend init -node infra/tofu.mjs backend plan -node infra/tofu.mjs backend apply - -node infra/tofu.mjs platform init -node infra/tofu.mjs platform plan -var-file=../../.local/platform-inputs.tfvars -# Nach Prüfung des konkreten Plans: -node infra/tofu.mjs platform apply +node infra/prepare-local.mjs seed +( + . .local/seed/environment.sh + tofu -chdir=infra/seed init -lockfile=readonly -backend-config=../../.local/seed/backend.hcl + tofu -chdir=infra/seed plan -out=../../.local/seed/review.tfplan + # Erst nach Prüfung des konkreten Plans: + tofu -chdir=infra/seed apply ../../.local/seed/review.tfplan + umask 077 + tofu -chdir=infra/seed output -json > .local/seed/outputs.json +) +node infra/prepare-local.mjs seed-protection +( + . .local/seed-protection/environment.sh + tofu -chdir=infra/seed-protection init -lockfile=readonly -backend-config=../../.local/seed-protection/backend.hcl + tofu -chdir=infra/seed-protection plan -out=../../.local/seed-protection/review.tfplan + # Erst nach Prüfung des konkreten Plans: + tofu -chdir=infra/seed-protection apply ../../.local/seed-protection/review.tfplan +) +rm -f .local/seed/outputs.json .local/seed/environment.sh .local/seed-protection/environment.sh ``` -`plan` speichert verschlüsselt unter `.local//review.tfplan`; `apply` führt ausschließlich diese Datei aus. Ein gespeicherter Plan wird von OpenTofu ohne weitere interaktive Bestätigung angewendet: Der explizite `apply`-Aufruf ist die Freigabe. Ein Manifest bindet den Plan an seinen Hash und die lokale `.env`-Konfiguration; fehlgeschlagene, verbrauchte oder mehr als 24 Stunden alte Plans werden abgewiesen. Nach Konfigurationsänderungen immer neu planen. `prevent_destroy` blockiert unbeabsichtigte Ersetzung/Löschung wichtiger Ressourcen, ersetzt aber kein Backup. - -Der Wrapper liest für die Plattform die S3-Zugangsdaten aus den verschlüsselten Bootstrap-Outputs in den Arbeitsspeicher und setzt sie nur im Kindprozess. Backend-Konfiguration enthält keine Schlüssel. Die Plattforminitialisierung prüft zuvor die vom Backend-Root angewendete Versionierung. Der Wrapper verwaltet derzeit genau eine Umgebung pro Checkout; weitere Umgebungen benötigen getrennte State-/Schlüsselkontexte und dürfen nicht durch bloßes Umbenennen dieses Checkouts erzeugt werden. S3-Locking mit `use_lockfile` muss gegen STACKIT praktisch getestet werden, bevor mehrere Operatoren oder automatisierte Applies zugelassen werden. - -## Implementierungsstand - -- [x] Bootstrap-/Backend-/Plattform-Ressourcen als Code, Provider-Lockfiles und Verschlüsselung. -- [x] Alle drei Roots validiert; sieben Mock-Plan-Tests sowie drei Tests des Plan-Prüfmechanismus. -- [x] Eigene CI-Jobs ohne Cloud-Credentials vorbereitet. -- [x] Erster echter Bootstrap-Plan für lzc-dev/eu01: 3 Create, 0 Update, 0 Delete. -- [ ] Erstes Apply und S3-Versionierungs-/Locking-/Restore-Test. -- [ ] Runtime-Root, App-Bindings, eingeschränkte DB-Rollen und Secret-/Bucket-Policies. -- [ ] Automatisierte Credential-Rotation samt Bindings und Restore-Runbook. - -Angewendet sind jetzt ausschließlich Verwaltungs-Bucket, Credentials-Gruppe, S3-Zugang und Versionierung. Bootstrap ist am Remote-Backend initialisiert und neu geplant; Plattformdienste sind noch nicht erstellt. - -## CI/CD-Zielbild - -Getrennte Bootstrap-, Plattform- und Release-Pipelines sind in [CI/CD](../docs/cicd.md) geplant. App-Objekte, Routen und App-Bindings gehören dabei dem deklarativen Release-Prozess. Vor Cloud-Applies in CI werden lokale Bootstrap-/Backend-States auf ein unabhängiges dauerhaftes Backend umgestellt und bestehende Verschlüsselungsschlüssel sicher eingebunden. Die Deployment-Pipelines sind noch nicht implementiert. - -## Verwaltungs-Seed ausführen und wiederherstellen - -```sh -node infra/tofu.mjs seed init -node infra/tofu.mjs seed plan -node infra/tofu.mjs seed apply -node infra/tofu.mjs seed-protection init -node infra/tofu.mjs seed-protection plan -node infra/tofu.mjs seed-protection apply -node infra/check-management-backend.mjs -# Nur bei einer erstmaligen Initialisierung ohne vorhandenen lokalen Ressourcen-State: -node infra/tofu.mjs bootstrap init -reconfigure -node infra/tofu.mjs bootstrap plan -``` - -Der Integrationscheck benötigt zusätzlich die AWS CLI, spricht aber ausschließlich den STACKIT-Endpunkt an. Er legt kurzlebige Testobjekt-Versionen an und entfernt nur diese; verschlüsselte Recovery-States bleiben erhalten. Wiederherstellung: passende Seed-State-Version aus `recovery/` herunterladen, lokalen State-Pfad und zugehörigen unabhängig gesicherten Schlüssel wiederherstellen, Seed initialisieren und zunächst einen Plan prüfen. Bei abgelaufenen S3-Zugängen ist eine Wiederherstellung des Zugangs über den Projekt-Service-Account erforderlich. Dieser Recovery-Pfad und Schlüsselrotation müssen vor CI-Freigabe vollständig geübt werden. - -## CI-Übergabe: aktueller Stand +Die Shell-Dateien enthalten sensible Werte, sind mit Modus 0600 angelegt und von Git ausgeschlossen. Die Umgebungen gelten nur in den gezeigten Subshells. Bereits vorhandene Schlüssel werden wiederverwendet; bei verlorenem Schlüssel eines bestehenden Workspace wird kein neuer erzeugt. Die Vorbereitung erlaubt Cloud-Konfiguration lokal ausschließlich für die beiden Seed-Roots. -[Bootstrap-CI](ci/README.md) ist lokal implementiert und mit einem echten Plan geprüft. Der konkurrierende S3-Locking-Test ist **fehlgeschlagen**; `use_lockfile=true` bietet am getesteten Endpunkt keinen nachgewiesenen Schutz. `ci/backend-safety.json` sperrt deshalb Bootstrap-/Backend-/Plattform-Applies im Wrapper und im CI-Einstieg. Die oben gezeigten Apply-Befehle sind bis zur Lösung nicht ausführbar. Seed und Seed-Protection bleiben lokale Einzeloperator-Stufen. +## Recovery und verbleibende Arbeiten -Reproduktion des isolierten Tests: `node infra/check-state-lock.mjs`. Er nutzt ausschließlich einen eindeutigen `checks/locking/`-Präfix, erstellt keine Cloud-Infrastruktur und entfernt seine Testobjekt-Versionen nach dem Lauf. Verschlüsselung und Versionierung ersetzen kein funktionierendes Locking. +Verschlüsselte Seed-Sicherungen liegen im Verwaltungs-Bucket unter `recovery/seed/terraform.tfstate` und `recovery/seed-protection/terraform.tfstate`; nach Seed-Änderungen erneuern. State-Schlüssel separat und unabhängig vom Bucket verwahren. Die Kopie im selben Bucket schützt nicht vor Verlust des gesamten Buckets. -### Aktualisierte Betriebsfreigabe +Bei einem fehlgeschlagenen Apply zuerst CI-Logs, verschlüsselte Recovery-Artefakte und tatsächlichen Ressourcenbestand prüfen. Keinen alten Plan blind wiederholen, keinen existierenden State überschreiben. Imports und Credential-Rotation müssen zum konkreten Vorfall passen und unter derselben Single-Writer-Regel erfolgen. Der frühere automatische Einmal-Recovery-Pfad wurde nach Abschluss entfernt. -Der Benutzer hat anschließend ausschließlich serialisierte CI-Applies bestätigt. Die vollständige Remote-Apply-Sperre ist dafür im CI-Einstieg aufgehoben; im lokalen Wrapper bleibt sie bestehen. Gemeinsame Concurrency-Gruppe aller mutierenden Workflows: `configurator-lzc-dev-mutation`, laufende Jobs nicht abbrechen. Native S3-Locking-Unterstützung wird weiterhin nicht behauptet. Details und Grenzen: [CI-Betrieb](ci/README.md). +Offen sind CF-Runtime/Bindings und Verbindungstests aus CF, eingeschränkte App-Rollen/RLS, automatisierte Credential-Rotation sowie ein vollständiger getesteter Betriebs-/Restore-Prozess. [Planung](../docs/planning.md). diff --git a/landing-zone-configurator/infra/ci/README.md b/landing-zone-configurator/infra/ci/README.md index c28cfc0..49f64ee 100644 --- a/landing-zone-configurator/infra/ci/README.md +++ b/landing-zone-configurator/infra/ci/README.md @@ -1,92 +1,81 @@ -# Bootstrap-CI +# OpenTofu in GitHub Actions -Implementiert und auf dem Feature-Branch veröffentlicht; erster Bootstrap-Plan und Apply erfolgreich. Der Workflow `.github/workflows/configurator-bootstrap.yml` wird manuell auf main gestartet: zuerst Root `bootstrap`, anschließend `backend`. Plan und Apply sind getrennte Jobs; Apply verlangt die Environment-Freigabe und verwendet das verschlüsselte Plan-Artefakt desselben Runs. Re-runs werden abgewiesen: immer einen neuen Workflow-Lauf mit neuem Plan starten. +Die ausführbaren Deployments stehen direkt in den Workflows: -## Betriebsentscheidung: ausschließlich serialisierte CI-Applies +- [Configurator Bootstrap](../../../.github/workflows/configurator-bootstrap.yml): `bootstrap` oder `backend`. +- [Configurator Platform](../../../.github/workflows/configurator-platform.yml): `platform`. +- [Validierung](../../../.github/workflows/validate-configurator.yml): Formatierung, Validierung und Mock-Tests aller fünf Roots ohne Cloud-Credentials. -Native S3-Sperren funktionieren im Integrationstest nicht. Der Benutzer hat als Übergang den Betrieb mit genau einem schreibenden CI-Lauf bestätigt. S3 bleibt State-Backend. Der gesamte Workflow einschließlich Plan, Freigabewartezeit und Apply nutzt `concurrency.group: configurator-lzc-dev-mutation` und `cancel-in-progress: false`. +Jeder Deployment-Job zeigt die einzelnen Schritte: Credential-Vorbereitung, gegebenenfalls Lesen der vorherigen Backend-Outputs, `tofu init`, `tofu validate`, `tofu plan` beziehungsweise `tofu apply`. Es gibt keinen eigenen JavaScript-Deployment-Runner. Einmalige Recovery-Operationen gehören nicht in diesen normalen Ablauf. -Alle zukünftigen mutierenden Configurator-Workflows im selben Repository müssen dieselbe Gruppe verwenden. GitHub serialisiert aktive Läufe dieser Gruppe; wartende Läufe bilden keine garantierte FIFO-Warteschlange und können durch spätere Anfragen ersetzt werden. Lokale Remote-Applies bleiben im Operator-Wrapper gesperrt. Der CI-Einstieg erlaubt Apply nur im freigegebenen Workflow auf main. Umgebungsvariablen sind dabei keine unabhängige Authentifizierung: Direkte OpenTofu-Aufrufe durch Inhaber der Credentials oder andere Repositories werden nicht von GitHub-Concurrency erfasst und sind im vereinbarten Betrieb ausgeschlossen. +## Plan und Apply -`backend-safety.json` dokumentiert diesen Modus, ohne den fehlgeschlagenen S3-Locking-Test als erfolgreich zu markieren. Der lokale CI-Plan-Smoke-Test gegen das echte S3-Backend war erfolgreich, ohne Apply. +Plan und Apply sind getrennte Jobs. Der Plan wird mit `-out` gespeichert und durch die native OpenTofu-Konfiguration verschlüsselt. Der Apply lädt genau das Plan-Artefakt desselben Runs herunter. Nach Prüfung des Plans erfolgt die Freigabe im geschützten GitHub Environment. Eine kleine Integritätsprüfung bindet das Artefakt an Hash, Commit, Repository, Run, Root, Eingaben und IaC-Dateien; Pläne sind höchstens 24 Stunden gültig. OpenTofu prüft zusätzlich, ob der State seit der Planung verändert wurde. Re-runs sind gesperrt: einen neuen Run mit frischem Plan starten. -Die ausdrückliche Freigabe für die bezeichneten GitHub-Environment-Secrets liegt vor. Alle drei Environments wurden angelegt und per API geprüft: ausschließlich main, kein Admin-Bypass, erforderliche Freigabe für Apply und Recovery. Fünf Secrets je Plan-/Apply-Environment und vier Recovery-Secrets sind hinterlegt; Werte wurden nicht ausgegeben. Der Workflow ist bislang nur lokal vorbereitet und muss vor dem ersten GitHub-Lauf auf main veröffentlicht werden. - -## Reproduzierbare GitHub-Einrichtung - -`github-environments.json` beschreibt Repository, erlaubten Branch, Reviewer und drei getrennte Environments. `configure-github.mjs` setzt die Konfiguration über GitHub API/CLI, prüft Schutzregeln und lädt Secret-Werte über stdin hoch; keine Werte in Argumenten, Logs oder Dateien im Repository. Die ausdrückliche Freigabe des Secret-Transfers wurde erteilt: +Die zentralen Befehle stehen sichtbar im Workflow, beispielsweise: ```sh -node infra/ci/configure-github.mjs +tofu -chdir="infra/$LZC_ROOT" init -input=false -lockfile=readonly -backend-config="$LZC_PRIVATE/$LZC_ROOT.backend.hcl" +tofu -chdir="infra/$LZC_ROOT" validate -no-color +tofu -chdir=infra/platform plan -input=false -no-color -var-file=../environments/lzc-dev.tfvars.json -out="$LZC_PLAN" +timeout --foreground --signal=INT --kill-after=10m 70m \ + tofu -chdir=infra/platform apply -input=false -no-color "$LZC_PLAN" ``` -| Environment | Verwendung | Secrets | -| --- | --- | --- | -| lzc-dev-bootstrap-plan | Plan auf main | Projekt-Service-Account, Management-S3-Zugang, Bootstrap-/Backend-State-Schlüssel | -| lzc-dev-bootstrap-apply | Apply auf main, Freigabe durch lweberru | Gleiche Deployment-Secrets | -| lzc-dev-recovery | Separat geschützter Recovery-Kontext, Freigabe durch lweberru | Management-S3-Zugang und Seed-/Seed-Protection-State-Schlüssel | - -Das vorhandene Projekt-Service-Account-Recht ist breiter als eine reine Plan-Leserolle. Der Plan-Job ist deshalb auf vertrauenswürdigen main-Code beschränkt. Eine separate minimal berechtigte Plan-Identität ist ein weiterer Härtungsschritt. Die Apply-Freigabe verhindert keinen missbräuchlichen Code auf main mit den Plan-Credentials; Branch-/PR-Reviews bleiben erforderlich. - -GitHub-Secrets sind nicht per API im Klartext exportierbar. Ihre Hinterlegung ersetzt kein unabhängig abrufbares Recovery-Backup der Schlüssel. Ein Recovery-Workflow und eine externe Schlüsselverwahrung müssen vor Betriebsfreigabe ergänzt und getestet werden. Seed-Schlüssel gehören nicht in normale Deployment-Jobs. - -## Planbindung und Credentials - -`run.mjs` liest ausschließlich explizite CI-Eingaben und bestehende Schlüssel. Es benötigt keine lokalen Seed-States oder Operator-env-Dateien. Credentials werden nur im Jobprozess beziehungsweise in einer restriktiven temporären Service-Account-Datei genutzt, die im finally-Block entfernt wird. Der CI-Job lädt ausschließlich Plan, Manifest und bereinigte Review-Zusammenfassung hoch. - -Der Apply-Prüfmechanismus bindet Plan-Hash, Repository, Commit, Workflow-Lauf, Root, Umgebung, Eingaben sowie Terraform-Dateien und Provider-Lockfiles. Die Gültigkeit beträgt maximal 24 Stunden. OpenTofu prüft zusätzlich den State-Stand. Fehlerhafte Läufe werden nicht automatisch wiederholt. - -## Nachweise - -- Lokaler CI-Planpfad gegen Management-S3: erfolgreich, drei geplante Bootstrap-Ressourcen. -- Beide Workflow-Dateien mit actionlint 1.7.7 validiert. -- Zwölf Node-Tests für Planbindung, Schlüsselhaltung und Apply-Sperre erfolgreich. -- Kein GitHub-Workflow gestartet und kein Plattform-Apply durchgeführt. - -## Erster Test vom Feature-Branch - -Pushes auf den exakten Branch `feature/landing-zone-configurator` starten bei Änderungen an IaC oder Bootstrap-Workflow automatisch einen Bootstrap-Plan. Nur das Plan-Environment erlaubt zusätzlich diesen Branch. Apply und Recovery bleiben auf main beschränkt; Feature-Pushes können keinen Apply-Job starten. Der CI-Einstieg prüft Branch/Event ebenfalls und bindet beides an das Planmanifest. Die Validierungs-CI läuft nun ebenfalls auf diesem Feature-Branch. - -## Einmaliger Bootstrap-Apply vom Feature-Branch +Die Workflows enthalten alle benötigten Umgebungsvariablen, Vorbereitungsschritte und Optionen. Diese Ausschnitte sind keine Anleitung für einen parallelen lokalen Apply. -Ein Feature-Push darf zusätzlich einen Apply-Job anfordern, wenn die Repository-Variable `LZC_BOOTSTRAP_APPLY_COMMIT` exakt seiner vollständigen Commit-SHA entspricht. Der CI-Einstieg prüft diesen Wert erneut, erlaubt dabei nur Root bootstrap und verlangt die korrekte Workflow-Identität. Das Apply-Environment erlaubt den exakten Feature-Branch, behält aber seine Reviewer-Freigabe. Die Variable wird nach dem Lauf entfernt; normale Feature-Pushes bleiben Plan-only. Wiederholungen benötigen einen neuen Run/Commit und einen neu geprüften Plan. +## Logs und Secrets -## Ausgeführte Läufe +Normale OpenTofu-Ausgaben erscheinen live im jeweiligen Schritt: Ressourcenänderungen, Fortschritt, Warnungen und Fehler. `sensitive`-Attribute/Outputs werden von OpenTofu maskiert; zusätzlich registriert die Vorbereitung die übergebenen Backend-Zugangsdaten und Verschlüsselungsschlüssel mit GitHubs `add-mask`. Kein `TF_LOG=DEBUG` und kein Shell-`set -x` für Deployment-Secrets. -- Erster Feature-Plan: [36599754659](https://github.com/stackitcloud/stackit-landing-zone/actions/runs/36599754659), erfolgreich, Apply übersprungen. -- Erster ausdrücklich freigegebener Bootstrap-Apply: [36601941540](https://github.com/stackitcloud/stackit-landing-zone/actions/runs/36601941540), Commit `59edaca69201d14083d7a6afd24cdf6cc3d7d267`, Plan und Apply erfolgreich. Drei Ressourcen erstellt; verschlüsselter Remote-State unabhängig überprüft. Die Variable `LZC_BOOTSTRAP_APPLY_COMMIT` wurde nach Abschluss entfernt. -- Begleitende Validierung: [36601941480](https://github.com/stackitcloud/stackit-landing-zone/actions/runs/36601941480), alle Jobs erfolgreich. +`tofu output -json` und State-Ausgaben können sensible Werte enthalten. Deshalb werden ausschließlich diese Ausgaben direkt in private Dateien unter `$RUNNER_TEMP/lzc-private` umgeleitet, mit `umask 077`. Sie werden nicht als Klartext-Artefakte hochgeladen. Die Credential-Vorbereitung führt selbst keine OpenTofu-Befehle aus. Zum Jobende werden die temporären Dateien entfernt. -Keine Übernahme nach main. Der separate Backend-Root und Plattformdienste wurden noch nicht angewendet. +Quellen: [OpenTofu sensitive outputs](https://opentofu.org/docs/language/values/outputs/#sensitive--suppressing-values-in-cli-output), [GNU timeout](https://www.gnu.org/software/coreutils/manual/html_node/timeout-invocation.html). -## Backend-Root über den Feature-Branch +## Kleine Hilfsprogramme -Für einen gezielten Backend-Lauf setzt der Operator zusätzlich `LZC_BOOTSTRAP_ROOT=backend`. Die Freigabe ist an Commit **und** Root gebunden; nur bootstrap/backend sind erlaubt. Nach dem Lauf werden Root-Auswahl und Commit-Freigabe entfernt. Ohne diese Variablen starten Feature-Pushes weiterhin ausschließlich einen Bootstrap-Plan. +| Datei | Begrenzte Aufgabe | +| --- | --- | +| `prepare.mjs` | Private Credential-/Backend-Dateien und Umgebungsvariablen vorbereiten; vorhandene Workload-Outputs/Versionierung prüfen. Keine Prozessausführung. | +| `review.mjs` | Verschlüsselten Plan mit Manifest versehen beziehungsweise vor Apply prüfen. Keine Prozessausführung. | +| `protect-state.mjs`, `artifact.mjs` | Bereits erfasste State-Dateien für Recovery verschlüsseln. Keine Prozessausführung. | +| `context.mjs` | Eingaben, Workflow-Identität und Planbindung prüfen. | +| `configure-github.mjs` | Separates Operator-Werkzeug für Environment-Regeln und ausdrücklich freigegebene Secret-Transfers. Kein Bestandteil eines Deployments. | -## Separate Plattform-Pipeline +## Backend- und Schlüsseltrennung -`configurator-platform.yml` plant ausschließlich den Plattform-Root. Plan/Apply verwenden `lzc-dev-platform-plan` und `lzc-dev-platform-apply`, gemeinsam mit Bootstrap die Concurrency-Gruppe `configurator-lzc-dev-mutation`. Für den Feature-Branch schaltet ausschließlich eine passende `LZC_PLATFORM_APPLY_COMMIT` einen reviewerpflichtigen Apply frei; Root und Workflow werden im CI-Einstieg zusätzlich geprüft. +Bootstrap und Backend verwenden den Verwaltungs-Bucket. Der Plattform-Job initialisiert zunächst diese beiden Roots und liest ihre Outputs privat. Erst nach Prüfung der Bucket-Versionierung wechselt er für seinen eigenen State zu den Workload-S3-Credentials. Jeder Root behält seinen separaten State-Verschlüsselungsschlüssel. Seed-Schlüssel werden nicht an diese Deployment-Jobs übergeben. -`node infra/ci/configure-github.mjs --platform` richtet die in `github-platform-environments.json` beschriebenen Environments ein. Es überträgt Projekt-Service-Account, Management-S3-Zugang und Bootstrap-/Backend-/Plattform-State-Schlüssel nach ausdrücklicher Ziel-Freigabe. Keine Seed-Schlüssel gelangen in diese Deployment-Jobs. Plattform-State liegt mit eigener Verschlüsselung im bereits versionierten Workload-Bucket, nicht im Verwaltungs-Bucket. +| Environment | Zweck | +| --- | --- | +| `lzc-dev-bootstrap-plan` / `lzc-dev-bootstrap-apply` | Bootstrap-/Backend-Plan und freizugebender Apply | +| `lzc-dev-platform-plan` / `lzc-dev-platform-apply` | Plattform-Plan und freizugebender Apply | +| `lzc-dev-recovery` | Separat geschützte Seed-Recovery-Secrets, kein automatischer Recovery-Job | -## Plattform-Bereitstellung freigegeben +Die Konfiguration steht in `github-environments.json` und `github-platform-environments.json`. Änderungen an Secret-Zielen oder Schlüsselübertragungen benötigen die entsprechende Benutzerautorisierung. Das Operator-Werkzeug lautet `node infra/ci/configure-github.mjs` beziehungsweise mit `--platform` für die Plattform-Environments. -Der Benutzer hat am 2026-09-29 ausdrücklich die Secret-Hinterlegung in `lzc-dev-platform-plan` und `lzc-dev-platform-apply` freigegeben und die Fortsetzung der Plattform-Bereitstellung beauftragt. Der erste CI-Apply wird über eine einzelne Commit-SHA in `LZC_PLATFORM_APPLY_COMMIT` angefordert; die Environment-Freigabe erfolgt erst nach Prüfung des konkreten Plans und erfolgreicher Validierung. +## Serialisierung und Feature-Branch +Alle mutierenden Configurator-Workflows verwenden `concurrency.group: configurator-lzc-dev-mutation` und `cancel-in-progress: false`, einschließlich Plan und Freigabewartezeit. Native S3-Sperren haben den Integrationstest nicht bestanden; `backend-safety.json` hält diesen Befund fest. Lokale Remote-Applies und schreibende Parallelzugriffe außerhalb dieser CI-Gruppe bleiben im vereinbarten Betrieb ausgeschlossen. Direkte CLI-Aufrufe durch Credential-Inhaber lassen sich nicht durch einen lokalen Wrapper oder GitHub-Concurrency verhindern. -## Plattform-Recovery nach abgebrochener Erstbereitstellung +Feature-Pushes auf `feature/landing-zone-configurator` erzeugen normalerweise nur Pläne. `LZC_PLATFORM_CI_ENABLED=true` aktiviert die Plattform-Pipeline. Ein Feature-Apply braucht zusätzlich eine passende vollständige Commit-SHA in `LZC_PLATFORM_APPLY_COMMIT` beziehungsweise `LZC_BOOTSTRAP_APPLY_COMMIT` und die Environment-Freigabe. Für einen Backend-Lauf wird `LZC_BOOTSTRAP_ROOT=backend` gesetzt. Diese Einmalvariablen nach dem Lauf entfernen. Der manuelle Hauptbranch-Pfad ist für die spätere Übernahme vorbereitet; derzeit wird nichts nach main gemergt. -Der freigegebene Lauf vom 2026-09-29 wurde nach 45 Minuten beendet, bevor ein Plattform-State gespeichert war. Die am 2026-09-30 geprüfte Datenbank ist `READY`. Das fest eingegrenzte Inventar in `recovery/lzc-dev-20260930.json` enthält sechs importierbare Ressourcen und genau einen verwaisten Model-Serving-Token. Ressourcen-IDs sind keine Zugangsdaten. +Die vorhandene Plan-Service-Account-Rolle ist nicht rein lesend. Deshalb dürfen diese Workflows ausschließlich vertrauenswürdigen Code ausführen; sie sind nicht für externe Pull Requests vorgesehen. -Der optionale `recover`-Job in `configurator-platform.yml` benötigt `LZC_PLATFORM_RECOVERY_COMMIT` gleich dem aktuellen Feature-Commit, `LZC_PLATFORM_CI_ENABLED=true` und das geschützte Apply-Environment. Er prüft den abgeschlossenen Quell-Run, entfernt dessen konkretes verwaistes Lock, importiert die sechs Ressourcen und widerruft den eindeutig identifizierten Token, dessen Wert nicht wiederherstellbar ist. Der STACKIT-Provider 0.114.0 unterstützt keinen Model-Serving-Token-Import. Der anschließende Plan ersetzt explizit die beiden importierten technischen Benutzer, deren Passwörter ebenfalls verloren sind. Instanzen, Organisation und Bucket bleiben bestehen. Der eigentliche Apply erfordert weiterhin einen getrennten, konkret geprüften Plan und die normale Commit-Freigabe. +## Timeouts und State-Sicherung -Diese Recovery ist absichtlich ein einmaliger Vorgang. Bei einem Teilfehler zuerst Inventar und State neu prüfen; nicht denselben Import blind wiederholen. Nach Abschluss beide Einmalvariablen entfernen. Normale Plan-Läufe überspringen Recovery. +Die Grenze steht direkt am jeweiligen `tofu`-Befehl und verwendet GNU `timeout`: -### Zeitlimits und Diagnose +| Schritt | Geordneter Abbruch nach | Zeit bis SIGKILL | Äußeres Joblimit | +| --- | --- | --- | --- | +| Plan | 15 Minuten | weitere 2 Minuten | 30 Minuten | +| Bootstrap-/Backend-Apply | 20 Minuten | weitere 5 Minuten | 45 Minuten | +| Plattform-Apply | 70 Minuten | weitere 10 Minuten | 100 Minuten | -Plattform-Jobs haben 100 Minuten statt 45 Minuten Laufzeit. Ein Plattform-Apply erhält maximal 70 Minuten; spätestens nach insgesamt 80 Minuten Skriptlaufzeit beginnt ein geordneter Abbruch. Der Prozess erhält genau ein `SIGINT` und bis zu zehn Minuten für Provider-Abbruch und State-Persistierung. Erst danach wird ein nicht reagierender Prozess beendet. Andere Roots behalten kürzere interne Grenzen. Ein Interrupt gilt auch bei Exit-Code 0 als fehlgeschlagener Versuch und darf nicht als erfolgreicher Apply gemeldet werden. +Ein Timeout sendet zunächst `SIGINT`, damit OpenTofu Provider beenden und State persistieren kann. Der Exit-Code bleibt ein Fehler, auch wenn der Prozess danach geordnet endet. Ein dauerhaft blockierter Prozess kann nach der zusätzlichen Wartezeit hart beendet werden. Die kurzen Init-Schritte haben eigene Grenzen; Snapshot und Upload liegen außerhalb des Apply-Kommandos und vor dem äußeren Joblimit. -Nach Apply/Recovery wird ein Remote-State-Snapshot versucht; ein lokaler `errored.tfstate` wird ebenfalls geschützt gesichert. Diagnose und Snapshots liegen ausschließlich als AES-256-GCM-Envelope in `.local/ci-recovery/*.enc.json` und werden auch bei fehlgeschlagenen Jobs für sieben Tage als GitHub-Artefakt hochgeladen. Schlüssel ist der jeweilige State-Schlüssel, daraus wird per scrypt mit zufälligem Salt ein Artefaktschlüssel abgeleitet. Der Envelope enthält `salt`, `nonce`, `tag` und `data` als Base64; Klartext darf nur lokal mit restriktiven Dateirechten entschlüsselt werden. Ein Snapshot kann zusätzlich OpenTofu-verschlüsselt sein. Abrupte Runner-Verluste können weiterhin Recovery erfordern; die Prozesssteuerung ersetzt keine erfolgreiche State-Persistierung. +Nach einem gestarteten Apply versucht ein `always()`-Schritt ausdrücklich `tofu state pull`. `protect-state.mjs` verschlüsselt diesen Snapshot und einen gegebenenfalls vorhandenen `errored.tfstate` mit AES-256-GCM und einem per scrypt aus dem jeweiligen State-Schlüssel abgeleiteten Schlüssel. Das Format enthält `salt`, `nonce`, `tag` und `data` als Base64; der State kann zusätzlich OpenTofu-verschlüsselt sein. Nur diese geschützten Dateien werden sieben Tage als Recovery-Artefakt aufbewahrt. Plan-Artefakte bleiben einen Tag erhalten. Abrupte Runner-Verluste können weiterhin manuelle Recovery erfordern. +## Betriebsnachweise -Recovery-Abnahme 2026-09-30: [Run 36675347654](https://github.com/stackitcloud/stackit-landing-zone/actions/runs/36675347654) erfolgreich. Alle sechs Imports, gezielter Token-Widerruf, frischer Plan und Apply abgeschlossen. Neun Ressourcen im verschlüsselten Remote-State unabhängig verifiziert; geschützte Apply-Diagnose und State-Snapshot erfolgreich entschlüsselt und geprüft. Beide Einmalvariablen wurden entfernt. `LZC_PLATFORM_CI_ENABLED=true` erlaubt wieder normale Plan-Läufe; der historische Recovery-Job bleibt ohne explizite Commit-Freigabe übersprungen. +Die bestehende Infrastruktur wurde durch diesen Umbau weder ersetzt noch migriert. Die erfolgreiche [Recovery und Plattform-Bereitstellung 36675347654](https://github.com/stackitcloud/stackit-landing-zone/actions/runs/36675347654) und der [No-op-Plan 36675895279](https://github.com/stackitcloud/stackit-landing-zone/actions/runs/36675895279) sind dokumentiert. Das abgeschlossene Recovery-Inventar liegt ausschließlich unter [docs/incidents](../../docs/incidents/2026-09-30-recovery-inventory.json); es ist kein ausführbarer Bestandteil der Pipeline. Aktuelle Abnahmen: [Plattform-Betriebsstand](../../docs/platform-readiness.md). diff --git a/landing-zone-configurator/infra/ci/artifact.mjs b/landing-zone-configurator/infra/ci/artifact.mjs new file mode 100644 index 0000000..10948aa --- /dev/null +++ b/landing-zone-configurator/infra/ci/artifact.mjs @@ -0,0 +1,10 @@ +import { randomBytes, scryptSync, createCipheriv } from "node:crypto"; +import { writeFileSync } from "node:fs"; + +export function writeProtectedArtifact(path, content, passphrase) { + if (passphrase.length < 32) throw new Error("Invalid artifact encryption key"); + const salt = randomBytes(16), nonce = randomBytes(12); + const cipher = createCipheriv("aes-256-gcm", scryptSync(passphrase, salt, 32), nonce); + const data = Buffer.concat([cipher.update(content), cipher.final()]); + writeFileSync(path, JSON.stringify({ version: 1, kdf: "scrypt", cipher: "aes-256-gcm", salt: salt.toString("base64"), nonce: nonce.toString("base64"), tag: cipher.getAuthTag().toString("base64"), data: data.toString("base64") }), { mode: 0o600 }); +} diff --git a/landing-zone-configurator/infra/ci/process.test.mjs b/landing-zone-configurator/infra/ci/artifact.test.mjs similarity index 57% rename from landing-zone-configurator/infra/ci/process.test.mjs rename to landing-zone-configurator/infra/ci/artifact.test.mjs index 1bf1cfc..ddeaf6e 100644 --- a/landing-zone-configurator/infra/ci/process.test.mjs +++ b/landing-zone-configurator/infra/ci/artifact.test.mjs @@ -4,16 +4,8 @@ import { mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { createDecipheriv, scryptSync } from "node:crypto"; -import { runProcess, writeProtectedArtifact } from "./process.mjs"; -test("timeout interrupts once and waits for child cleanup", async () => { - const r = await runProcess(process.execPath, ["-e", 'process.on("SIGINT",()=>setTimeout(()=>{console.log("saved");process.exit(0)},80));setInterval(()=>{},20)'], { timeoutMs: 500, graceMs: 2000, heartbeatMs: 0 }); - assert.equal(r.interrupted, true); assert.equal(r.code, 0); assert.equal(r.stdout.trim(), "saved"); -}); -test("unresponsive child is killed after grace period", async () => { - const r = await runProcess(process.execPath, ["-e", 'process.on("SIGINT",()=>{});setInterval(()=>{},20)'], { timeoutMs: 500, graceMs: 100, heartbeatMs: 0 }); - assert.equal(r.signal, "SIGKILL"); assert.equal(r.interrupted, true); -}); -test("diagnostics are authenticated ciphertext decryptable with the state key", () => { +import { writeProtectedArtifact } from "./artifact.mjs"; +test("state snapshots are authenticated ciphertext decryptable with the state key", () => { const dir = mkdtempSync(join(tmpdir(), "lzc-artifact-")); try { const path = join(dir, "log.enc"), key = "k".repeat(40), secret = "example-sensitive-output"; diff --git a/landing-zone-configurator/infra/ci/context.test.mjs b/landing-zone-configurator/infra/ci/context.test.mjs index c72f485..1733c76 100644 --- a/landing-zone-configurator/infra/ci/context.test.mjs +++ b/landing-zone-configurator/infra/ci/context.test.mjs @@ -24,7 +24,7 @@ test("plan context binds inputs, root, commit, run and source files",()=>{ }); test("local invocation cannot apply even with a valid plan context",()=>{ - const result=spawnSync(process.execPath,[fileURLToPath(new URL("./run.mjs",import.meta.url)),"apply"],{env:{...process.env,...env,GITHUB_ACTIONS:"false"},encoding:"utf8"}); + const result=spawnSync(process.execPath,[fileURLToPath(new URL("./review.mjs",import.meta.url)),"verify"],{env:{...process.env,...env,GITHUB_ACTIONS:"false"},encoding:"utf8"}); assert.notEqual(result.status,0); assert.match(result.stderr,/Remote apply is restricted to the serialized GitHub Actions workflow/); }); diff --git a/landing-zone-configurator/infra/ci/prepare.mjs b/landing-zone-configurator/infra/ci/prepare.mjs new file mode 100644 index 0000000..5bfdc93 --- /dev/null +++ b/landing-zone-configurator/infra/ci/prepare.mjs @@ -0,0 +1,87 @@ +// Prepare files/environment only. Every OpenTofu invocation stays in the workflow. +import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { randomBytes, randomUUID } from "node:crypto"; +import { deploymentInputs } from "./context.mjs"; + +const [mode] = process.argv.slice(2); +if (!["management", "workload", "validation"].includes(mode)) throw new Error("Usage: prepare.mjs management|workload|validation"); +process.umask(0o077); +const privateDir = resolve(process.env.RUNNER_TEMP, "lzc-private"); +mkdirSync(privateDir, { recursive: true, mode: 0o700 }); +const required = name => { if (!process.env[name]) throw new Error(`Missing ${name}`); return process.env[name]; }; +const mask = value => console.log(`::add-mask::${String(value).replaceAll("%", "%25").replaceAll("\r", "%0D").replaceAll("\n", "%0A")}`); +function publish(name, value, sensitive = false) { + if (sensitive) mask(value); + const delimiter = `lzc_${randomUUID()}`; + appendFileSync(required("GITHUB_ENV"), `${name}<<${delimiter}\n${value}\n${delimiter}\n`); +} +function readJson(path) { + try { return JSON.parse(readFileSync(path, "utf8")); } + catch { throw new Error("Required private JSON input missing or invalid"); } +} +function encryption(root, passphrase) { + if (passphrase.trim().length < 32) throw new Error("Invalid state encryption key"); + mask(passphrase.trim()); + const value = JSON.stringify({ key_provider: { pbkdf2: { state: { passphrase: passphrase.trim() } } } }); + writeFileSync(resolve(privateDir, `${root}.encryption.json`), value, { mode: 0o600 }); + return value; +} +function backend(root, bucket, inputs) { + const settings = { bucket, key: `configurator/${inputs.prefix}/${root}/terraform.tfstate`, region: inputs.region, + endpoints: { s3: `https://object.storage.${inputs.region}.onstackit.cloud` }, use_path_style: true, use_lockfile: true, + skip_credentials_validation: true, skip_region_validation: true, skip_requesting_account_id: true, + skip_metadata_api_check: true, skip_s3_checksum: true }; + writeFileSync(resolve(privateDir, `${root}.backend.hcl`), Object.entries(settings).map(([k,v]) => `${k} = ${JSON.stringify(v)}`).join("\n"), { mode: 0o600 }); +} +if (mode === "validation") { + const root = required("LZC_ROOT"); + if (!["seed", "seed-protection", "bootstrap", "backend", "platform"].includes(root)) throw new Error("Invalid validation root"); + publish("TF_ENCRYPTION", encryption(root, randomBytes(48).toString("base64url")), true); + publish("TF_DATA_DIR", resolve(privateDir, `${root}-data`)); +} else { + const inputs = deploymentInputs(process.env); + if (mode === "management") { + // Environment-scoped GitHub variables are read in this step, then passed to + // subsequent steps through GITHUB_ENV (not evaluated at workflow scope). + for (const name of ["LZC_PROJECT_ID", "LZC_REGION", "LZC_NAME_PREFIX", "LZC_MANAGEMENT_BUCKET", "LZC_CREDENTIAL_EXPIRATION"]) publish(name, required(name)); + const key = required("LZC_SERVICE_ACCOUNT_KEY"); + try { JSON.parse(key); } catch { throw new Error("Invalid service account JSON"); } + writeFileSync(resolve(privateDir, "service-account.json"), key, { mode: 0o600 }); + const roots = inputs.root === "platform" ? ["bootstrap", "backend", "platform"] : ["bootstrap", "backend"]; + for (const root of roots) { + const config = encryption(root, required(`LZC_STATE_KEY_${root.toUpperCase()}`)); + if (root === inputs.root) publish("TF_ENCRYPTION", config, true); + backend(root, inputs.bucket, inputs); + } + publish("STACKIT_SERVICE_ACCOUNT_KEY_PATH", resolve(privateDir, "service-account.json")); + publish("AWS_ACCESS_KEY_ID", required("LZC_MANAGEMENT_ACCESS_KEY"), true); + publish("AWS_SECRET_ACCESS_KEY", required("LZC_MANAGEMENT_SECRET_KEY"), true); + publish("TF_VAR_project_id", inputs.project); + publish("TF_VAR_region", inputs.region); + publish("TF_VAR_name_prefix", inputs.prefix); + publish("TF_VAR_state_credential_expiration", inputs.expiration); + publish("TF_DATA_DIR", resolve(privateDir, `${inputs.root}-data`)); + publish("LZC_PLAN", resolve(".local/ci-plan/review.tfplan")); + mkdirSync(resolve(".local/ci-plan"), { recursive: true, mode: 0o700 }); + } else { + if (!["backend", "platform"].includes(inputs.root)) throw new Error("Workload inputs not required for this root"); + const outputs = readJson(resolve(privateDir, "bootstrap-outputs.json")); + const storage = outputs.backend?.value, credentials = outputs.backend_credentials?.value; + if (storage?.bucket !== `${inputs.prefix}-state-${inputs.project.slice(0,8)}` || storage?.region !== inputs.region || !credentials?.access_key || !credentials?.secret_key) throw new Error("Invalid workload backend outputs"); + if (inputs.root === "platform") { + const protection = readJson(resolve(privateDir, "backend-outputs.json")); + if (protection.versioning_enabled?.value !== true || protection.state_bucket_name?.value !== storage.bucket) throw new Error("Versioned workload backend is not ready"); + backend("platform", storage.bucket, inputs); + publish("AWS_ACCESS_KEY_ID", credentials.access_key, true); + publish("AWS_SECRET_ACCESS_KEY", credentials.secret_key, true); + } else { + publish("TF_VAR_state_bucket_name", storage.bucket); + publish("TF_VAR_storage_access_key", credentials.access_key, true); + publish("TF_VAR_storage_secret_key", credentials.secret_key, true); + } + } +} +publish("LZC_PRIVATE", privateDir); +publish("TF_INPUT", "0"); +publish("TF_IN_AUTOMATION", "1"); diff --git a/landing-zone-configurator/infra/ci/prepare.test.mjs b/landing-zone-configurator/infra/ci/prepare.test.mjs new file mode 100644 index 0000000..d5da12e --- /dev/null +++ b/landing-zone-configurator/infra/ci/prepare.test.mjs @@ -0,0 +1,16 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, writeFileSync, readFileSync, statSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawnSync } from "node:child_process"; +const script=fileURLToPath(new URL("./prepare.mjs",import.meta.url)); +const base={LZC_ROOT:"platform",LZC_ENVIRONMENT:"lzc-dev",GITHUB_REF:"refs/heads/feature/landing-zone-configurator",GITHUB_EVENT_NAME:"push",GITHUB_RUN_ATTEMPT:"1",GITHUB_SHA:"a".repeat(40),LZC_PROJECT_ID:"00000000-0000-4000-8000-000000000001",LZC_REGION:"eu01",LZC_NAME_PREFIX:"lzc-dev",LZC_MANAGEMENT_BUCKET:"lzc-dev-management-00000000",LZC_CREDENTIAL_EXPIRATION:"2026-12-28T00:00:00Z",GITHUB_REPOSITORY:"stackitcloud/stackit-landing-zone",GITHUB_RUN_ID:"123",LZC_SERVICE_ACCOUNT_KEY:'{"test":"fake"}',LZC_MANAGEMENT_ACCESS_KEY:"management-access",LZC_MANAGEMENT_SECRET_KEY:"management-secret",LZC_STATE_KEY_BOOTSTRAP:"b".repeat(40),LZC_STATE_KEY_BACKEND:"c".repeat(40),LZC_STATE_KEY_PLATFORM:"p".repeat(40)}; +function fixture(fn){const dir=mkdtempSync(join(tmpdir(),"lzc-prepare-"));const env={...process.env,...base,RUNNER_TEMP:dir,GITHUB_ENV:join(dir,"env")};try{fn(dir,env,(mode,patch={})=>spawnSync(process.execPath,[script,mode],{cwd:dir,env:{...env,...patch},encoding:"utf8"}));}finally{rmSync(dir,{recursive:true,force:true});}} +function exported(path){const text=readFileSync(path,"utf8"),result={};const lines=text.split("\n");for(let i=0;ifixture((dir,env,run)=>{const r=run("management");assert.equal(r.status,0,r.stderr);const v=exported(env.GITHUB_ENV);assert.equal(v.AWS_SECRET_ACCESS_KEY,"management-secret");assert.equal(JSON.parse(v.TF_ENCRYPTION).key_provider.pbkdf2.state.passphrase,base.LZC_STATE_KEY_PLATFORM);const file=join(dir,"lzc-private/platform.backend.hcl");assert.ok(!readFileSync(file,"utf8").includes("secret"));assert.equal(statSync(join(dir,"lzc-private/service-account.json")).mode&0o777,0o600);assert.equal(statSync(join(dir,"lzc-private")).mode&0o777,0o700);})); +test("platform switches to workload backend only after matching versioning proof",()=>fixture((dir,env,run)=>{assert.equal(run("management").status,0);outputs(dir,false);writeFileSync(env.GITHUB_ENV,"");const failed=run("workload");assert.notEqual(failed.status,0);assert.equal(readFileSync(env.GITHUB_ENV,"utf8"),"");outputs(dir);const r=run("workload");assert.equal(r.status,0,r.stderr);assert.equal(exported(env.GITHUB_ENV).AWS_SECRET_ACCESS_KEY,"workload-secret");assert.match(readFileSync(join(dir,"lzc-private/platform.backend.hcl"),"utf8"),/lzc-dev-state-00000000/);})); +test("backend provider gets workload credentials without replacing management S3 credentials",()=>fixture((dir,env,run)=>{assert.equal(run("management",{LZC_ROOT:"backend"}).status,0);outputs(dir);const r=run("workload",{LZC_ROOT:"backend"});assert.equal(r.status,0,r.stderr);const v=exported(env.GITHUB_ENV);assert.equal(v.AWS_SECRET_ACCESS_KEY,"management-secret");assert.equal(v.TF_VAR_storage_secret_key,"workload-secret");})); +test("validation does not need deployment credentials or touch existing local state keys",()=>fixture((_dir,env,run)=>{const patch=Object.fromEntries(Object.keys(base).map(k=>[k,""]));const r=run("validation",{...patch,LZC_ROOT:"seed"});assert.equal(r.status,0,r.stderr);const v=exported(env.GITHUB_ENV);assert.ok(JSON.parse(v.TF_ENCRYPTION).key_provider.pbkdf2.state.passphrase.length>=32);assert.equal(v.AWS_ACCESS_KEY_ID,undefined);})); diff --git a/landing-zone-configurator/infra/ci/process.mjs b/landing-zone-configurator/infra/ci/process.mjs deleted file mode 100644 index 44007eb..0000000 --- a/landing-zone-configurator/infra/ci/process.mjs +++ /dev/null @@ -1,46 +0,0 @@ -import { spawn } from "node:child_process"; -import { randomBytes, scryptSync, createCipheriv } from "node:crypto"; -import { writeFileSync } from "node:fs"; - -// One interrupt lets OpenTofu stop providers and persist state. Never use a second -// interrupt as a normal timeout: OpenTofu treats it as an immediate abort. -export function runProcess(binary, args, { env, timeoutMs = 600_000, graceMs = 600_000, heartbeatMs = 30_000 } = {}) { - return new Promise((resolve, reject) => { - const child = spawn(binary, args, { env, stdio: ["ignore", "pipe", "pipe"] }); - const chunks = { stdout: [], stderr: [] }; - let size = 0, interrupted = false, overflow = false, killTimer; - const interrupt = () => { - if (interrupted) return; - interrupted = true; - child.kill("SIGINT"); - killTimer = setTimeout(() => child.kill("SIGKILL"), graceMs); - }; - const timeout = setTimeout(interrupt, timeoutMs); - const heartbeat = heartbeatMs ? setInterval(() => console.log(interrupted ? "OpenTofu stopping; waiting for state persistence." : "OpenTofu operation still running."), heartbeatMs) : undefined; - const cleanup = () => { - clearTimeout(timeout); clearTimeout(killTimer); clearInterval(heartbeat); - process.off("SIGINT", interrupt); process.off("SIGTERM", interrupt); - }; - process.on("SIGINT", interrupt); process.on("SIGTERM", interrupt); - for (const stream of ["stdout", "stderr"]) child[stream].on("data", data => { - size += data.length; - if (size <= 16 * 1024 * 1024) chunks[stream].push(data); - else { overflow = true; interrupt(); } - }); - child.once("error", () => { cleanup(); reject(new Error("Unable to start OpenTofu process")); }); - child.once("close", (code, signal) => { - cleanup(); - resolve({ code, signal, interrupted, overflow, stdout: Buffer.concat(chunks.stdout).toString(), stderr: Buffer.concat(chunks.stderr).toString() }); - }); - }); -} - -// Separate envelope from OpenTofu's state encryption; it also protects diagnostic -// text and any plaintext emergency state produced by a failed backend write. -export function writeProtectedArtifact(path, content, passphrase) { - if (passphrase.length < 32) throw new Error("Invalid artifact encryption key"); - const salt = randomBytes(16), nonce = randomBytes(12); - const cipher = createCipheriv("aes-256-gcm", scryptSync(passphrase, salt, 32), nonce); - const data = Buffer.concat([cipher.update(content), cipher.final()]); - writeFileSync(path, JSON.stringify({ version: 1, kdf: "scrypt", cipher: "aes-256-gcm", salt: salt.toString("base64"), nonce: nonce.toString("base64"), tag: cipher.getAuthTag().toString("base64"), data: data.toString("base64") }), { mode: 0o600 }); -} diff --git a/landing-zone-configurator/infra/ci/protect-state.mjs b/landing-zone-configurator/infra/ci/protect-state.mjs new file mode 100644 index 0000000..eeb9286 --- /dev/null +++ b/landing-zone-configurator/infra/ci/protect-state.mjs @@ -0,0 +1,16 @@ +// Encrypt captured state files only; state pull is an explicit workflow step. +import { existsSync, mkdirSync, readFileSync, statSync } from "node:fs"; +import { resolve } from "node:path"; +import { writeProtectedArtifact } from "./artifact.mjs"; +const root = process.env.LZC_ROOT; +if (!["bootstrap", "backend", "platform"].includes(root)) throw new Error("Invalid state root"); +const privateDir = process.env.LZC_PRIVATE; +const key = JSON.parse(readFileSync(resolve(privateDir, `${root}.encryption.json`))).key_provider.pbkdf2.state.passphrase; +const destination = resolve(".local/ci-recovery"); +mkdirSync(destination, { recursive: true, mode: 0o700 }); +for (const [name, path] of [["remote-state", resolve(privateDir,"state.snapshot")], ["emergency-state", resolve("infra",root,"errored.tfstate")]]) { + if (existsSync(path) && statSync(path).size > 0) { + writeProtectedArtifact(resolve(destination, `${name}.enc.json`), readFileSync(path), key); + console.log(`Protected ${name} snapshot saved.`); + } +} diff --git a/landing-zone-configurator/infra/ci/recovery.mjs b/landing-zone-configurator/infra/ci/recovery.mjs deleted file mode 100644 index 939d290..0000000 --- a/landing-zone-configurator/infra/ci/recovery.mjs +++ /dev/null @@ -1,48 +0,0 @@ -import { readFileSync } from "node:fs"; -import { randomUUID, sign } from "node:crypto"; - -// STACKIT SDK key-flow: RS512 JWT bearer exchange, no token or assertion logging. -// https://github.com/stackitcloud/stackit-sdk-go/blob/main/core/clients/key_flow.go -export async function accessToken(keyFile) { - const { credentials: c } = JSON.parse(readFileSync(keyFile, "utf8")); - if (c.tokenEndpoint !== "https://accounts.stackit.cloud/oauth/v2/token") throw new Error("Unexpected token endpoint"); - const b64 = value => Buffer.from(JSON.stringify(value)).toString("base64url"); - const now = Math.floor(Date.now() / 1000); - const unsigned = `${b64({ alg: "RS512", typ: "JWT", kid: c.kid })}.${b64({ iss: c.iss, sub: c.sub, aud: c.aud, jti: randomUUID(), iat: now, exp: now + 600 })}`; - const assertion = `${unsigned}.${sign("RSA-SHA512", Buffer.from(unsigned), c.privateKey).toString("base64url")}`; - const r = await fetch(c.tokenEndpoint, { method: "POST", redirect: "error", signal: AbortSignal.timeout(30_000), headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer", assertion }) }); - if (!r.ok) throw new Error(`Recovery authentication failed (HTTP ${r.status})`); - const result = await r.json(); - if (!result.access_token) throw new Error("Missing access token"); - return result.access_token; -} - -export async function recoverPlatform({ manifest, inputs, credentialsPath, tofu, env }) { - if (inputs.root !== "platform" || manifest.project !== inputs.project || process.env.LZC_RECOVERY_COMMIT !== inputs.commit) throw new Error("Recovery requires an exact approved platform commit"); - const previousRun = await fetch(`https://api.github.com/repos/stackitcloud/stackit-landing-zone/actions/runs/${manifest.failedRun}`, { redirect: "error", signal: AbortSignal.timeout(30_000), headers: { Accept: "application/vnd.github+json" } }); - if (!previousRun.ok) throw new Error("Cannot verify failed runner completion"); - const previous = await previousRun.json(); - if (previous.status !== "completed" || !["cancelled", "failure", "timed_out"].includes(previous.conclusion)) throw new Error("Recovery source run is not completed with failure"); - // This one-off manifest is reviewed alongside the code. The protected job shares - // the global mutation group; the failed runner must have completed beforehand. - await tofu("platform", ["force-unlock", "-force", manifest.lockId], env); - for (const [address, id] of Object.entries(manifest.imports)) { - if (!id.startsWith(`${inputs.project},`)) throw new Error("Import project mismatch"); - await tofu("platform", ["import", "-input=false", "-lock-timeout=60s", "-var-file=../environments/lzc-dev.tfvars.json", address, id], env); - console.log(`Recovered ${address} into encrypted remote state.`); - } - // Tokens have no provider importer, and their value is returned only at creation. - // Revoke only the explicitly inventoried orphan; a fresh reviewed plan creates its successor. - const token = await accessToken(credentialsPath); - const url = `https://model-serving.api.stackit.cloud/v1/projects/${inputs.project}/regions/${inputs.region}/tokens/${manifest.unrecoverableToken.id}`; - const options = { headers: { Authorization: `Bearer ${token}` }, redirect: "error", signal: AbortSignal.timeout(30_000) }; - const current = await fetch(url, options); - if (current.status === 404) { console.log("Orphan token already absent."); return; } - if (!current.ok) throw new Error(`Token inventory failed (HTTP ${current.status})`); - const body = await current.json(); - const details = body.token; - if (details?.id !== manifest.unrecoverableToken.id || details?.name !== manifest.unrecoverableToken.name) throw new Error("Orphan token identity mismatch"); - const revoked = await fetch(url, { ...options, method: "DELETE", signal: AbortSignal.timeout(30_000) }); - if (!revoked.ok) throw new Error(`Orphan token revocation failed (HTTP ${revoked.status})`); - console.log("Revoked the inventoried orphan Model Serving token."); -} diff --git a/landing-zone-configurator/infra/ci/recovery.test.mjs b/landing-zone-configurator/infra/ci/recovery.test.mjs deleted file mode 100644 index 6d92b77..0000000 --- a/landing-zone-configurator/infra/ci/recovery.test.mjs +++ /dev/null @@ -1,21 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import { recoverPlatform } from "./recovery.mjs"; -const inputs={root:"platform",project:"project",commit:"a".repeat(40)}; -const manifest={project:"project",failedRun:"123",lockId:"known-lock",imports:{"stackit_scf_organization.configurator":"project,eu01,id"}}; -test("recovery rejects unapproved commit before any cloud access",async()=>{ - await assert.rejects(recoverPlatform({manifest,inputs}),/exact approved/); -}); -test("active prior runner blocks unlocking and imports",async()=>{ - const original=globalThis.fetch;process.env.LZC_RECOVERY_COMMIT=inputs.commit; - try { globalThis.fetch=async()=>({ok:true,json:async()=>({status:"in_progress",conclusion:null})}); - await assert.rejects(recoverPlatform({manifest,inputs,tofu:()=>assert.fail("must not mutate")}),/not completed/); - }finally{globalThis.fetch=original;delete process.env.LZC_RECOVERY_COMMIT;} -}); -test("failed import stops before credential revocation",async()=>{ - const original=globalThis.fetch;process.env.LZC_RECOVERY_COMMIT=inputs.commit;const calls=[]; - try { globalThis.fetch=async()=>({ok:true,json:async()=>({status:"completed",conclusion:"cancelled"})}); - await assert.rejects(recoverPlatform({manifest,inputs,tofu:async(_root,args)=>{calls.push(args);if(args[0]==="import")throw new Error("import failed")}}),/import failed/); - assert.deepEqual(calls.map(x=>x[0]),["force-unlock","import"]);assert.equal(calls[0][2],"known-lock"); - }finally{globalThis.fetch=original;delete process.env.LZC_RECOVERY_COMMIT;} -}); diff --git a/landing-zone-configurator/infra/ci/review.mjs b/landing-zone-configurator/infra/ci/review.mjs new file mode 100644 index 0000000..03cff3c --- /dev/null +++ b/landing-zone-configurator/infra/ci/review.mjs @@ -0,0 +1,35 @@ +// Bind a saved, encrypted plan to this workflow run. Does not execute OpenTofu. +import { readFileSync, writeFileSync, readdirSync, appendFileSync } from "node:fs"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { deploymentInputs, digest, planContext, assertApplyExecution } from "./context.mjs"; +import { isApplicablePlan } from "../plan-guard.mjs"; +const infra = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const [command] = process.argv.slice(2); +if (!["seal", "verify"].includes(command)) throw new Error("Usage: review.mjs seal|verify"); +const inputs = deploymentInputs(process.env); +if (command === "verify") assertApplyExecution(JSON.parse(readFileSync(resolve(infra,"ci/backend-safety.json"))), process.env); +const pieces = []; +for (const root of ["bootstrap", "backend", "platform"]) { + for (const file of readdirSync(resolve(infra,root)).filter(f => f.endsWith(".tf") || f === ".terraform.lock.hcl").sort()) { + pieces.push(`${root}/${file}:${digest(readFileSync(resolve(infra,root,file)))}`); + } +} +if (inputs.root === "platform") pieces.push(digest(readFileSync(resolve(infra,"environments/lzc-dev.tfvars.json")))); +const contextHash = planContext(inputs, digest(pieces.join("\n"))); +const plan = process.env.LZC_PLAN ?? resolve(infra,"../.local/ci-plan/review.tfplan"); +const manifest = resolve(dirname(plan),"review.json"); +const bytes = readFileSync(plan), planHash = digest(bytes); +const envelope = JSON.parse(bytes); +if (!envelope.encrypted_data || !envelope.encryption_version) throw new Error("Refusing an unencrypted plan artifact"); +if (command === "seal") { + writeFileSync(manifest, JSON.stringify({ status: "ready", contextHash, planHash, createdAt: new Date().toISOString(), ...inputs }, null, 2), { mode: 0o600 }); + const summary = `## OpenTofu ${inputs.root}\nCommit: ${inputs.commit}\nPlan SHA-256: ${planHash}\n\nFull resource diff: see the OpenTofu plan step in this job.\n`; + console.log(summary); + if (process.env.GITHUB_STEP_SUMMARY) appendFileSync(process.env.GITHUB_STEP_SUMMARY, summary); +} else { + const review = JSON.parse(readFileSync(manifest)); + if (!isApplicablePlan(review, contextHash, planHash)) throw new Error("Plan mismatch or expiry; start a fresh workflow run"); + writeFileSync(manifest, JSON.stringify({ ...review, status: "consumed" }), { mode: 0o600 }); + console.log(`Verified saved ${inputs.root} plan for this commit and run.`); +} diff --git a/landing-zone-configurator/infra/ci/review.test.mjs b/landing-zone-configurator/infra/ci/review.test.mjs new file mode 100644 index 0000000..893368e --- /dev/null +++ b/landing-zone-configurator/infra/ci/review.test.mjs @@ -0,0 +1,21 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { fileURLToPath } from "node:url"; +import { spawnSync } from "node:child_process"; +const script=fileURLToPath(new URL("./review.mjs",import.meta.url)); +test("saved plan is bound to run and bytes, and can be consumed only once",()=>{ + const dir=mkdtempSync(join(tmpdir(),"lzc-review-")),plan=join(dir,"review.tfplan"); + const bytes=JSON.stringify({encryption_version:"v0",encrypted_data:"fake-test-ciphertext"}); + const env={...process.env,LZC_PLAN:plan,LZC_ROOT:"platform",LZC_ENVIRONMENT:"lzc-dev",GITHUB_REF:"refs/heads/feature/landing-zone-configurator",GITHUB_EVENT_NAME:"push",GITHUB_RUN_ATTEMPT:"1",GITHUB_SHA:"a".repeat(40),LZC_PROJECT_ID:"00000000-0000-4000-8000-000000000001",LZC_REGION:"eu01",LZC_NAME_PREFIX:"lzc-dev",LZC_MANAGEMENT_BUCKET:"lzc-dev-management-00000000",LZC_CREDENTIAL_EXPIRATION:"2026-12-28T00:00:00Z",GITHUB_REPOSITORY:"stackitcloud/stackit-landing-zone",GITHUB_RUN_ID:"123",GITHUB_ACTIONS:"true",GITHUB_WORKFLOW_REF:"stackitcloud/stackit-landing-zone/.github/workflows/configurator-platform.yml@refs/heads/feature/landing-zone-configurator",LZC_APPROVED_APPLY_COMMIT:"a".repeat(40),LZC_APPROVED_APPLY_ROOT:"platform",GITHUB_STEP_SUMMARY:join(dir,"summary")}; + const run=(command,patch={})=>spawnSync(process.execPath,[script,command],{env:{...env,...patch},encoding:"utf8"}); + try { + writeFileSync(plan,bytes);assert.equal(run("seal").status,0); + assert.notEqual(run("verify",{GITHUB_RUN_ID:"124"}).status,0); + writeFileSync(plan,bytes.replace("fake-test","altered-test"));assert.notEqual(run("verify").status,0); + writeFileSync(plan,bytes);const ok=run("verify");assert.equal(ok.status,0,ok.stderr);assert.notEqual(run("verify").status,0); + writeFileSync(plan,JSON.stringify({values:{}}));assert.notEqual(run("seal").status,0); + }finally{rmSync(dir,{recursive:true,force:true});} +}); diff --git a/landing-zone-configurator/infra/ci/run.mjs b/landing-zone-configurator/infra/ci/run.mjs deleted file mode 100644 index ec417d4..0000000 --- a/landing-zone-configurator/infra/ci/run.mjs +++ /dev/null @@ -1,133 +0,0 @@ -// CI-only entrypoint. No local .env, seed output, state or encryption key is required. -import { execFileSync } from "node:child_process"; -import { readFileSync, writeFileSync, mkdirSync, rmSync, readdirSync, appendFileSync, existsSync } from "node:fs"; -import { dirname, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; -import { deploymentInputs, digest, planContext, assertApplyExecution } from "./context.mjs"; -import { isApplicablePlan } from "../plan-guard.mjs"; -import { runProcess, writeProtectedArtifact } from "./process.mjs"; -import { recoverPlatform } from "./recovery.mjs"; -const infra = resolve(dirname(fileURLToPath(import.meta.url)), ".."); -const [command, ...extra] = process.argv.slice(2); -if (!["plan", "apply", "recover"].includes(command) || extra.length) throw new Error("Usage: node infra/ci/run.mjs plan|apply|recover"); -const inputs = deploymentInputs(process.env); -const safety = JSON.parse(readFileSync(resolve(infra,"ci/backend-safety.json"),"utf8")); -if(command !== "plan") assertApplyExecution(safety, process.env); -const startedAt = Date.now(); -const binary = process.env.LZC_TOFU_BIN ?? "tofu"; -const temporary = resolve(process.env.RUNNER_TEMP ?? infra, `lzc-ci-${inputs.run}-${command}`); -const artifact = resolve(infra,"../.local/ci-plan"); -process.umask(0o077); -mkdirSync(temporary,{recursive:true,mode:0o700}); -mkdirSync(artifact,{recursive:true,mode:0o700}); -const recoveryArtifact = resolve(infra,"../.local/ci-recovery"); -mkdirSync(recoveryArtifact,{recursive:true,mode:0o700}); -const cleanEnv = Object.fromEntries(Object.entries(process.env).filter(([k]) => !/^(AWS_|TF_|STACKIT_|LZC_)/.test(k))); -const secret = (name) => { if(!process.env[name])throw new Error(`Missing ${name}`); return process.env[name]; }; -const credentialsPath=resolve(temporary,"service-account.json"); -const encryption = (root) => { - const passphrase=secret(`LZC_STATE_KEY_${root.toUpperCase()}`).trim(); - if(passphrase.length<32)throw new Error("Invalid encryption key"); - return JSON.stringify({key_provider:{pbkdf2:{state:{passphrase}}}}); -}; -const environment = (root) => ({...cleanEnv, TF_DATA_DIR:resolve(temporary,root), TF_INPUT:"0", TF_ENCRYPTION:encryption(root), - AWS_ACCESS_KEY_ID:secret("LZC_MANAGEMENT_ACCESS_KEY"), AWS_SECRET_ACCESS_KEY:secret("LZC_MANAGEMENT_SECRET_KEY"), - STACKIT_SERVICE_ACCOUNT_KEY_PATH:credentialsPath, TF_VAR_project_id:inputs.project, TF_VAR_region:inputs.region, - TF_VAR_name_prefix:inputs.prefix, TF_VAR_state_credential_expiration:inputs.expiration }); -async function tofu(root,args,env, snapshot = false) { - const remaining = Math.max(1, (inputs.root === "platform" ? 80 : 25)*60_000 - (Date.now()-startedAt)); - const timeoutMs = snapshot ? 60_000 : Math.min(remaining, args[0] === "apply" && root === "platform" ? 70*60_000 : 10*60_000); - const result = await runProcess(binary,[`-chdir=${resolve(infra,root)}`,...args],{env, timeoutMs, graceMs: snapshot ? 30_000 : 10*60_000}); - if (args[0] === "apply" || result.code !== 0 || result.interrupted) { - writeProtectedArtifact(resolve(recoveryArtifact,`${root}-${args[0]}-${Date.now()}.enc.json`),JSON.stringify(result),secret(`LZC_STATE_KEY_${root.toUpperCase()}`).trim()); - } - if(result.code !== 0 || result.interrupted || result.overflow) throw new Error(`OpenTofu ${root}/${args[0]} failed or was interrupted; inspect encrypted diagnostics`); - return result.stdout; -} -async function preserveState(env) { - const key = secret(`LZC_STATE_KEY_${inputs.root.toUpperCase()}`).trim(); - const emergency = resolve(infra,inputs.root,"errored.tfstate"); - if(existsSync(emergency)) writeProtectedArtifact(resolve(recoveryArtifact,"emergency-state.enc.json"),readFileSync(emergency),key); - try { - const state = await tofu(inputs.root,["state","pull"],env,true); - writeProtectedArtifact(resolve(recoveryArtifact,"remote-state.enc.json"),state,key); - console.log("Protected remote-state snapshot saved."); - } catch { console.log("Remote-state snapshot unavailable; inspect protected diagnostics and emergency artifact."); } -} -async function initialize(root, storage) { - const env=environment(root); - if(storage) { env.AWS_ACCESS_KEY_ID=storage.credentials.access_key; env.AWS_SECRET_ACCESS_KEY=storage.credentials.secret_key; } - const backend={bucket:storage?.bucket ?? inputs.bucket,key:`configurator/${inputs.prefix}/${root}/terraform.tfstate`,region:inputs.region, - endpoints:{s3:`https://object.storage.${inputs.region}.onstackit.cloud`},use_path_style:true,use_lockfile:true, - skip_credentials_validation:true,skip_region_validation:true,skip_requesting_account_id:true,skip_metadata_api_check:true,skip_s3_checksum:true}; - const file=resolve(temporary,`${root}.backend.hcl`); - writeFileSync(file,Object.entries(backend).map(([k,v])=>`${k} = ${JSON.stringify(v)}`).join("\n")); - await tofu(root,["init",`-backend-config=${file}`,"-lockfile=readonly","-input=false","-no-color"],env); - return env; -} -function sourceDigest() { - const pieces=[]; - for(const root of ["bootstrap","backend","platform"]) { - for(const file of readdirSync(resolve(infra,root)).filter(f=>f.endsWith(".tf")||f===".terraform.lock.hcl").sort()) { - pieces.push(`${root}/${file}:${digest(readFileSync(resolve(infra,root,file)))}`); - } - } - if(inputs.root === "platform") pieces.push(digest(readFileSync(resolve(infra,"environments/lzc-dev.tfvars.json")))); - return digest(pieces.join("\n")); -} -try { - const version=JSON.parse(execFileSync(binary,["version","-json"],{encoding:"utf8"})); - if(version.terraform_version!=="1.12.6")throw new Error("OpenTofu 1.12.6 required"); - const credentialJson=secret("LZC_SERVICE_ACCOUNT_KEY"); - try { JSON.parse(credentialJson); } catch { throw new Error("Invalid service-account JSON"); } - writeFileSync(credentialsPath,credentialJson,{mode:0o600}); - let storage; - if(inputs.root === "platform") { - const bootstrapEnv=await initialize("bootstrap"); - const outputs=JSON.parse(await tofu("bootstrap",["output","-json"],bootstrapEnv)); - const protection=JSON.parse(await tofu("backend",["output","-json"],await initialize("backend"))); - if(outputs.backend?.value?.region !== inputs.region || protection.versioning_enabled?.value !== true || protection.state_bucket_name?.value !== outputs.backend.value.bucket) throw new Error("Versioned platform backend is not ready"); - storage={bucket:outputs.backend.value.bucket,credentials:outputs.backend_credentials.value}; - } - const env=await initialize(inputs.root,storage); - if(inputs.root==="backend") { - const bootstrapEnv=await initialize("bootstrap"); - const outputs=JSON.parse(await tofu("bootstrap",["output","-json"],bootstrapEnv)); - if(outputs.backend?.value?.region!==inputs.region)throw new Error("Bootstrap region mismatch"); - env.TF_VAR_state_bucket_name=outputs.backend.value.bucket; - env.TF_VAR_storage_access_key=outputs.backend_credentials.value.access_key; - env.TF_VAR_storage_secret_key=outputs.backend_credentials.value.secret_key; - } - const contextHash=planContext(inputs,sourceDigest()); - const plan=resolve(artifact,"review.tfplan"); - const manifest=resolve(artifact,"review.json"); - if(command==="recover") { - try { await recoverPlatform({manifest:JSON.parse(readFileSync(resolve(infra,"ci/recovery/lzc-dev-20260930.json"),"utf8")), inputs, credentialsPath, tofu, env}); } - finally { await preserveState(env); } - } else if(command==="plan") { - writeFileSync(manifest,JSON.stringify({status:"planning"})); - const parameters=inputs.root === "platform" ? [`-var-file=${resolve(infra,"environments/lzc-dev.tfvars.json")}`] : []; - if(inputs.root === "platform" && process.env.LZC_RECOVERY_COMMIT === inputs.commit) parameters.push("-replace=stackit_scf_organization_manager.configurator","-replace=stackit_secretsmanager_user.provisioner"); - await tofu(inputs.root,["plan",...parameters,`-out=${plan}`,"-input=false","-lock-timeout=60s","-no-color"],env); - const envelope=JSON.parse(readFileSync(plan,"utf8")); - if(!envelope.encrypted_data||!envelope.encryption_version)throw new Error("Refusing an unencrypted plan artifact"); - const details=JSON.parse(await tofu(inputs.root,["show","-json",plan],env)); - const summary=(details.resource_changes??[]).map(r=>({address:r.address,actions:r.change.actions})); - const review={status:"ready",contextHash,planHash:digest(readFileSync(plan)),createdAt:new Date().toISOString(),...inputs}; - writeFileSync(manifest,JSON.stringify(review,null,2)); - const text=`## ${inputs.environment}: ${inputs.root}\nCommit: ${inputs.commit}\n\n${summary.map(r=>`- ${r.address}: ${r.actions.join(", ")}`).join("\n")}\n\nPlan SHA-256: ${review.planHash}\n`; - writeFileSync(resolve(artifact,"summary.md"),text); - if(process.env.GITHUB_STEP_SUMMARY)appendFileSync(process.env.GITHUB_STEP_SUMMARY,text); - console.log(text); - } else { - const review=JSON.parse(readFileSync(manifest,"utf8")); - if(!isApplicablePlan(review,contextHash,digest(readFileSync(plan))))throw new Error("Plan mismatch or expiry; start a fresh workflow run"); - // Saved plan applies exactly the reviewed actions. No fresh plan is generated here. - writeFileSync(manifest,JSON.stringify({...review,status:"consumed"})); - try { await tofu(inputs.root,["apply","-lock-timeout=60s","-no-color",plan],env); } - finally { await preserveState(env); } - console.log(`Applied reviewed ${inputs.root} plan successfully.`); - } -} finally { - rmSync(temporary,{recursive:true,force:true}); -} diff --git a/landing-zone-configurator/infra/ci/timeout.test.mjs b/landing-zone-configurator/infra/ci/timeout.test.mjs new file mode 100644 index 0000000..68f73ae --- /dev/null +++ b/landing-zone-configurator/infra/ci/timeout.test.mjs @@ -0,0 +1,8 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +const binary=["timeout","gtimeout"].find(cmd=>spawnSync(cmd,["--version"],{encoding:"utf8"}).status===0); +test("GNU timeout streams progress, allows cleanup after SIGINT and remains a failure",{skip:!binary&&"GNU coreutils unavailable; executed on the Linux CI runner"},()=>{ + const r=spawnSync(binary,["--foreground","--signal=INT","--kill-after=2s","0.5s",process.execPath,"-e",'console.log("progress");process.on("SIGINT",()=>setTimeout(()=>{console.log("state saved");process.exit(0)},80));setInterval(()=>{},20)'],{encoding:"utf8",timeout:5000}); + assert.equal(r.status,124);assert.match(r.stdout,/progress/);assert.match(r.stdout,/state saved/); +}); diff --git a/landing-zone-configurator/infra/platform/README.md b/landing-zone-configurator/infra/platform/README.md index 50519fa..85ef969 100644 --- a/landing-zone-configurator/infra/platform/README.md +++ b/landing-zone-configurator/infra/platform/README.md @@ -4,6 +4,6 @@ Dieser Root verwaltet CF-Organisation und technischen Org-Manager, PostgreSQL Fl Region, Foundation, Quota, DB-Plan und verifizierte Quell-CIDRs sind explizite Eingaben. Keine offene Standard-ACL. Der Datenbank-Owner ist für Migrationen vorgesehen, nicht als späterer App-Benutzer; App-Rollen/RLS und Secret-Policies werden in der Runtime-Schicht ergänzt. -Voraussetzung: erfolgreich angewendeter State-Bootstrap und Backend-Root mit aktivierter Versionierung. Der Wrapper übergibt dessen Backend-Zugang ohne Klartextdatei. [IaC-Anleitung](../README.md). +Voraussetzung: erfolgreich angewendeter State-Bootstrap und Backend-Root mit aktivierter Versionierung. Die Pipeline übergibt dessen Backend-Zugang aus einer privaten temporären Output-Datei; normale OpenTofu-Ausgaben bleiben sichtbar. [IaC-Anleitung](../README.md). Ein `sensitive`-Output maskiert nur die Anzeige. Provider-erzeugte Credentials liegen im verschlüsselten State. Nachgelagerte IaC erhält nur die benötigten Outputs; keine Anbindung der unbeschränkten Provisionierungsidentitäten an Kunden-Runner. diff --git a/landing-zone-configurator/infra/prepare-local.mjs b/landing-zone-configurator/infra/prepare-local.mjs new file mode 100644 index 0000000..7ec4c99 --- /dev/null +++ b/landing-zone-configurator/infra/prepare-local.mjs @@ -0,0 +1,41 @@ +// Writes a private shell environment only. Run tofu explicitly after sourcing it. +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { parseEnv } from "node:util"; +import { stateKey } from "./state-key.mjs"; +const infra = dirname(fileURLToPath(import.meta.url)), base = resolve(infra,".."), repository = resolve(base,".."); +const [root, flag, ...extra] = process.argv.slice(2), validation = flag === "--validation"; +if (!["seed","seed-protection","bootstrap","backend","platform"].includes(root) || (flag && !validation) || extra.length) throw new Error("Usage: prepare-local.mjs ROOT [--validation]"); +if (!validation && !["seed","seed-protection"].includes(root)) throw new Error("Remote deployment preparation belongs to the serialized GitHub workflow; use --validation locally"); +if (process.env.CI || process.env.GITHUB_ACTIONS) throw new Error("Use infra/ci/prepare.mjs in CI"); +process.umask(0o077); +const location = validation ? resolve(base,".local/validation",root) : resolve(base,".local",root); +mkdirSync(location,{recursive:true,mode:0o700}); +const passphrase = stateKey(location, { validation }); +const env = { TF_INPUT:"0", TF_DATA_DIR:resolve(location,"data"), TF_ENCRYPTION:JSON.stringify({key_provider:{pbkdf2:{state:{passphrase}}}}) }; +if (!validation) { + const config = parseEnv(readFileSync(resolve(repository,"landing-zone-configurator.env"),"utf8")); + if (!config.PROJECT_ID || !config.REGION || !config.NAME_PREFIX) throw new Error("Project, region and prefix are required"); + env.TF_VAR_region=config.REGION; + if (root === "seed") { + if (!config.STATE_CREDENTIAL_EXPIRATION) throw new Error("Explicit credential expiration required"); + const credentials=resolve(repository,"landing-zone-configurator-credentials.json"); + if (!existsSync(credentials)) throw new Error("Service-account file missing"); + Object.assign(env,{STACKIT_SERVICE_ACCOUNT_KEY_PATH:credentials,TF_VAR_project_id:config.PROJECT_ID,TF_VAR_name_prefix:config.NAME_PREFIX,TF_VAR_state_credential_expiration:config.STATE_CREDENTIAL_EXPIRATION}); + } else { + let output; + try { output=JSON.parse(readFileSync(resolve(base,".local/seed/outputs.json"),"utf8")); } + catch { throw new Error("Capture seed outputs privately with tofu output -json first; see infra/README.md"); } + if(output.backend?.value?.region!==config.REGION || output.backend?.value?.bucket!==`${config.NAME_PREFIX}-management-${config.PROJECT_ID.slice(0,8)}`) throw new Error("Seed backend does not match project"); + const credentials=output.backend_credentials?.value; + if(!credentials?.access_key || !credentials?.secret_key) throw new Error("Seed credentials missing"); + Object.assign(env,{AWS_ACCESS_KEY_ID:credentials.access_key,AWS_SECRET_ACCESS_KEY:credentials.secret_key,TF_VAR_state_bucket_name:output.backend.value.bucket}); + } + writeFileSync(resolve(location,"backend.hcl"),`path = ${JSON.stringify(resolve(location,"terraform.tfstate"))}\n`,{mode:0o600}); +} +const quote = value => `'${String(value).replaceAll("'", "'\\''")}'`; +const inherited=Object.keys(process.env).filter(k=>/^(AWS_|STACKIT_|TF_VAR_|TF_CLI_ARGS|TF_LOG)/.test(k)&&/^[A-Za-z_][A-Za-z0-9_]*$/.test(k)); +const script=(inherited.length?`unset ${inherited.join(" ")}\n`:"")+Object.entries(env).map(([k,v])=>`export ${k}=${quote(v)}`).join("\n")+"\n"; +writeFileSync(resolve(location,"environment.sh"),script,{mode:0o600}); +console.log(`Prepared ${validation?"disposable validation":"local seed"} environment at ${location}/environment.sh. No OpenTofu command executed.`); diff --git a/landing-zone-configurator/infra/prepare-local.test.mjs b/landing-zone-configurator/infra/prepare-local.test.mjs new file mode 100644 index 0000000..fa711e0 --- /dev/null +++ b/landing-zone-configurator/infra/prepare-local.test.mjs @@ -0,0 +1,19 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, copyFileSync, writeFileSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawnSync } from "node:child_process"; +test("local seed preparation preserves existing keys and shell-quotes secrets without execution",()=>{ + const repo=mkdtempSync(join(tmpdir(),"lzc-local-")),base=join(repo,"landing-zone-configurator"),infra=join(base,"infra");mkdirSync(infra,{recursive:true}); + for(const name of ["prepare-local.mjs","state-key.mjs"])copyFileSync(fileURLToPath(new URL(name,import.meta.url)),join(infra,name)); + const location=join(base,".local/seed");mkdirSync(location,{recursive:true}); + const key="test-key-".repeat(6)+"'\n$(false)`false`"; + writeFileSync(join(location,"state.passphrase"),key);writeFileSync(join(repo,"landing-zone-configurator.env"),'PROJECT_ID=00000000-0000-4000-8000-000000000001\nREGION=eu01\nNAME_PREFIX=lzc-dev\nSTATE_CREDENTIAL_EXPIRATION=2026-12-28T00:00:00Z\n');writeFileSync(join(repo,"landing-zone-configurator-credentials.json"),'{}'); + try{ + const env={...process.env,CI:"",GITHUB_ACTIONS:""};const r=spawnSync(process.execPath,[join(infra,"prepare-local.mjs"),"seed"],{env,encoding:"utf8"});assert.equal(r.status,0,r.stderr); + const result=spawnSync('bash',['-c','. "$1"; "$2" -e \'process.stdout.write(process.env.TF_ENCRYPTION)\'','test',join(location,"environment.sh"),process.execPath],{env,encoding:"utf8"});assert.equal(result.status,0,result.stderr);assert.equal(JSON.parse(result.stdout).key_provider.pbkdf2.state.passphrase,key);assert.equal(readFileSync(join(location,"state.passphrase"),"utf8"),key); + const denied=spawnSync(process.execPath,[join(infra,"prepare-local.mjs"),"platform"],{env,encoding:"utf8"});assert.notEqual(denied.status,0);assert.match(denied.stderr,/serialized GitHub workflow/); + }finally{rmSync(repo,{recursive:true,force:true});} +}); diff --git a/landing-zone-configurator/infra/tofu.mjs b/landing-zone-configurator/infra/tofu.mjs deleted file mode 100644 index cd1c8db..0000000 --- a/landing-zone-configurator/infra/tofu.mjs +++ /dev/null @@ -1,181 +0,0 @@ -import { execFileSync, spawnSync } from "node:child_process"; -import { createHash } from "node:crypto"; -import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import { dirname, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; -import { parseEnv } from "node:util"; -import { stateKey } from "./state-key.mjs"; -import { isApplicablePlan } from "./plan-guard.mjs"; - -const infra = dirname(fileURLToPath(import.meta.url)); -const repository = resolve(infra, "../.."); -const local = resolve(infra, "../.local"); -const [root, command, ...extra] = process.argv.slice(2); -const allowed = ["init", "validate", "plan", "apply", "test", "fmt", "providers"]; -if (!["seed", "seed-protection", "bootstrap", "backend", "platform"].includes(root) || !allowed.includes(command)) { - console.error("Usage: node infra/tofu.mjs [options]"); - process.exit(1); -} -const binary = process.env.LZC_TOFU_BIN ?? "tofu"; -const version = JSON.parse(execFileSync(binary, ["version", "-json"], { encoding: "utf8" })); -if (version.terraform_version !== "1.12.6") throw new Error("OpenTofu 1.12.6 is required"); -process.umask(0o077); -mkdirSync(local, { recursive: true, mode: 0o700 }); - -function rootEnvironment(name) { - const location = resolve(local, name); - mkdirSync(location, { recursive: true, mode: 0o700 }); - const ci = process.env.CI === "true" || process.env.GITHUB_ACTIONS === "true"; - const validation = ["validate", "test", "fmt", "providers"].includes(command) || - (command === "init" && extra.includes("-backend=false")); - // Fail closed until durable remote state has been integrated for every root. - if (ci && !validation) throw new Error("CI cloud operations require the remote-state integration; local CI state is forbidden"); - const passphrase = stateKey(location, { - ci, validation, suppliedKey: process.env[`LZC_STATE_KEY_${name.toUpperCase()}`], - }); - const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => - !key.startsWith("LZC_STATE_KEY_") && !key.startsWith("STACKIT_") && !key.startsWith("TF_VAR_") && !key.startsWith("TF_LOG") && - !key.startsWith("TF_CLI_ARGS") && !key.startsWith("AWS_"))); - env.TF_DATA_DIR = resolve(location, "data"); - env.TF_INPUT = "0"; - env.TF_ENCRYPTION = JSON.stringify({ key_provider: { pbkdf2: { state: { passphrase } } } }); - return env; -} - -try { - if(command === "apply" && ["bootstrap","backend","platform"].includes(root)) { - const safety=JSON.parse(readFileSync(resolve(infra,"ci/backend-safety.json"),"utf8")); - if(safety.mode === "github-actions-single-writer" || safety.s3LockingVerified !== true)throw new Error("Local remote apply disabled: use the serialized GitHub Actions workflow"); - } - const env = rootEnvironment(root); - const configFile = resolve(repository, "landing-zone-configurator.env"); - const config = existsSync(configFile) ? parseEnv(readFileSync(configFile, "utf8")) : {}; - const cloudOperation = ["plan", "apply"].includes(command) || (["bootstrap", "backend", "platform"].includes(root) && command === "init" && !extra.includes("-backend=false")); - if (cloudOperation) { - if (!config.PROJECT_ID || !config.REGION || !config.NAME_PREFIX) { - throw new Error("Set PROJECT_ID, REGION and NAME_PREFIX in the local configurator env file before planning/applying"); - } - const credentials = resolve(repository, "landing-zone-configurator-credentials.json"); - if (!existsSync(credentials)) throw new Error("Configurator credential file is missing"); - env.STACKIT_SERVICE_ACCOUNT_KEY_PATH = credentials; - env.TF_VAR_project_id = config.PROJECT_ID; - env.TF_VAR_region = config.REGION; - env.TF_VAR_name_prefix = config.NAME_PREFIX; - if (["seed", "bootstrap"].includes(root)) { - if (!config.STATE_CREDENTIAL_EXPIRATION) throw new Error("Set STATE_CREDENTIAL_EXPIRATION explicitly"); - env.TF_VAR_state_credential_expiration = config.STATE_CREDENTIAL_EXPIRATION; - } - } - const args = [`-chdir=${resolve(infra, root)}`, command, ...extra]; - if (["seed", "seed-protection"].includes(root) && command === "init" && !extra.includes("-backend=false")) { - args.push(`-backend-config=path=${resolve(local, root, "terraform.tfstate")}`); - } - function readOutputs(name) { - try { - return JSON.parse(execFileSync(binary, [`-chdir=${resolve(infra, name)}`, "output", "-json"], - { env: rootEnvironment(name), encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] })); - } catch { throw new Error(`Outputs unavailable for ${name}; initialize/apply or restore it first`); } - } - function credentialsFrom(outputs) { - const credentials = outputs.backend_credentials?.value; - if (!credentials) throw new Error("Backend credentials missing"); - return credentials; - } - if (["seed-protection", "bootstrap", "backend", "platform"].includes(root) && cloudOperation) { - const management = readOutputs("seed"); - const managementCredentials = credentialsFrom(management); - let source = management; - if (root === "platform") { - // Read bootstrap with its management backend credentials, without printing outputs. - const bootstrapEnv = rootEnvironment("bootstrap"); - bootstrapEnv.AWS_ACCESS_KEY_ID = managementCredentials.access_key; - bootstrapEnv.AWS_SECRET_ACCESS_KEY = managementCredentials.secret_key; - try { - source = JSON.parse(execFileSync(binary, [`-chdir=${resolve(infra, "bootstrap")}`, "output", "-json"], - { env: bootstrapEnv, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] })); - } catch { throw new Error("Bootstrap outputs unavailable"); } - } - const backend = source.backend?.value; - const credentials = credentialsFrom(source); - if (!backend || backend.region !== config.REGION) throw new Error("Backend region mismatch"); - env.AWS_ACCESS_KEY_ID = credentials.access_key; - env.AWS_SECRET_ACCESS_KEY = credentials.secret_key; - if (root === "seed-protection") env.TF_VAR_state_bucket_name = backend.bucket; - if (root === "backend" && command !== "init") { - const bootstrapEnv = { ...rootEnvironment("bootstrap"), AWS_ACCESS_KEY_ID: managementCredentials.access_key, - AWS_SECRET_ACCESS_KEY: managementCredentials.secret_key }; - let outputs; - try { - outputs = JSON.parse(execFileSync(binary, [`-chdir=${resolve(infra, "bootstrap")}`, "output", "-json"], - { env: bootstrapEnv, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] })); - } catch { throw new Error("Bootstrap outputs unavailable"); } - env.TF_VAR_state_bucket_name = outputs.backend.value.bucket; - // AWS provider uses explicit credentials; S3 state backend retains management credentials. - const workload = credentialsFrom(outputs); - env.TF_VAR_storage_access_key = workload.access_key; - env.TF_VAR_storage_secret_key = workload.secret_key; - } - if (["bootstrap", "backend", "platform"].includes(root) && command === "init") { - if (root === "platform") { - const protectionEnv = { ...rootEnvironment("backend"), AWS_ACCESS_KEY_ID: managementCredentials.access_key, - AWS_SECRET_ACCESS_KEY: managementCredentials.secret_key }; - let protection; - try { - protection = JSON.parse(execFileSync(binary, [`-chdir=${resolve(infra, "backend")}`, "output", "-json"], - { env: protectionEnv, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] })); - } catch { throw new Error("Backend protection outputs unavailable"); } - if (protection.versioning_enabled?.value !== true || protection.state_bucket_name?.value !== backend.bucket) { - throw new Error("Apply workload bucket versioning first"); - } - } else { - const protection = readOutputs("seed-protection"); - if (protection.versioning_enabled?.value !== true || protection.state_bucket_name?.value !== backend.bucket) { - throw new Error("Apply management bucket versioning first"); - } - } - const backendFile = resolve(local, root, "backend.hcl"); - const settings = [ - `bucket = ${JSON.stringify(backend.bucket)}`, - `key = ${JSON.stringify(`configurator/${config.NAME_PREFIX}/${root}/terraform.tfstate`)}`, - `region = ${JSON.stringify(backend.region)}`, - `endpoints = { s3 = ${JSON.stringify(backend.endpoint)} }`, - "use_path_style = true", "use_lockfile = true", "skip_credentials_validation = true", - "skip_region_validation = true", "skip_requesting_account_id = true", "skip_metadata_api_check = true", - "skip_s3_checksum = true", - ]; - writeFileSync(backendFile, `${settings.join("\n")}\n`, { mode: 0o600 }); - args.push(`-backend-config=${backendFile}`); - } - } - const planPath = resolve(local, root, "review.tfplan"); - const manifestPath = resolve(local, root, "review.json"); - const contextHash = createHash("sha256").update(JSON.stringify(Object.entries(config).sort())).digest("hex"); - const hashPlan = () => createHash("sha256").update(readFileSync(planPath)).digest("hex"); - if (command === "plan") { - writeFileSync(manifestPath, JSON.stringify({ status: "planning" }), { mode: 0o600 }); - if (extra.some((arg) => arg.startsWith("-out"))) throw new Error("Plan path is managed by the wrapper"); - args.push(`-out=${planPath}`, "-input=false"); - } - if (command === "apply") { - if (extra.length) throw new Error("Apply takes no additional options; it applies only the saved review plan"); - if (!existsSync(planPath) || !existsSync(manifestPath)) throw new Error("Create and review a plan first"); - const manifest = JSON.parse(readFileSync(manifestPath, "utf8")); - if (!isApplicablePlan(manifest, contextHash, hashPlan())) { - throw new Error("Saved plan is failed, changed, expired or belongs to different inputs; plan again"); - } - args.push(planPath); - } - const result = spawnSync(binary, args, { env, stdio: "inherit" }); - if (result.error) throw new Error("OpenTofu could not start"); - if (command === "plan" && [0, 2].includes(result.status) && existsSync(planPath)) { - writeFileSync(manifestPath, JSON.stringify({ status: "ready", contextHash, planHash: hashPlan(), createdAt: new Date().toISOString() }), { mode: 0o600 }); - } - if (command === "apply") { - writeFileSync(manifestPath, JSON.stringify({ status: "consumed" }), { mode: 0o600 }); - } - process.exitCode = result.status ?? 1; -} catch (error) { - // No child-process output or credentials are incorporated in these operator errors. - console.error(error instanceof Error ? error.message : "Infrastructure command failed"); - process.exitCode = 1; -} From 8dc8c09f088410b3a1f218b6e697602dd2c2e878 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Weberru=C3=9F?= Date: Wed, 30 Sep 2026 08:16:03 +0200 Subject: [PATCH 015/175] docs(configurator): record direct CLI plan and logging checks --- landing-zone-configurator/docs/platform-readiness.md | 3 +++ landing-zone-configurator/infra/README.md | 3 +++ 2 files changed, 6 insertions(+) diff --git a/landing-zone-configurator/docs/platform-readiness.md b/landing-zone-configurator/docs/platform-readiness.md index fc6fb01..bd049d1 100644 --- a/landing-zone-configurator/docs/platform-readiness.md +++ b/landing-zone-configurator/docs/platform-readiness.md @@ -106,3 +106,6 @@ Nächster Ausbau: CF-Space und App-/Task-Runtime per IaC, getrennte Laufzeitiden ## CI-Vereinfachung 2026-09-30 Die Workflows führen OpenTofu jetzt direkt aus. Der bisherige Deployment-Runner, Prozessmanager und automatische Einmal-Recovery-Pfad sind entfernt. Normale Plan-/Apply-Logs erscheinen live; Credential-Outputs und State-Snapshots bleiben gezielt geschützt. GNU `timeout` übernimmt SIGINT und die anschließende Wartezeit; keine eigene Signalsteuerung. Die geltenden Zeitlimits und Befehle stehen in der [CI-Betriebsanleitung](../infra/ci/README.md). Das historische Recovery-Inventar liegt unter `docs/incidents`, ohne aktive Ausführung. + + +Abnahme der direkten CLI-Pipelines: Bootstrap-Run 36677111141 und Plattform-Run 36677111032 erfolgreich, jeweils `No changes`, Apply übersprungen. Die normalen Init-/Plan-Details sind live sichtbar. Logs gegen bekannte Deployment-Credentials und State-Schlüssel geprüft, keine Treffer. Validierung 36677111055 erfolgreich, einschließlich des GNU-Timeout-Tests in allen fünf Linux-Matrix-Jobs. Keine Infrastrukturänderung durch den Umbau. diff --git a/landing-zone-configurator/infra/README.md b/landing-zone-configurator/infra/README.md index 74df298..37040aa 100644 --- a/landing-zone-configurator/infra/README.md +++ b/landing-zone-configurator/infra/README.md @@ -78,3 +78,6 @@ Verschlüsselte Seed-Sicherungen liegen im Verwaltungs-Bucket unter `recovery/se Bei einem fehlgeschlagenen Apply zuerst CI-Logs, verschlüsselte Recovery-Artefakte und tatsächlichen Ressourcenbestand prüfen. Keinen alten Plan blind wiederholen, keinen existierenden State überschreiben. Imports und Credential-Rotation müssen zum konkreten Vorfall passen und unter derselben Single-Writer-Regel erfolgen. Der frühere automatische Einmal-Recovery-Pfad wurde nach Abschluss entfernt. Offen sind CF-Runtime/Bindings und Verbindungstests aus CF, eingeschränkte App-Rollen/RLS, automatisierte Credential-Rotation sowie ein vollständiger getesteter Betriebs-/Restore-Prozess. [Planung](../docs/planning.md). + + +Direkte CLI-Pipelines geprüft (2026-09-30): [Bootstrap 36677111141](https://github.com/stackitcloud/stackit-landing-zone/actions/runs/36677111141) und [Plattform 36677111032](https://github.com/stackitcloud/stackit-landing-zone/actions/runs/36677111032) zeigen jeweils `No changes`, mit sichtbaren nativen Init-/Plan-Logs und übersprungenem Apply. [Validierung 36677111055](https://github.com/stackitcloud/stackit-landing-zone/actions/runs/36677111055) bestätigt auch den GNU-Timeout-Test auf Linux. Aktuelle weitere Abnahmen stehen im [Plattform-Betriebsstand](../docs/platform-readiness.md). From 950f5174a7f6f49c3152425b581cacad6ab57d4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Weberru=C3=9F?= Date: Wed, 30 Sep 2026 08:18:04 +0200 Subject: [PATCH 016/175] docs(configurator): confirm direct CLI checks for all deployment roots --- landing-zone-configurator/docs/planning.md | 3 +++ landing-zone-configurator/docs/platform-readiness.md | 3 +++ 2 files changed, 6 insertions(+) diff --git a/landing-zone-configurator/docs/planning.md b/landing-zone-configurator/docs/planning.md index 06665a1..120c065 100644 --- a/landing-zone-configurator/docs/planning.md +++ b/landing-zone-configurator/docs/planning.md @@ -614,3 +614,6 @@ No-op-Abnahme abgeschlossen: Plattform-Run 36675895279 zeigt für alle neun Ress CI-Entscheidung 2026-09-30: Auf Benutzerwunsch den eigenen JavaScript-Deployment-Runner durch direkte, sichtbare OpenTofu-Schritte in GitHub Actions ersetzen. Kleine Hilfsprogramme bereiten ausschließlich Credentials vor, prüfen Planintegrität und schützen State-Snapshots. Normale CLI-Logs werden wieder live ausgegeben; JSON-Credential-/State-Ausgaben bleiben privat. Abgeschlossene Recovery vom regulären Ablauf trennen. Auch lokale Validierung und Seed-Bedienung verwenden direkte `tofu`-Befehle. [Aktuelle Betriebsanleitung](../infra/ci/README.md). + + +CI-Vereinfachung abgenommen: echte direkte OpenTofu-Pläne für Bootstrap (36677111141), Plattform (36677111032) und Backend (36677367229) jeweils ohne Änderungen; Apply jeweils übersprungen. Native Logs sichtbar und gegen bekannte Credentials geprüft. Validierung einschließlich Linux-Timeout-Test erfolgreich. Temporäre Backend-Auswahl entfernt; Feature-Branch bleibt getrennt von main. diff --git a/landing-zone-configurator/docs/platform-readiness.md b/landing-zone-configurator/docs/platform-readiness.md index bd049d1..c44a919 100644 --- a/landing-zone-configurator/docs/platform-readiness.md +++ b/landing-zone-configurator/docs/platform-readiness.md @@ -109,3 +109,6 @@ Die Workflows führen OpenTofu jetzt direkt aus. Der bisherige Deployment-Runner Abnahme der direkten CLI-Pipelines: Bootstrap-Run 36677111141 und Plattform-Run 36677111032 erfolgreich, jeweils `No changes`, Apply übersprungen. Die normalen Init-/Plan-Details sind live sichtbar. Logs gegen bekannte Deployment-Credentials und State-Schlüssel geprüft, keine Treffer. Validierung 36677111055 erfolgreich, einschließlich des GNU-Timeout-Tests in allen fünf Linux-Matrix-Jobs. Keine Infrastrukturänderung durch den Umbau. + + +Backend-Abnahme des Umbaus: [Run 36677367229](https://github.com/stackitcloud/stackit-landing-zone/actions/runs/36677367229) hat Root `backend` mit getrennten Management-/Workload-Zugängen geplant: `No changes`, Apply übersprungen. Root, Commit, Run, Plan-Hash und verschlüsseltes Artefakt geprüft. Native Logs sichtbar und ohne bekannte Credential-Werte. [Validierung 36677367252](https://github.com/stackitcloud/stackit-landing-zone/actions/runs/36677367252) erfolgreich. Temporäre Root-Auswahl entfernt; keine Apply-Freigaben gesetzt. Bootstrap, Backend und Plattform wurden damit lesend geprüft, ohne Cloud-Apply oder State-Migration durch den Umbau. From 02516d7e737de2be450b44a4ef01561322325db2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Weberru=C3=9F?= Date: Wed, 30 Sep 2026 09:00:09 +0200 Subject: [PATCH 017/175] feat(configurator): provision CF space and runtime identities via OpenTofu --- .github/workflows/configurator-runtime.yml | 235 ++++++++++++++++++ .github/workflows/validate-configurator.yml | 2 +- landing-zone-configurator/.gitignore | 2 + .../infra/ci/configure-github.mjs | 10 +- .../infra/ci/context.mjs | 6 +- .../infra/ci/context.test.mjs | 9 + .../infra/ci/github-runtime-environments.json | 9 + .../infra/ci/prepare.mjs | 25 +- .../infra/ci/prepare.test.mjs | 13 + .../infra/ci/protect-state.mjs | 2 +- landing-zone-configurator/infra/ci/review.mjs | 2 +- .../infra/prepare-local.mjs | 2 +- .../infra/runtime/.terraform.lock.hcl | 70 ++++++ .../infra/runtime/README.md | 23 ++ .../infra/runtime/main.tf | 41 +++ .../infra/runtime/outputs.tf | 22 ++ .../infra/runtime/terraform.tf | 30 +++ .../infra/runtime/tests/runtime.tftest.hcl | 30 +++ .../infra/runtime/variables.tf | 26 ++ 19 files changed, 542 insertions(+), 17 deletions(-) create mode 100644 .github/workflows/configurator-runtime.yml create mode 100644 landing-zone-configurator/infra/ci/github-runtime-environments.json create mode 100644 landing-zone-configurator/infra/runtime/.terraform.lock.hcl create mode 100644 landing-zone-configurator/infra/runtime/README.md create mode 100644 landing-zone-configurator/infra/runtime/main.tf create mode 100644 landing-zone-configurator/infra/runtime/outputs.tf create mode 100644 landing-zone-configurator/infra/runtime/terraform.tf create mode 100644 landing-zone-configurator/infra/runtime/tests/runtime.tftest.hcl create mode 100644 landing-zone-configurator/infra/runtime/variables.tf diff --git a/.github/workflows/configurator-runtime.yml b/.github/workflows/configurator-runtime.yml new file mode 100644 index 0000000..ce9d4bf --- /dev/null +++ b/.github/workflows/configurator-runtime.yml @@ -0,0 +1,235 @@ +name: Configurator Runtime + +on: + push: + branches: [feature/landing-zone-configurator] + paths: + - 'landing-zone-configurator/infra/runtime/**' + - 'landing-zone-configurator/infra/environments/lzc-dev.tfvars.json' + - 'landing-zone-configurator/infra/ci/**' + - '.github/workflows/configurator-runtime.yml' + workflow_dispatch: + inputs: + root: + description: 'CF space and application identities' + type: choice + options: [runtime] + default: runtime + apply: + description: 'Apply the reviewed plan after environment approval' + type: boolean + default: false + +permissions: + contents: read + +concurrency: + group: configurator-lzc-dev-mutation + cancel-in-progress: false + +env: + LZC_ENVIRONMENT: lzc-dev + LZC_ROOT: runtime + +jobs: + plan: + if: vars.LZC_RUNTIME_CI_ENABLED == 'true' && github.run_attempt == 1 && ((github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main') || (github.event_name == 'push' && github.ref == 'refs/heads/feature/landing-zone-configurator')) + runs-on: ubuntu-latest + timeout-minutes: 30 + environment: lzc-dev-runtime-plan + defaults: + run: + working-directory: landing-zone-configurator + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: '24.21.0' + - uses: opentofu/setup-opentofu@a1320f892987e89d278cc92dc5adc984fb93aca4 # v2.0.2 + with: + tofu_version: '1.12.6' + tofu_wrapper: false + - name: Prepare credentials and backend configuration + id: prepare + run: node infra/ci/prepare.mjs management + env: + LZC_PROJECT_ID: ${{ vars.LZC_PROJECT_ID }} + LZC_REGION: ${{ vars.LZC_REGION }} + LZC_NAME_PREFIX: ${{ vars.LZC_NAME_PREFIX }} + LZC_MANAGEMENT_BUCKET: ${{ vars.LZC_MANAGEMENT_BUCKET }} + LZC_CREDENTIAL_EXPIRATION: ${{ vars.LZC_CREDENTIAL_EXPIRATION }} + LZC_MANAGEMENT_ACCESS_KEY: ${{ secrets.LZC_MANAGEMENT_ACCESS_KEY }} + LZC_MANAGEMENT_SECRET_KEY: ${{ secrets.LZC_MANAGEMENT_SECRET_KEY }} + LZC_SERVICE_ACCOUNT_KEY: ${{ secrets.LZC_SERVICE_ACCOUNT_KEY }} + LZC_STATE_KEY_BOOTSTRAP: ${{ secrets.LZC_STATE_KEY_BOOTSTRAP }} + LZC_STATE_KEY_BACKEND: ${{ secrets.LZC_STATE_KEY_BACKEND }} + LZC_STATE_KEY_PLATFORM: ${{ secrets.LZC_STATE_KEY_PLATFORM }} + LZC_STATE_KEY_RUNTIME: ${{ secrets.LZC_STATE_KEY_RUNTIME }} + - name: Read bootstrap backend outputs (private file) + if: env.LZC_ROOT != 'bootstrap' + timeout-minutes: 3 + run: | + umask 077 + export TF_DATA_DIR="$LZC_PRIVATE/bootstrap-data" + export TF_ENCRYPTION="$(cat "$LZC_PRIVATE/bootstrap.encryption.json")" + tofu -chdir=infra/bootstrap init -input=false -no-color -lockfile=readonly -backend-config="$LZC_PRIVATE/bootstrap.backend.hcl" + tofu -chdir=infra/bootstrap output -json > "$LZC_PRIVATE/bootstrap-outputs.json" + - name: Read bucket versioning outputs (private file) + timeout-minutes: 3 + run: | + umask 077 + export TF_DATA_DIR="$LZC_PRIVATE/backend-data" + export TF_ENCRYPTION="$(cat "$LZC_PRIVATE/backend.encryption.json")" + tofu -chdir=infra/backend init -input=false -no-color -lockfile=readonly -backend-config="$LZC_PRIVATE/backend.backend.hcl" + tofu -chdir=infra/backend output -json > "$LZC_PRIVATE/backend-outputs.json" + - name: Prepare workload credentials + if: env.LZC_ROOT != 'bootstrap' + run: node infra/ci/prepare.mjs workload + - name: Read platform outputs (private file) + timeout-minutes: 3 + run: | + umask 077 + export TF_DATA_DIR="$LZC_PRIVATE/platform-data" + export TF_ENCRYPTION="$(cat "$LZC_PRIVATE/platform.encryption.json")" + tofu -chdir=infra/platform init -input=false -no-color -lockfile=readonly -backend-config="$LZC_PRIVATE/platform.backend.hcl" + tofu -chdir=infra/platform output -json > "$LZC_PRIVATE/platform-outputs.json" + - name: Prepare CF runtime inputs + run: node infra/ci/prepare.mjs runtime + - name: OpenTofu init + timeout-minutes: 3 + run: tofu -chdir="infra/$LZC_ROOT" init -input=false -no-color -lockfile=readonly -backend-config="$LZC_PRIVATE/$LZC_ROOT.backend.hcl" + - name: OpenTofu validate + run: tofu -chdir="infra/$LZC_ROOT" validate -no-color + - name: OpenTofu plan + run: >- + timeout --foreground --signal=INT --kill-after=2m 15m + tofu -chdir="infra/$LZC_ROOT" plan + -input=false -no-color -lock-timeout=60s + -out="$LZC_PLAN" + - name: Bind encrypted plan to commit and workflow run + run: node infra/ci/review.mjs seal + - name: Upload encrypted plan + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: runtime-plan-${{ github.run_id }} + path: | + landing-zone-configurator/.local/ci-plan/review.tfplan + landing-zone-configurator/.local/ci-plan/review.json + include-hidden-files: true + if-no-files-found: error + retention-days: 1 + - name: Remove temporary credentials + if: always() + run: rm -rf -- "$RUNNER_TEMP/lzc-private" + + apply: + needs: plan + if: github.run_attempt == 1 && ((github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' && inputs.apply) || (github.event_name == 'push' && github.ref == 'refs/heads/feature/landing-zone-configurator' && github.sha == vars.LZC_RUNTIME_APPLY_COMMIT)) + runs-on: ubuntu-latest + timeout-minutes: 100 + environment: lzc-dev-runtime-apply + defaults: + run: + working-directory: landing-zone-configurator + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: '24.21.0' + - uses: opentofu/setup-opentofu@a1320f892987e89d278cc92dc5adc984fb93aca4 # v2.0.2 + with: + tofu_version: '1.12.6' + tofu_wrapper: false + - name: Prepare credentials and backend configuration + id: prepare + run: node infra/ci/prepare.mjs management + env: + LZC_PROJECT_ID: ${{ vars.LZC_PROJECT_ID }} + LZC_REGION: ${{ vars.LZC_REGION }} + LZC_NAME_PREFIX: ${{ vars.LZC_NAME_PREFIX }} + LZC_MANAGEMENT_BUCKET: ${{ vars.LZC_MANAGEMENT_BUCKET }} + LZC_CREDENTIAL_EXPIRATION: ${{ vars.LZC_CREDENTIAL_EXPIRATION }} + LZC_MANAGEMENT_ACCESS_KEY: ${{ secrets.LZC_MANAGEMENT_ACCESS_KEY }} + LZC_MANAGEMENT_SECRET_KEY: ${{ secrets.LZC_MANAGEMENT_SECRET_KEY }} + LZC_SERVICE_ACCOUNT_KEY: ${{ secrets.LZC_SERVICE_ACCOUNT_KEY }} + LZC_STATE_KEY_BOOTSTRAP: ${{ secrets.LZC_STATE_KEY_BOOTSTRAP }} + LZC_STATE_KEY_BACKEND: ${{ secrets.LZC_STATE_KEY_BACKEND }} + LZC_STATE_KEY_PLATFORM: ${{ secrets.LZC_STATE_KEY_PLATFORM }} + LZC_STATE_KEY_RUNTIME: ${{ secrets.LZC_STATE_KEY_RUNTIME }} + - name: Read bootstrap backend outputs (private file) + if: env.LZC_ROOT != 'bootstrap' + timeout-minutes: 3 + run: | + umask 077 + export TF_DATA_DIR="$LZC_PRIVATE/bootstrap-data" + export TF_ENCRYPTION="$(cat "$LZC_PRIVATE/bootstrap.encryption.json")" + tofu -chdir=infra/bootstrap init -input=false -no-color -lockfile=readonly -backend-config="$LZC_PRIVATE/bootstrap.backend.hcl" + tofu -chdir=infra/bootstrap output -json > "$LZC_PRIVATE/bootstrap-outputs.json" + - name: Read bucket versioning outputs (private file) + timeout-minutes: 3 + run: | + umask 077 + export TF_DATA_DIR="$LZC_PRIVATE/backend-data" + export TF_ENCRYPTION="$(cat "$LZC_PRIVATE/backend.encryption.json")" + tofu -chdir=infra/backend init -input=false -no-color -lockfile=readonly -backend-config="$LZC_PRIVATE/backend.backend.hcl" + tofu -chdir=infra/backend output -json > "$LZC_PRIVATE/backend-outputs.json" + - name: Prepare workload credentials + if: env.LZC_ROOT != 'bootstrap' + run: node infra/ci/prepare.mjs workload + - name: Read platform outputs (private file) + timeout-minutes: 3 + run: | + umask 077 + export TF_DATA_DIR="$LZC_PRIVATE/platform-data" + export TF_ENCRYPTION="$(cat "$LZC_PRIVATE/platform.encryption.json")" + tofu -chdir=infra/platform init -input=false -no-color -lockfile=readonly -backend-config="$LZC_PRIVATE/platform.backend.hcl" + tofu -chdir=infra/platform output -json > "$LZC_PRIVATE/platform-outputs.json" + - name: Prepare CF runtime inputs + run: node infra/ci/prepare.mjs runtime + - name: OpenTofu init + timeout-minutes: 3 + run: tofu -chdir="infra/$LZC_ROOT" init -input=false -no-color -lockfile=readonly -backend-config="$LZC_PRIVATE/$LZC_ROOT.backend.hcl" + - name: OpenTofu validate + run: tofu -chdir="infra/$LZC_ROOT" validate -no-color + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: runtime-plan-${{ github.run_id }} + path: landing-zone-configurator/.local/ci-plan + - name: Verify reviewed plan and apply authorization + run: node infra/ci/review.mjs verify + env: + LZC_APPROVED_APPLY_COMMIT: ${{ vars.LZC_RUNTIME_APPLY_COMMIT }} + LZC_APPROVED_APPLY_ROOT: runtime + - name: OpenTofu apply reviewed plan + id: apply + # One SIGINT allows OpenTofu to save state before the outer job deadline. + run: >- + timeout --foreground --signal=INT --kill-after=10m 70m + tofu -chdir="infra/$LZC_ROOT" apply + -input=false -no-color -lock-timeout=60s "$LZC_PLAN" + - name: Capture and encrypt recovery state + if: always() && steps.apply.outcome != '' && steps.apply.outcome != 'skipped' + run: | + umask 077 + if ! timeout --foreground --signal=INT --kill-after=10s 60s \ + tofu -chdir="infra/$LZC_ROOT" state pull > "$LZC_PRIVATE/state.snapshot"; then + echo "::warning::Remote state snapshot unavailable; checking for emergency state." + rm -f -- "$LZC_PRIVATE/state.snapshot" + fi + node infra/ci/protect-state.mjs + - name: Upload encrypted recovery state + if: always() && steps.apply.outcome != '' && steps.apply.outcome != 'skipped' + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: runtime-state-${{ github.run_id }} + path: landing-zone-configurator/.local/ci-recovery/*.enc.json + include-hidden-files: true + if-no-files-found: warn + retention-days: 7 + - name: Remove temporary credentials + if: always() + run: rm -rf -- "$RUNNER_TEMP/lzc-private" diff --git a/.github/workflows/validate-configurator.yml b/.github/workflows/validate-configurator.yml index e9200d0..c57f7c5 100644 --- a/.github/workflows/validate-configurator.yml +++ b/.github/workflows/validate-configurator.yml @@ -54,7 +54,7 @@ jobs: timeout-minutes: 20 strategy: matrix: - root: [seed, seed-protection, bootstrap, backend, platform] + root: [seed, seed-protection, bootstrap, backend, platform, runtime] defaults: run: working-directory: landing-zone-configurator diff --git a/landing-zone-configurator/.gitignore b/landing-zone-configurator/.gitignore index 2832a8d..8678ab5 100644 --- a/landing-zone-configurator/.gitignore +++ b/landing-zone-configurator/.gitignore @@ -18,3 +18,5 @@ coverage/ !infra/seed/.terraform.lock.hcl !infra/seed-protection/.terraform.lock.hcl + +!infra/runtime/.terraform.lock.hcl diff --git a/landing-zone-configurator/infra/ci/configure-github.mjs b/landing-zone-configurator/infra/ci/configure-github.mjs index a335219..7a4c8da 100644 --- a/landing-zone-configurator/infra/ci/configure-github.mjs +++ b/landing-zone-configurator/infra/ci/configure-github.mjs @@ -8,9 +8,10 @@ const here=dirname(fileURLToPath(import.meta.url)); const infra=resolve(here,".."); const local=resolve(infra,"../.local"); const repoPath=resolve(infra,"../.."); -const platform = process.argv[2] === "--platform"; -if(process.argv.length > 2 && !platform) throw new Error("Only --platform is supported"); -const spec=JSON.parse(readFileSync(resolve(here,platform ? "github-platform-environments.json" : "github-environments.json"),"utf8")); +const runtime = process.argv[2] === "--runtime"; +const platform = process.argv[2] === "--platform" || runtime; +if(process.argv.length > 2 && !platform) throw new Error("Only --platform or --runtime is supported"); +const spec=JSON.parse(readFileSync(resolve(here,runtime ? "github-runtime-environments.json" : platform ? "github-platform-environments.json" : "github-environments.json"),"utf8")); if(spec.repository!=="stackitcloud/stackit-landing-zone")throw new Error("Unexpected secret destination"); function gh(args,input) { try {return execFileSync("gh",args,{input,encoding:"utf8",stdio:["pipe","pipe","pipe"]});} @@ -35,6 +36,7 @@ const deploySecrets={...sharedSecrets, LZC_STATE_KEY_BACKEND:readFileSync(resolve(local,"backend/state.passphrase"),"utf8").trim(), }; if(platform) deploySecrets.LZC_STATE_KEY_PLATFORM=readFileSync(resolve(local,"platform/state.passphrase"),"utf8").trim(); +if(runtime) deploySecrets.LZC_STATE_KEY_RUNTIME=readFileSync(resolve(local,"runtime/state.passphrase"),"utf8").trim(); const recoverySecrets={...sharedSecrets, LZC_STATE_KEY_SEED:readFileSync(resolve(local,"seed/state.passphrase"),"utf8").trim(), LZC_STATE_KEY_SEED_PROTECTION:readFileSync(resolve(local,"seed-protection/state.passphrase"),"utf8").trim(), @@ -63,4 +65,4 @@ for(const target of spec.environments) { console.log(`Configured ${target.name}: branches=${branches.join(",")}, review=${target.review}; secret values not displayed.`); } -if(platform) gh(["variable","set","LZC_PLATFORM_CI_ENABLED","--repo",spec.repository],"true"); +if(platform) gh(["variable","set",runtime ? "LZC_RUNTIME_CI_ENABLED" : "LZC_PLATFORM_CI_ENABLED","--repo",spec.repository],"true"); diff --git a/landing-zone-configurator/infra/ci/context.mjs b/landing-zone-configurator/infra/ci/context.mjs index 711fd3a..586da4b 100644 --- a/landing-zone-configurator/infra/ci/context.mjs +++ b/landing-zone-configurator/infra/ci/context.mjs @@ -7,7 +7,7 @@ export function deploymentInputs(env) { }; if (required("GITHUB_REPOSITORY") !== "stackitcloud/stackit-landing-zone") throw new Error("Unexpected repository"); const root = required("LZC_ROOT"); - if (!["bootstrap", "backend", "platform"].includes(root)) throw new Error("Unsupported CI root"); + if (!["bootstrap", "backend", "platform", "runtime"].includes(root)) throw new Error("Unsupported CI root"); if (required("LZC_ENVIRONMENT") !== "lzc-dev") throw new Error("Unsupported environment"); const ref = required("GITHUB_REF"); const event = required("GITHUB_EVENT_NAME"); @@ -41,8 +41,8 @@ export function assertApplyExecution(safety, env) { } const main = env.GITHUB_REF === "refs/heads/main" && env.GITHUB_EVENT_NAME === "workflow_dispatch"; const feature = env.GITHUB_REF === "refs/heads/feature/landing-zone-configurator" && env.GITHUB_EVENT_NAME === "push" && - /^[a-f0-9]{40}$/.test(env.LZC_APPROVED_APPLY_COMMIT ?? "") && env.GITHUB_SHA === env.LZC_APPROVED_APPLY_COMMIT && ["bootstrap", "backend", "platform"].includes(env.LZC_ROOT) && env.LZC_ROOT === env.LZC_APPROVED_APPLY_ROOT; - const workflowName = env.LZC_ROOT === "platform" ? "configurator-platform.yml" : "configurator-bootstrap.yml"; + /^[a-f0-9]{40}$/.test(env.LZC_APPROVED_APPLY_COMMIT ?? "") && env.GITHUB_SHA === env.LZC_APPROVED_APPLY_COMMIT && ["bootstrap", "backend", "platform", "runtime"].includes(env.LZC_ROOT) && env.LZC_ROOT === env.LZC_APPROVED_APPLY_ROOT; + const workflowName = env.LZC_ROOT === "runtime" ? "configurator-runtime.yml" : env.LZC_ROOT === "platform" ? "configurator-platform.yml" : "configurator-bootstrap.yml"; const workflow = `stackitcloud/stackit-landing-zone/.github/workflows/${workflowName}@${env.GITHUB_REF}`; if (env.GITHUB_ACTIONS !== "true" || (!main && !feature) || env.GITHUB_WORKFLOW_REF !== workflow) { throw new Error("Remote apply is restricted to the serialized GitHub Actions workflow"); diff --git a/landing-zone-configurator/infra/ci/context.test.mjs b/landing-zone-configurator/infra/ci/context.test.mjs index 1733c76..76c27f9 100644 --- a/landing-zone-configurator/infra/ci/context.test.mjs +++ b/landing-zone-configurator/infra/ci/context.test.mjs @@ -72,3 +72,12 @@ test("platform apply is isolated from bootstrap workflow",()=>{ assert.throws(()=>assertApplyExecution(safety,{...platform,GITHUB_WORKFLOW_REF:"stackitcloud/stackit-landing-zone/.github/workflows/configurator-bootstrap.yml@refs/heads/feature/landing-zone-configurator"})); assert.throws(()=>assertApplyExecution(safety,{...platform,LZC_APPROVED_APPLY_ROOT:"bootstrap"})); }); + +test("runtime apply requires its own workflow and root approval",()=>{ + const safety={mode:"github-actions-single-writer",concurrencyGroup:"configurator-lzc-dev-mutation"}; + const runtime={...env,LZC_ROOT:"runtime",GITHUB_ACTIONS:"true",GITHUB_REF:"refs/heads/feature/landing-zone-configurator",GITHUB_EVENT_NAME:"push",LZC_APPROVED_APPLY_COMMIT:env.GITHUB_SHA,LZC_APPROVED_APPLY_ROOT:"runtime",GITHUB_WORKFLOW_REF:"stackitcloud/stackit-landing-zone/.github/workflows/configurator-runtime.yml@refs/heads/feature/landing-zone-configurator"}; + assert.equal(deploymentInputs(runtime).root,"runtime"); + assert.doesNotThrow(()=>assertApplyExecution(safety,runtime)); + assert.throws(()=>assertApplyExecution(safety,{...runtime,LZC_APPROVED_APPLY_ROOT:"platform"})); + assert.throws(()=>assertApplyExecution(safety,{...runtime,GITHUB_WORKFLOW_REF:runtime.GITHUB_WORKFLOW_REF.replace("runtime.yml","platform.yml")})); +}); diff --git a/landing-zone-configurator/infra/ci/github-runtime-environments.json b/landing-zone-configurator/infra/ci/github-runtime-environments.json new file mode 100644 index 0000000..1deb52d --- /dev/null +++ b/landing-zone-configurator/infra/ci/github-runtime-environments.json @@ -0,0 +1,9 @@ +{ + "repository": "stackitcloud/stackit-landing-zone", + "branch": "main", + "reviewer": "lweberru", + "environments": [ + {"name":"lzc-dev-runtime-plan","review":false,"branches":["main","feature/landing-zone-configurator"]}, + {"name":"lzc-dev-runtime-apply","review":true,"branches":["main","feature/landing-zone-configurator"]} + ] +} diff --git a/landing-zone-configurator/infra/ci/prepare.mjs b/landing-zone-configurator/infra/ci/prepare.mjs index 5bfdc93..82add11 100644 --- a/landing-zone-configurator/infra/ci/prepare.mjs +++ b/landing-zone-configurator/infra/ci/prepare.mjs @@ -5,7 +5,7 @@ import { randomBytes, randomUUID } from "node:crypto"; import { deploymentInputs } from "./context.mjs"; const [mode] = process.argv.slice(2); -if (!["management", "workload", "validation"].includes(mode)) throw new Error("Usage: prepare.mjs management|workload|validation"); +if (!["management", "workload", "runtime", "validation"].includes(mode)) throw new Error("Usage: prepare.mjs management|workload|runtime|validation"); process.umask(0o077); const privateDir = resolve(process.env.RUNNER_TEMP, "lzc-private"); mkdirSync(privateDir, { recursive: true, mode: 0o700 }); @@ -36,7 +36,7 @@ function backend(root, bucket, inputs) { } if (mode === "validation") { const root = required("LZC_ROOT"); - if (!["seed", "seed-protection", "bootstrap", "backend", "platform"].includes(root)) throw new Error("Invalid validation root"); + if (!["seed", "seed-protection", "bootstrap", "backend", "platform", "runtime"].includes(root)) throw new Error("Invalid validation root"); publish("TF_ENCRYPTION", encryption(root, randomBytes(48).toString("base64url")), true); publish("TF_DATA_DIR", resolve(privateDir, `${root}-data`)); } else { @@ -48,7 +48,7 @@ if (mode === "validation") { const key = required("LZC_SERVICE_ACCOUNT_KEY"); try { JSON.parse(key); } catch { throw new Error("Invalid service account JSON"); } writeFileSync(resolve(privateDir, "service-account.json"), key, { mode: 0o600 }); - const roots = inputs.root === "platform" ? ["bootstrap", "backend", "platform"] : ["bootstrap", "backend"]; + const roots = inputs.root === "runtime" ? ["bootstrap", "backend", "platform", "runtime"] : inputs.root === "platform" ? ["bootstrap", "backend", "platform"] : ["bootstrap", "backend"]; for (const root of roots) { const config = encryption(root, required(`LZC_STATE_KEY_${root.toUpperCase()}`)); if (root === inputs.root) publish("TF_ENCRYPTION", config, true); @@ -64,15 +64,28 @@ if (mode === "validation") { publish("TF_DATA_DIR", resolve(privateDir, `${inputs.root}-data`)); publish("LZC_PLAN", resolve(".local/ci-plan/review.tfplan")); mkdirSync(resolve(".local/ci-plan"), { recursive: true, mode: 0o700 }); + } else if (mode === "runtime") { + const platform = readJson(resolve(privateDir,"platform-outputs.json")); + const cf = platform.cf_runtime?.value, ids = platform.service_ids?.value; + if(inputs.root !== "runtime" || !cf?.org_id || !cf?.api_url || !cf?.username || !cf?.password || !platform.model_serving_token?.value || !ids?.database_instance_id || !ids?.secrets_instance_id) throw new Error("Platform outputs incomplete"); + publish("CF_API_URL", cf.api_url); + publish("CF_USER", cf.username, true); + publish("CF_PASSWORD", cf.password, true); + publish("TF_VAR_cf_org_id", cf.org_id); + publish("TF_VAR_cf_username", cf.username, true); + publish("TF_VAR_database_instance_id", ids.database_instance_id); + publish("TF_VAR_secrets_instance_id", ids.secrets_instance_id); + publish("TF_VAR_model_serving_token", platform.model_serving_token.value, true); } else { - if (!["backend", "platform"].includes(inputs.root)) throw new Error("Workload inputs not required for this root"); + if (!["backend", "platform", "runtime"].includes(inputs.root)) throw new Error("Workload inputs not required for this root"); const outputs = readJson(resolve(privateDir, "bootstrap-outputs.json")); const storage = outputs.backend?.value, credentials = outputs.backend_credentials?.value; if (storage?.bucket !== `${inputs.prefix}-state-${inputs.project.slice(0,8)}` || storage?.region !== inputs.region || !credentials?.access_key || !credentials?.secret_key) throw new Error("Invalid workload backend outputs"); - if (inputs.root === "platform") { + if (["platform", "runtime"].includes(inputs.root)) { const protection = readJson(resolve(privateDir, "backend-outputs.json")); if (protection.versioning_enabled?.value !== true || protection.state_bucket_name?.value !== storage.bucket) throw new Error("Versioned workload backend is not ready"); - backend("platform", storage.bucket, inputs); + backend(inputs.root, storage.bucket, inputs); + if(inputs.root === "runtime") backend("platform", storage.bucket, inputs); publish("AWS_ACCESS_KEY_ID", credentials.access_key, true); publish("AWS_SECRET_ACCESS_KEY", credentials.secret_key, true); } else { diff --git a/landing-zone-configurator/infra/ci/prepare.test.mjs b/landing-zone-configurator/infra/ci/prepare.test.mjs index d5da12e..5d47655 100644 --- a/landing-zone-configurator/infra/ci/prepare.test.mjs +++ b/landing-zone-configurator/infra/ci/prepare.test.mjs @@ -14,3 +14,16 @@ test("prepare writes private files, keeps credentials out of backend HCL",()=>fi test("platform switches to workload backend only after matching versioning proof",()=>fixture((dir,env,run)=>{assert.equal(run("management").status,0);outputs(dir,false);writeFileSync(env.GITHUB_ENV,"");const failed=run("workload");assert.notEqual(failed.status,0);assert.equal(readFileSync(env.GITHUB_ENV,"utf8"),"");outputs(dir);const r=run("workload");assert.equal(r.status,0,r.stderr);assert.equal(exported(env.GITHUB_ENV).AWS_SECRET_ACCESS_KEY,"workload-secret");assert.match(readFileSync(join(dir,"lzc-private/platform.backend.hcl"),"utf8"),/lzc-dev-state-00000000/);})); test("backend provider gets workload credentials without replacing management S3 credentials",()=>fixture((dir,env,run)=>{assert.equal(run("management",{LZC_ROOT:"backend"}).status,0);outputs(dir);const r=run("workload",{LZC_ROOT:"backend"});assert.equal(r.status,0,r.stderr);const v=exported(env.GITHUB_ENV);assert.equal(v.AWS_SECRET_ACCESS_KEY,"management-secret");assert.equal(v.TF_VAR_storage_secret_key,"workload-secret");})); test("validation does not need deployment credentials or touch existing local state keys",()=>fixture((_dir,env,run)=>{const patch=Object.fromEntries(Object.keys(base).map(k=>[k,""]));const r=run("validation",{...patch,LZC_ROOT:"seed"});assert.equal(r.status,0,r.stderr);const v=exported(env.GITHUB_ENV);assert.ok(JSON.parse(v.TF_ENCRYPTION).key_provider.pbkdf2.state.passphrase.length>=32);assert.equal(v.AWS_ACCESS_KEY_ID,undefined);})); + +test("runtime reads platform credentials privately and targets its own workload state",()=>fixture((dir,env,run)=>{ + const patch={LZC_ROOT:"runtime",LZC_STATE_KEY_RUNTIME:"r".repeat(40)}; + assert.equal(run("management",patch).status,0); outputs(dir); + assert.equal(run("workload",patch).status,0); + for(const root of ["platform","runtime"]) assert.match(readFileSync(join(dir,`lzc-private/${root}.backend.hcl`),"utf8"),/lzc-dev-state-00000000/); + assert.notEqual(run("runtime",patch).status,0); + writeFileSync(join(dir,"lzc-private/platform-outputs.json"),JSON.stringify({cf_runtime:{value:{api_url:"https://example.invalid",org_id:"org",username:"cf-user",password:"cf-secret"}},service_ids:{value:{database_instance_id:"db",secrets_instance_id:"sm"}},model_serving_token:{value:"model-secret"}})); + const result=run("runtime",patch); assert.equal(result.status,0,result.stderr); + const v=exported(env.GITHUB_ENV); + assert.equal(v.CF_PASSWORD,"cf-secret"); assert.equal(v.TF_VAR_database_instance_id,"db"); + assert.equal(JSON.parse(v.TF_ENCRYPTION).key_provider.pbkdf2.state.passphrase,patch.LZC_STATE_KEY_RUNTIME); +})); diff --git a/landing-zone-configurator/infra/ci/protect-state.mjs b/landing-zone-configurator/infra/ci/protect-state.mjs index eeb9286..f6ff7a3 100644 --- a/landing-zone-configurator/infra/ci/protect-state.mjs +++ b/landing-zone-configurator/infra/ci/protect-state.mjs @@ -3,7 +3,7 @@ import { existsSync, mkdirSync, readFileSync, statSync } from "node:fs"; import { resolve } from "node:path"; import { writeProtectedArtifact } from "./artifact.mjs"; const root = process.env.LZC_ROOT; -if (!["bootstrap", "backend", "platform"].includes(root)) throw new Error("Invalid state root"); +if (!["bootstrap", "backend", "platform", "runtime"].includes(root)) throw new Error("Invalid state root"); const privateDir = process.env.LZC_PRIVATE; const key = JSON.parse(readFileSync(resolve(privateDir, `${root}.encryption.json`))).key_provider.pbkdf2.state.passphrase; const destination = resolve(".local/ci-recovery"); diff --git a/landing-zone-configurator/infra/ci/review.mjs b/landing-zone-configurator/infra/ci/review.mjs index 03cff3c..2353cd0 100644 --- a/landing-zone-configurator/infra/ci/review.mjs +++ b/landing-zone-configurator/infra/ci/review.mjs @@ -10,7 +10,7 @@ if (!["seal", "verify"].includes(command)) throw new Error("Usage: review.mjs se const inputs = deploymentInputs(process.env); if (command === "verify") assertApplyExecution(JSON.parse(readFileSync(resolve(infra,"ci/backend-safety.json"))), process.env); const pieces = []; -for (const root of ["bootstrap", "backend", "platform"]) { +for (const root of ["bootstrap", "backend", "platform", "runtime"]) { for (const file of readdirSync(resolve(infra,root)).filter(f => f.endsWith(".tf") || f === ".terraform.lock.hcl").sort()) { pieces.push(`${root}/${file}:${digest(readFileSync(resolve(infra,root,file)))}`); } diff --git a/landing-zone-configurator/infra/prepare-local.mjs b/landing-zone-configurator/infra/prepare-local.mjs index 7ec4c99..ef4740a 100644 --- a/landing-zone-configurator/infra/prepare-local.mjs +++ b/landing-zone-configurator/infra/prepare-local.mjs @@ -6,7 +6,7 @@ import { parseEnv } from "node:util"; import { stateKey } from "./state-key.mjs"; const infra = dirname(fileURLToPath(import.meta.url)), base = resolve(infra,".."), repository = resolve(base,".."); const [root, flag, ...extra] = process.argv.slice(2), validation = flag === "--validation"; -if (!["seed","seed-protection","bootstrap","backend","platform"].includes(root) || (flag && !validation) || extra.length) throw new Error("Usage: prepare-local.mjs ROOT [--validation]"); +if (!["seed","seed-protection","bootstrap","backend","platform","runtime"].includes(root) || (flag && !validation) || extra.length) throw new Error("Usage: prepare-local.mjs ROOT [--validation]"); if (!validation && !["seed","seed-protection"].includes(root)) throw new Error("Remote deployment preparation belongs to the serialized GitHub workflow; use --validation locally"); if (process.env.CI || process.env.GITHUB_ACTIONS) throw new Error("Use infra/ci/prepare.mjs in CI"); process.umask(0o077); diff --git a/landing-zone-configurator/infra/runtime/.terraform.lock.hcl b/landing-zone-configurator/infra/runtime/.terraform.lock.hcl new file mode 100644 index 0000000..e2e9d16 --- /dev/null +++ b/landing-zone-configurator/infra/runtime/.terraform.lock.hcl @@ -0,0 +1,70 @@ +# This file is maintained automatically by "tofu init". +# Manual edits may be lost in future updates. + +provider "registry.opentofu.org/cloudfoundry/cloudfoundry" { + version = "1.17.0" + constraints = "1.17.0" + hashes = [ + "h1:3AyqeWEuozc8oKww+hTdq38n+1RQGd9RjuEtNdFQWJw=", + "h1:4/GsZ4SehnuWmRG7beG23C8g9Anlz4s8qyXGEI9pt6Y=", + "h1:4NMqx1EQiuIP7sQWF9SrHHXCZP5n8DczHDm7WbmTSv4=", + "h1:C11OUz/1n24/P6Z0zys74yGMF8OMTHgIEnTW2j7wqcY=", + "h1:CDYFi6W7h6pZksjMbYPoOBKiocyJo5hfuH8ABNEm4dg=", + "h1:QldyjrujC7fzIBQRxD/vZKUbvILoJymrgt3RllaDsv8=", + "h1:XLMYCCHlANNlD+B6u0yd+fSr8neguE2rvAZC1b3TGGI=", + "h1:e9I09KtZR+MBjXEVYkxcg1sU4vdc+Zah9cD5Bicame0=", + "h1:lhQEJD9zVlTIaIgT5/F3h/MEZUCp9ATXSClhdWYtO1Q=", + "h1:om7aE24aP0wfPDJVuhbpJYj0ztpo3AMrmzhsZ8w4Khw=", + "h1:qGOxPUfCel3tSEKgYGptY8wjYys4MNqLMd/gPRTb4zY=", + "h1:vhQL2X3F1peOBoITtXXfTgFPhFcF4A9TYRaCA+yPIeA=", + "h1:xR8lLvyfdNaQsT9AHzQm3bN09Mh0H1uyrjwOfgCx5Vo=", + "zh:03f4c02011df4c4f5f1d7929b7a43a1e354d37ce808ecda9ed877beee7064928", + "zh:27798aafa6f3ee19e86af0ae9f2b39ba3c80f1a396081d5e5461d76676efaed3", + "zh:31f761d98e1c20a2d19b9149877c78ef70edb925a1362e0d0ff8ef151ea7ab0b", + "zh:3e2f03ea32923ebdc451ada492ca1be0cbc33c0ae1a0c8dbfcad44b43a406ce6", + "zh:4c87e8c0b9c80355fd82793c35c2062d2eea81b53dacf725388dd2ecddaa3bd8", + "zh:518dc37dba7508f4ef48d0d54877be059115a473d145f66ae29607ac05e26cf9", + "zh:51a1fed1721da6cf71a4fbabcfcde68cce27c7ee6d257fc0efbbcd4d2ba5354c", + "zh:84dd689103061da5bf9f4509c1cc20a8718c5b39e5b74443640df82af93452fa", + "zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f", + "zh:ad01a791e65510b6a1cb968929d92069da2cda8d9d0c98f8486d3292fe8ab7ce", + "zh:b8efcdaff87074521a31ba2de6bffb63c2c3a046f4c6dd231b3a854f9eb04da1", + "zh:bd853534e12b66af64bf6a7195f1341fd2b608b3cc7037313186170871b92613", + "zh:ce5be94f93ff0141d66969931d2956dfdeda41adbd1bdf38a87abc506d0c605b", + "zh:e74c04d8ae9a7e745d82f1a4cc1fc95e2e198fc12303f36c9b4d0ad5bd9c0c8a", + ] +} + +provider "registry.opentofu.org/stackitcloud/stackit" { + version = "0.114.0" + constraints = "0.114.0" + hashes = [ + "h1:1LWQQnyh7olj5+Ceu33qfZrtvwV6oB4OatOT01QhHBQ=", + "h1:3WbnQ683RK3lVPMZ8Kt+hkJAgVVXYUkoetlhZ74/o8A=", + "h1:6wvA9Zh+oe/0dpCyjEakLPeq1MX2Kd84QFQz/o25xtU=", + "h1:BrXRBNOvpOOTIoJitmN4wD6utYcxVcfzk4/zJzKW15A=", + "h1:MTREOQy9s4S+guQpHVAQwZjk9oudo+57vbt7LSvG6jk=", + "h1:Oi6VgqQ3zQVeFjnksXYno9JOyQyYDfulBNCdEgDeimM=", + "h1:Pw89rX8OcLNY06pMMsdZ48ATRHRdc14Hqr7Gz70jfRo=", + "h1:ShJfA+qiqn/Hh4/alQH90yeJiGipB/4CnLiFfkDsJqM=", + "h1:UF2Zbvh5weaMRKhQql1VT7OvfgAllgad7EdhSIwN/pM=", + "h1:ao60KSPxE34NBuNZDYg78p4/ZxXhmbnlh8tMBsu2Omg=", + "h1:g14AMgsC94E7L7RTo0tK6hzcBepik/vcEw1ixONnMHo=", + "h1:oGShI7JYRcemHmAvOp+fwLQCtsrDv9KNBud/6O8JqrU=", + "h1:oQdvYsXY32mfOZv+kJ9uFSorpTj1IWrJeGyYIZcFOf0=", + "zh:096d6a1d716f124e9a7576318ffb2128ae0ac29e7c1f13b1b3c7723b79f760de", + "zh:0dde99e7b343fa01f8eefc378171fb8621bedb20f59157d6cc8e3d46c738105f", + "zh:1d48af034c791b5565a86c4905abd309152bf50c2c67c44e4943a750a3b0b060", + "zh:1f5703f9616cde7a19ae0e4aa987d02d3d04f3c1b5e593de5d3e8c8043b9da9b", + "zh:1f984dd23d3933734238f1e8b3e1ad2cab20c5e58010b62861ca64f9058b220f", + "zh:338244fafd34188194789bd0d0f84702b12c9086901b7e6bc740cc2415a82c95", + "zh:3b52f81ac36371435a9fde9ff4b437d7b4aee0b692081c490bf7aa4fdf4197b6", + "zh:45f281c29dc783023a1b8deb054bc9dc8f559ed450471005afb4b6d44e68ddc8", + "zh:70d41f68d8a522aa10aab708c425e4c7ce158ced023a9a4dd32cfd4775206c1c", + "zh:a1c7d0e7f9f91d95241ba5ecc6b17ff0a0cd90fe2b2343f5d515b551a97341fa", + "zh:ae292b2f3fc479755f3fcd730bdf5f05c283be788a9daba33012439c0084ce4d", + "zh:b41b5b7a899735d2e516eb76a9b2d67cc46b01fa3fc0392b60ca1448184bf470", + "zh:d01cc2b743081d0c1085e0a134c1d011368305f2ba2219167a0e29ff1b66397b", + "zh:fe4e441850590c04cfaf21398020d0460befd490e1cc7aa791a13ceace80fcbf", + ] +} diff --git a/landing-zone-configurator/infra/runtime/README.md b/landing-zone-configurator/infra/runtime/README.md new file mode 100644 index 0000000..9c08f2e --- /dev/null +++ b/landing-zone-configurator/infra/runtime/README.md @@ -0,0 +1,23 @@ +# Configurator runtime infrastructure + +This root creates the `configurator` space in the existing `lzc-dev` CF organization, +assigns the deployment identity the space developer role, and creates separate +PostgreSQL and read-only Secrets Manager application users. The platform's database +migration and Secrets provisioning credentials are not application credentials. + +Apply runs only in `.github/workflows/configurator-runtime.yml`, with visible +`tofu init`, `tofu plan` and `tofu apply` steps. State is encrypted with its own +key at `configurator/lzc-dev/runtime/terraform.tfstate` in the workload bucket. +The same workflow-level `configurator-lzc-dev-mutation` concurrency group serializes +all Configurator mutations. No local remote-state applies are supported. + +Platform outputs supply CF authentication and existing service IDs through private +files. The sensitive `app_environment` output is intended only for the backend CF +application; never expose it in logs, frontend builds, or unencrypted artifacts. +The app has no database schema grants yet and Secrets access is read-only for this +connectivity milestone. Tenant authorization and credential write operations need +a separate application implementation before onboarding users. + +User authorization (2026-09-30): necessary Configurator deployment applies are +approved until the MVP milestone. Saved plans are still reviewed for scope and +GitHub Environment approval is retained. Changes stay on the feature branch. diff --git a/landing-zone-configurator/infra/runtime/main.tf b/landing-zone-configurator/infra/runtime/main.tf new file mode 100644 index 0000000..e55aa81 --- /dev/null +++ b/landing-zone-configurator/infra/runtime/main.tf @@ -0,0 +1,41 @@ +provider "stackit" { + default_region = var.region +} +# Credentials come from the private platform outputs, through CF_* environment variables. +provider "cloudfoundry" {} + +resource "cloudfoundry_space" "configurator" { + name = "configurator" + org = var.cf_org_id + allow_ssh = false + lifecycle { + prevent_destroy = true + } +} +resource "cloudfoundry_space_role" "deployer" { + space = cloudfoundry_space.configurator.id + username = var.cf_username + type = "space_developer" +} +resource "stackit_postgresflex_user" "application" { + project_id = var.project_id + instance_id = var.database_instance_id + username = "configurator_app" + roles = ["login"] + rotate_when_changed = { + generation = var.credential_generation + } +} +resource "stackit_secretsmanager_user" "application" { + project_id = var.project_id + instance_id = var.secrets_instance_id + description = "Configurator runtime read-only access" + write_enabled = false + rotate_when_changed = { + generation = var.credential_generation + } +} +data "stackit_postgresflex_instance" "configurator" { + project_id = var.project_id + instance_id = var.database_instance_id +} diff --git a/landing-zone-configurator/infra/runtime/outputs.tf b/landing-zone-configurator/infra/runtime/outputs.tf new file mode 100644 index 0000000..4aa5b45 --- /dev/null +++ b/landing-zone-configurator/infra/runtime/outputs.tf @@ -0,0 +1,22 @@ +output "space" { + value = { + id = cloudfoundry_space.configurator.id + name = cloudfoundry_space.configurator.name + } +} +output "app_environment" { + description = "Backend-only runtime credentials; never print or include in frontend assets." + sensitive = true + value = { + LZC_DATABASE_HOST = data.stackit_postgresflex_instance.configurator.connection_info.write.host + LZC_DATABASE_PORT = tostring(data.stackit_postgresflex_instance.configurator.connection_info.write.port) + LZC_DATABASE_NAME = "configurator" + LZC_DATABASE_USER = stackit_postgresflex_user.application.username + LZC_DATABASE_PASSWORD = stackit_postgresflex_user.application.password + LZC_SECRETS_ADDRESS = "https://prod.sm.${var.region}.stackit.cloud" + LZC_SECRETS_INSTANCE_ID = var.secrets_instance_id + LZC_SECRETS_USERNAME = stackit_secretsmanager_user.application.username + LZC_SECRETS_PASSWORD = stackit_secretsmanager_user.application.password + LZC_MODEL_SERVING_TOKEN = var.model_serving_token + } +} diff --git a/landing-zone-configurator/infra/runtime/terraform.tf b/landing-zone-configurator/infra/runtime/terraform.tf new file mode 100644 index 0000000..2d6fa76 --- /dev/null +++ b/landing-zone-configurator/infra/runtime/terraform.tf @@ -0,0 +1,30 @@ +terraform { + required_version = "= 1.12.6" + required_providers { + cloudfoundry = { + source = "cloudfoundry/cloudfoundry" + version = "1.17.0" + } + stackit = { + source = "stackitcloud/stackit" + version = "0.114.0" + } + } + backend "s3" {} + + encryption { + # CI supplies the passphrase via TF_ENCRYPTION, never a tfvars file. + key_provider "pbkdf2" "state" {} + method "aes_gcm" "state" { + keys = key_provider.pbkdf2.state + } + state { + method = method.aes_gcm.state + enforced = true + } + plan { + method = method.aes_gcm.state + enforced = true + } + } +} diff --git a/landing-zone-configurator/infra/runtime/tests/runtime.tftest.hcl b/landing-zone-configurator/infra/runtime/tests/runtime.tftest.hcl new file mode 100644 index 0000000..bfe1042 --- /dev/null +++ b/landing-zone-configurator/infra/runtime/tests/runtime.tftest.hcl @@ -0,0 +1,30 @@ +mock_provider "stackit" {} +mock_provider "cloudfoundry" { + mock_resource "cloudfoundry_space" { + defaults = { id = "00000000-0000-4000-8000-000000000005" } + } +} +variables { + project_id = "00000000-0000-4000-8000-000000000001" + region = "eu01" + cf_org_id = "00000000-0000-4000-8000-000000000002" + cf_username = "deployer@example.invalid" + database_instance_id = "00000000-0000-4000-8000-000000000003" + secrets_instance_id = "00000000-0000-4000-8000-000000000004" + model_serving_token = "test-only-not-a-real-token" +} +run "isolated_runtime_identities" { + command = plan + assert { + condition = stackit_postgresflex_user.application.roles == toset(["login"]) + error_message = "Runtime must not receive database administration roles." + } + assert { + condition = stackit_secretsmanager_user.application.write_enabled == false + error_message = "Connectivity stage requires read-only Secrets access." + } + assert { + condition = cloudfoundry_space.configurator.allow_ssh == false && cloudfoundry_space_role.deployer.type == "space_developer" + error_message = "Space deployment permissions must be explicit." + } +} diff --git a/landing-zone-configurator/infra/runtime/variables.tf b/landing-zone-configurator/infra/runtime/variables.tf new file mode 100644 index 0000000..270b72b --- /dev/null +++ b/landing-zone-configurator/infra/runtime/variables.tf @@ -0,0 +1,26 @@ +variable "project_id" { + type = string +} +variable "region" { + type = string +} +variable "cf_org_id" { + type = string +} +variable "cf_username" { + type = string +} +variable "database_instance_id" { + type = string +} +variable "secrets_instance_id" { + type = string +} +variable "model_serving_token" { + type = string + sensitive = true +} +variable "credential_generation" { + type = string + default = "initial" +} From 02054c5082af1503d346c413e79f4cd7269f9884 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Weberru=C3=9F?= Date: Wed, 30 Sep 2026 09:05:56 +0200 Subject: [PATCH 018/175] fix(configurator): assign CF org membership before space role --- landing-zone-configurator/infra/runtime/README.md | 2 +- landing-zone-configurator/infra/runtime/main.tf | 13 ++++++++++--- .../infra/runtime/tests/runtime.tftest.hcl | 2 +- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/landing-zone-configurator/infra/runtime/README.md b/landing-zone-configurator/infra/runtime/README.md index 9c08f2e..8a1f593 100644 --- a/landing-zone-configurator/infra/runtime/README.md +++ b/landing-zone-configurator/infra/runtime/README.md @@ -1,7 +1,7 @@ # Configurator runtime infrastructure This root creates the `configurator` space in the existing `lzc-dev` CF organization, -assigns the deployment identity the space developer role, and creates separate +assigns the deployment identity organization membership and the space developer role, and creates separate PostgreSQL and read-only Secrets Manager application users. The platform's database migration and Secrets provisioning credentials are not application credentials. diff --git a/landing-zone-configurator/infra/runtime/main.tf b/landing-zone-configurator/infra/runtime/main.tf index e55aa81..e9def0c 100644 --- a/landing-zone-configurator/infra/runtime/main.tf +++ b/landing-zone-configurator/infra/runtime/main.tf @@ -12,10 +12,17 @@ resource "cloudfoundry_space" "configurator" { prevent_destroy = true } } -resource "cloudfoundry_space_role" "deployer" { - space = cloudfoundry_space.configurator.id +# CF requires organization membership even for an existing org manager. +resource "cloudfoundry_org_role" "deployer_member" { + org = var.cf_org_id username = var.cf_username - type = "space_developer" + type = "organization_user" +} +resource "cloudfoundry_space_role" "deployer" { + depends_on = [cloudfoundry_org_role.deployer_member] + space = cloudfoundry_space.configurator.id + username = var.cf_username + type = "space_developer" } resource "stackit_postgresflex_user" "application" { project_id = var.project_id diff --git a/landing-zone-configurator/infra/runtime/tests/runtime.tftest.hcl b/landing-zone-configurator/infra/runtime/tests/runtime.tftest.hcl index bfe1042..08bfbe4 100644 --- a/landing-zone-configurator/infra/runtime/tests/runtime.tftest.hcl +++ b/landing-zone-configurator/infra/runtime/tests/runtime.tftest.hcl @@ -24,7 +24,7 @@ run "isolated_runtime_identities" { error_message = "Connectivity stage requires read-only Secrets access." } assert { - condition = cloudfoundry_space.configurator.allow_ssh == false && cloudfoundry_space_role.deployer.type == "space_developer" + condition = cloudfoundry_space.configurator.allow_ssh == false && cloudfoundry_space_role.deployer.type == "space_developer" && cloudfoundry_org_role.deployer_member.type == "organization_user" error_message = "Space deployment permissions must be explicit." } } From 5f654d2685ae89a7e6675d72309ea25e583777e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Weberru=C3=9F?= Date: Wed, 30 Sep 2026 09:08:24 +0200 Subject: [PATCH 019/175] feat(configurator): add CF application release and service connection checks --- .github/workflows/configurator-release.yml | 159 +++++++ .github/workflows/validate-configurator.yml | 6 +- .../app/apps/api/package.json | 4 +- .../app/apps/api/src/app.ts | 11 +- .../app/apps/api/src/check-connections.ts | 16 + .../app/apps/api/src/connectivity.ts | 119 +++++ .../app/apps/api/src/main.ts | 4 +- landing-zone-configurator/app/cf/README.md | 43 ++ landing-zone-configurator/app/cf/manifest.yml | 28 ++ .../app/package-lock.json | 420 +++++++++++++++++- landing-zone-configurator/app/package.json | 1 + .../app/tests/api.test.ts | 17 + .../app/tests/connectivity.test.ts | 42 ++ landing-zone-configurator/docs/cicd.md | 15 +- .../infra/ci/configure-github.mjs | 21 +- .../infra/ci/github-release-environments.json | 8 + .../infra/ci/prepare-release.mjs | 37 ++ .../infra/ci/prepare-release.test.mjs | 31 ++ 18 files changed, 968 insertions(+), 14 deletions(-) create mode 100644 .github/workflows/configurator-release.yml create mode 100644 landing-zone-configurator/app/apps/api/src/check-connections.ts create mode 100644 landing-zone-configurator/app/apps/api/src/connectivity.ts create mode 100644 landing-zone-configurator/app/cf/README.md create mode 100644 landing-zone-configurator/app/cf/manifest.yml create mode 100644 landing-zone-configurator/app/tests/connectivity.test.ts create mode 100644 landing-zone-configurator/infra/ci/github-release-environments.json create mode 100644 landing-zone-configurator/infra/ci/prepare-release.mjs create mode 100644 landing-zone-configurator/infra/ci/prepare-release.test.mjs diff --git a/.github/workflows/configurator-release.yml b/.github/workflows/configurator-release.yml new file mode 100644 index 0000000..e1d2bfe --- /dev/null +++ b/.github/workflows/configurator-release.yml @@ -0,0 +1,159 @@ +name: Configurator Release + +on: + push: + branches: [feature/landing-zone-configurator] + paths: + - 'landing-zone-configurator/app/**' + - 'landing-zone-configurator/infra/ci/prepare-release.mjs' + - '.github/workflows/configurator-release.yml' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: configurator-lzc-dev-mutation + cancel-in-progress: false + +jobs: + build: + if: vars.LZC_RELEASE_CI_ENABLED == 'true' && github.run_attempt == 1 && ((github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main') || (github.event_name == 'push' && github.ref == 'refs/heads/feature/landing-zone-configurator')) + runs-on: ubuntu-latest + timeout-minutes: 15 + defaults: + run: + working-directory: landing-zone-configurator/app + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: '24.21.0' + cache: npm + cache-dependency-path: landing-zone-configurator/app/package-lock.json + - name: Install and verify application + run: | + npm install --global npm@11.19.0 + npm ci + npm run check + - name: Package production application and pinned Node runtime + run: | + mkdir -p ../.local/release/apps/api ../.local/release/packages/contracts ../.local/release/public ../.local/release/runtime + cp package.json package-lock.json ../.local/release/ + cp apps/api/package.json ../.local/release/apps/api/ + cp -R apps/api/dist ../.local/release/apps/api/ + cp packages/contracts/package.json ../.local/release/packages/contracts/ + cp -R packages/contracts/dist ../.local/release/packages/contracts/ + cp -R apps/web/dist/. ../.local/release/public/ + (cd ../.local/release && npm ci --omit=dev --ignore-scripts --workspace=@lzc/api --workspace=@lzc/contracts) + curl --fail --silent --show-error --location --retry 3 https://nodejs.org/dist/v24.21.0/node-v24.21.0-linux-x64.tar.xz -o "$RUNNER_TEMP/node.tar.xz" + echo "fd8e59d5a511510f6a298afb548f18c7d2b1be404d8b4a27d94fbe49f56cb2d6 $RUNNER_TEMP/node.tar.xz" | sha256sum --check + tar -xJf "$RUNNER_TEMP/node.tar.xz" --strip-components=1 -C ../.local/release/runtime node-v24.21.0-linux-x64/bin/node node-v24.21.0-linux-x64/LICENSE + ../.local/release/runtime/bin/node --version + tar -czf ../.local/release.tar.gz -C ../.local/release . + (cd ../.local && sha256sum release.tar.gz > release.sha256) + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: configurator-app-${{ github.run_id }} + path: | + landing-zone-configurator/.local/release.tar.gz + landing-zone-configurator/.local/release.sha256 + include-hidden-files: true + if-no-files-found: error + retention-days: 7 + + deploy: + needs: build + runs-on: ubuntu-latest + timeout-minutes: 30 + environment: + name: lzc-dev-release + url: https://lzc-dev-configurator-7dbff805.apps.01.cf.eu01.stackit.cloud + defaults: + run: + working-directory: landing-zone-configurator + env: + CF_STAGING_TIMEOUT: '15' + CF_STARTUP_TIMEOUT: '5' + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: '24.21.0' + - uses: opentofu/setup-opentofu@a1320f892987e89d278cc92dc5adc984fb93aca4 # v2.0.2 + with: + tofu_version: '1.12.6' + tofu_wrapper: false + - name: Reject a superseded branch revision + env: + GH_TOKEN: ${{ github.token }} + run: | + head=$(gh api "repos/$GITHUB_REPOSITORY/git/ref/heads/${GITHUB_REF#refs/heads/}" --jq '.object.sha') + test "$head" = "$GITHUB_SHA" + - name: Install pinned CF CLI + run: | + curl --fail --silent --show-error --location --retry 3 https://github.com/cloudfoundry/cli/releases/download/v8.17.0/cf8-cli_8.17.0_linux_x86-64.tgz -o "$RUNNER_TEMP/cf.tgz" + echo "922b91e5651d141ff8756e631adc613c820610c84dcae6cb59f57e22ae073112 $RUNNER_TEMP/cf.tgz" | sha256sum --check + mkdir -p "$RUNNER_TEMP/cf-bin" + tar -xzf "$RUNNER_TEMP/cf.tgz" -C "$RUNNER_TEMP/cf-bin" cf8 + ln -s cf8 "$RUNNER_TEMP/cf-bin/cf" + echo "$RUNNER_TEMP/cf-bin" >> "$GITHUB_PATH" + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: configurator-app-${{ github.run_id }} + path: landing-zone-configurator/.local + - name: Unpack application + run: | + (cd .local && sha256sum --check release.sha256) + mkdir -p .local/release + tar -xzf .local/release.tar.gz -C .local/release + - name: Prepare encrypted state read access + run: node infra/ci/prepare-release.mjs backend + env: + LZC_WORKLOAD_BUCKET: ${{ vars.LZC_WORKLOAD_BUCKET }} + LZC_WORKLOAD_ACCESS_KEY: ${{ secrets.LZC_WORKLOAD_ACCESS_KEY }} + LZC_WORKLOAD_SECRET_KEY: ${{ secrets.LZC_WORKLOAD_SECRET_KEY }} + LZC_STATE_KEY_PLATFORM: ${{ secrets.LZC_STATE_KEY_PLATFORM }} + LZC_STATE_KEY_RUNTIME: ${{ secrets.LZC_STATE_KEY_RUNTIME }} + - name: Read platform and runtime outputs (private files) + timeout-minutes: 5 + run: | + umask 077 + for root in platform runtime; do + export TF_DATA_DIR="$LZC_PRIVATE/$root-data" + export TF_ENCRYPTION="$(cat "$LZC_PRIVATE/$root.encryption.json")" + tofu -chdir="infra/$root" init -input=false -no-color -lockfile=readonly -backend-config="$LZC_PRIVATE/$root.backend.hcl" + tofu -chdir="infra/$root" output -json > "$LZC_PRIVATE/$root-outputs.json" + done + - name: Prepare private CF variables and mask runtime credentials + run: node infra/ci/prepare-release.mjs application + - name: CF login and target + 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: CF push application + run: cf push -f app/cf/manifest.yml -p .local/release --vars-file "$LZC_PRIVATE/app-vars.json" --redact-env + - name: CF service connection tests + id: connectivity + timeout-minutes: 5 + run: cf run-task landing-zone-configurator --command './runtime/bin/node apps/api/dist/check-connections.js' --name "connections-$GITHUB_RUN_ID" -m 128M -k 512M --wait + - name: CF application and connection logs + if: always() && steps.connectivity.outcome != '' && steps.connectivity.outcome != 'skipped' + run: cf logs landing-zone-configurator --recent + - name: Public route checks + env: + LZC_URL: https://lzc-dev-configurator-7dbff805.apps.01.cf.eu01.stackit.cloud + run: | + curl --fail --silent --show-error --retry 3 "$LZC_URL/healthz" | jq -e '.status == "ok" and .authentication == "not-configured"' + curl --fail --silent --show-error "$LZC_URL/" | grep -q 'Landing Zone Configurator' + test "$(curl --silent --show-error -o /dev/null -w '%{http_code}' "$LZC_URL/api/v1/session")" = 401 + - 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 c57f7c5..da0dccb 100644 --- a/.github/workflows/validate-configurator.yml +++ b/.github/workflows/validate-configurator.yml @@ -7,6 +7,8 @@ on: - '.github/workflows/validate-configurator.yml' - '.github/workflows/configurator-bootstrap.yml' - '.github/workflows/configurator-platform.yml' + - '.github/workflows/configurator-runtime.yml' + - '.github/workflows/configurator-release.yml' push: branches: [main, feature/landing-zone-configurator] paths: @@ -14,6 +16,8 @@ on: - '.github/workflows/validate-configurator.yml' - '.github/workflows/configurator-bootstrap.yml' - '.github/workflows/configurator-platform.yml' + - '.github/workflows/configurator-runtime.yml' + - '.github/workflows/configurator-release.yml' workflow_dispatch: permissions: @@ -73,7 +77,7 @@ jobs: tofu_version: '1.12.6' tofu_wrapper: false - name: Check operator plan guard - run: node --test infra/prepare-local.test.mjs infra/plan-guard.test.mjs infra/state-key.test.mjs infra/ci/context.test.mjs infra/ci/artifact.test.mjs infra/ci/prepare.test.mjs infra/ci/review.test.mjs infra/ci/timeout.test.mjs + run: node --test infra/prepare-local.test.mjs infra/plan-guard.test.mjs infra/state-key.test.mjs infra/ci/context.test.mjs infra/ci/artifact.test.mjs infra/ci/prepare.test.mjs infra/ci/prepare-release.test.mjs infra/ci/review.test.mjs infra/ci/timeout.test.mjs - name: Check formatting run: tofu -chdir=infra fmt -check -recursive - name: Prepare disposable validation encryption diff --git a/landing-zone-configurator/app/apps/api/package.json b/landing-zone-configurator/app/apps/api/package.json index 8c6ecd4..2d927cf 100644 --- a/landing-zone-configurator/app/apps/api/package.json +++ b/landing-zone-configurator/app/apps/api/package.json @@ -8,7 +8,9 @@ "start": "node dist/main.js" }, "dependencies": { + "@fastify/static": "10.1.5", "@lzc/contracts": "0.1.0", - "fastify": "5.12.5" + "fastify": "5.12.5", + "pg": "8.23.0" } } diff --git a/landing-zone-configurator/app/apps/api/src/app.ts b/landing-zone-configurator/app/apps/api/src/app.ts index 9e1f923..6f396e5 100644 --- a/landing-zone-configurator/app/apps/api/src/app.ts +++ b/landing-zone-configurator/app/apps/api/src/app.ts @@ -1,7 +1,8 @@ +import fastifyStatic from "@fastify/static"; import type { HealthResponse } from "@lzc/contracts"; import Fastify from "fastify"; -export function buildApp() { +export function buildApp(options: { webRoot?: string } = {}) { const app = Fastify({ logger: { redact: { @@ -32,5 +33,13 @@ export function buildApp() { { prefix: "/api/v1" }, ); + if (options.webRoot) { + app.register(fastifyStatic, { + root: options.webRoot, + index: ["index.html"], + dotfiles: "deny", + }); + } + return app; } diff --git a/landing-zone-configurator/app/apps/api/src/check-connections.ts b/landing-zone-configurator/app/apps/api/src/check-connections.ts new file mode 100644 index 0000000..9af2d97 --- /dev/null +++ b/landing-zone-configurator/app/apps/api/src/check-connections.ts @@ -0,0 +1,16 @@ +import { checkDatabase, checkSecrets, safeFailure } from "./connectivity.js"; + +for (const [service, check] of [ + ["postgresql", checkDatabase], + ["secrets-manager", checkSecrets], +] as const) { + try { + await check(); + console.log(JSON.stringify({ service, ok: true, tls: "verified" })); + } catch (error) { + console.error( + JSON.stringify({ service, ok: false, error: safeFailure(error) }), + ); + process.exitCode = 1; + } +} diff --git a/landing-zone-configurator/app/apps/api/src/connectivity.ts b/landing-zone-configurator/app/apps/api/src/connectivity.ts new file mode 100644 index 0000000..fabf0ed --- /dev/null +++ b/landing-zone-configurator/app/apps/api/src/connectivity.ts @@ -0,0 +1,119 @@ +import pg from "pg"; + +function required(name: string): string { + const value = process.env[name]; + if (!value) throw new Error("missing-configuration"); + return value; +} + +export async function checkDatabase(): Promise { + const host = required("LZC_DATABASE_HOST"); + const client = new pg.Client({ + host, + port: Number(required("LZC_DATABASE_PORT")), + database: required("LZC_DATABASE_NAME"), + user: required("LZC_DATABASE_USER"), + password: required("LZC_DATABASE_PASSWORD"), + ssl: { rejectUnauthorized: true, servername: host }, + connectionTimeoutMillis: 15000, + query_timeout: 15000, + statement_timeout: 10000, + application_name: "lzc-connectivity-check", + }); + try { + await client.connect(); + const result = await client.query<{ ssl: boolean }>( + "SELECT ssl FROM pg_stat_ssl WHERE pid = pg_backend_pid()", + ); + if (result.rows[0]?.ssl !== true) throw new Error("tls-required"); + await client.query("SELECT 1"); + } finally { + await client.end(); + } +} + +export async function checkSecrets(): Promise { + const address = required("LZC_SECRETS_ADDRESS"); + if (address !== "https://prod.sm.eu01.stackit.cloud") + throw new Error("unexpected-service-address"); + const instance = required("LZC_SECRETS_INSTANCE_ID"); + if (!/^[a-f0-9-]{36}$/.test(instance)) throw new Error("invalid-instance"); + const request = (path: string, init: RequestInit) => + fetch(`${address}/v1/${path}`, { + ...init, + redirect: "error", + signal: AbortSignal.timeout(15000), + }); + const login = await request( + `auth/userpass/login/${encodeURIComponent(required("LZC_SECRETS_USERNAME"))}`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ password: required("LZC_SECRETS_PASSWORD") }), + }, + ); + if (!login.ok) throw new Error(`login-http-${login.status}`); + const auth = (await login.json()) as { auth?: { client_token?: string } }; + const token = auth.auth?.client_token; + if (!token) throw new Error("missing-session-token"); + const headers = { + "X-Vault-Token": token, + "Content-Type": "application/json", + }; + let revokeSucceeded = false; + try { + // Verify the instance-specific read policy, without reading any tenant secrets. + const path = `${instance}/data/configurator/connectivity-probe`; + const capabilities = await request("sys/capabilities-self", { + method: "POST", + headers, + body: JSON.stringify({ paths: [path] }), + }); + if (!capabilities.ok) + throw new Error(`capabilities-http-${capabilities.status}`); + const data = (await capabilities.json()) as Record; + const permissions = data[path]; + if ( + !Array.isArray(permissions) || + !permissions.includes("read") || + permissions.includes("create") || + permissions.includes("update") + ) { + throw new Error("unexpected-runtime-policy"); + } + const listing = await request(`${instance}/metadata/?list=true`, { + headers, + }); + // An empty, authorized KV store returns 404. A denied request returns 403. + if (listing.status !== 200 && listing.status !== 404) + throw new Error(`metadata-http-${listing.status}`); + await listing.arrayBuffer(); // Discard secret names; never log responses. + } finally { + const revoked = await request("auth/token/revoke-self", { + method: "POST", + headers, + }); + revokeSucceeded = revoked.ok; + } + if (!revokeSucceeded) throw new Error("session-revoke-failed"); +} + +export function safeFailure(error: unknown): string { + // Raw network/driver errors may contain credentials or service responses. + if ( + error instanceof Error && + /^(missing-configuration|tls-required|unexpected-service-address|invalid-instance|missing-session-token|unexpected-runtime-policy|session-revoke-failed|(?:login|capabilities|metadata)-http-\d{3})$/.test( + error.message, + ) + ) + return error.message; + const code = + error && typeof error === "object" && "code" in error + ? String(error.code) + : ""; + return /^(ECONNREFUSED|ETIMEDOUT|ENOTFOUND|ECONNRESET|CERT_HAS_EXPIRED|UNABLE_TO_VERIFY_LEAF_SIGNATURE|SELF_SIGNED_CERT_IN_CHAIN|DEPTH_ZERO_SELF_SIGNED_CERT|ERR_TLS_CERT_ALTNAME_INVALID|28P01|28000|42501|3D000)$/.test( + code, + ) + ? code + : "connection-or-response-failed"; +} diff --git a/landing-zone-configurator/app/apps/api/src/main.ts b/landing-zone-configurator/app/apps/api/src/main.ts index d93b65d..ec366ec 100644 --- a/landing-zone-configurator/app/apps/api/src/main.ts +++ b/landing-zone-configurator/app/apps/api/src/main.ts @@ -1,6 +1,8 @@ import { buildApp } from "./app.js"; -const app = buildApp(); +const app = buildApp( + process.env.LZC_WEB_ROOT ? { webRoot: process.env.LZC_WEB_ROOT } : {}, +); const port = Number(process.env.PORT ?? "3000"); if (!Number.isInteger(port) || port < 1 || port > 65535) { throw new Error("PORT must be an integer between 1 and 65535"); diff --git a/landing-zone-configurator/app/cf/README.md b/landing-zone-configurator/app/cf/README.md new file mode 100644 index 0000000..bdb332b --- /dev/null +++ b/landing-zone-configurator/app/cf/README.md @@ -0,0 +1,43 @@ +# CF application release + +`.github/workflows/configurator-release.yml` builds and tests the application, +packages production dependencies and the checksum-pinned Node 24.21.0 Linux runtime, +and deploys the same archive with explicit `cf` CLI commands. The CF node buildpack +currently offers an older Node version, so this release uses `binary_buildpack`. +Update the version and checksum together when upgrading Node. The CF stack remains +platform-maintained (`cflinuxfs4`). + +`manifest.yml` owns the application, route, process size, health check and backend +environment bindings. OpenTofu owns the CF org/space/roles and backing services; +it does not also manage the application. The worker is not deployed yet. + +The protected `lzc-dev-release` Environment contains workload S3 credentials and +platform/runtime state keys. No project service-account key is supplied to the +release workflow. `tofu output -json` writes private files; `prepare-release.mjs` +only extracts explicitly allowed backend variables and masks them. `cf push` +uses a private variables file and `--redact-env`; neither credentials nor decrypted +states are included in the application artifact. The app receives only its own +PostgreSQL/Secrets credentials and the operator Model Serving token. + +For this development milestone the CF deployment identity is still the existing +org manager with explicit space developer membership, and release reads platform +state. A dedicated space-only deployer and narrower state/credential publication +are required before production. CF environment variables are visible to authorized +space developers; restrict and audit those roles. The public app remains a foundation +page: protected API routes refuse requests until real authentication exists. + +After push, a CF task performs PostgreSQL authentication, a query with verified TLS, +and Secrets Manager authentication plus instance-specific read authorization and +metadata access. No tenant secrets are read or written. Diagnostic logs only show +service names, result and sanitized failure categories. The task revokes its Vault +token. A health check alone does not prove service connectivity. + +All mutations share `configurator-lzc-dev-mutation` with infrastructure workflows. +Feature-branch releases require Environment approval. A superseded branch revision +is rejected before deployment. GitHub concurrency is not FIFO: newer pending runs +can replace older pending runs. Never run local applies or pushes concurrently. + +The initial single-instance `cf push` can interrupt service briefly. Immutable +archive/checksum retention is seven days; explicit rollback/promotion and rolling +releases are subsequent milestones. DB migrations, schema grants, RLS, personal +credential storage and customer deployment execution are not part of this release. diff --git a/landing-zone-configurator/app/cf/manifest.yml b/landing-zone-configurator/app/cf/manifest.yml new file mode 100644 index 0000000..19fd6e7 --- /dev/null +++ b/landing-zone-configurator/app/cf/manifest.yml @@ -0,0 +1,28 @@ +--- +applications: + - name: landing-zone-configurator + instances: 1 + memory: 256M + disk_quota: 512M + stack: cflinuxfs4 + buildpacks: [binary_buildpack] + command: ./runtime/bin/node apps/api/dist/main.js + health-check-type: http + health-check-http-endpoint: /healthz + timeout: 120 + routes: + - route: lzc-dev-configurator-7dbff805.apps.01.cf.eu01.stackit.cloud + env: + NODE_ENV: production + HOST: 0.0.0.0 + LZC_WEB_ROOT: /home/vcap/app/public + LZC_DATABASE_HOST: ((LZC_DATABASE_HOST)) + LZC_DATABASE_PORT: ((LZC_DATABASE_PORT)) + LZC_DATABASE_NAME: ((LZC_DATABASE_NAME)) + LZC_DATABASE_USER: ((LZC_DATABASE_USER)) + LZC_DATABASE_PASSWORD: ((LZC_DATABASE_PASSWORD)) + LZC_SECRETS_ADDRESS: ((LZC_SECRETS_ADDRESS)) + LZC_SECRETS_INSTANCE_ID: ((LZC_SECRETS_INSTANCE_ID)) + LZC_SECRETS_USERNAME: ((LZC_SECRETS_USERNAME)) + LZC_SECRETS_PASSWORD: ((LZC_SECRETS_PASSWORD)) + LZC_MODEL_SERVING_TOKEN: ((LZC_MODEL_SERVING_TOKEN)) diff --git a/landing-zone-configurator/app/package-lock.json b/landing-zone-configurator/app/package-lock.json index 9183e03..bb2d8a0 100644 --- a/landing-zone-configurator/app/package-lock.json +++ b/landing-zone-configurator/app/package-lock.json @@ -14,6 +14,7 @@ "devDependencies": { "@biomejs/biome": "2.5.14", "@types/node": "24.19.0", + "@types/pg": "8.23.1", "tsx": "4.23.15", "typescript": "7.0.2", "vitest": "5.0.2" @@ -27,8 +28,10 @@ "name": "@lzc/api", "version": "0.1.0", "dependencies": { + "@fastify/static": "10.1.5", "@lzc/contracts": "0.1.0", - "fastify": "5.12.5" + "fastify": "5.12.5", + "pg": "8.23.0" } }, "apps/web": { @@ -667,6 +670,22 @@ "node": ">=18" } }, + "node_modules/@fastify/accept-negotiator": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@fastify/accept-negotiator/-/accept-negotiator-2.1.0.tgz", + "integrity": "sha512-F3EVbzWt+xcnVaOHmWyIlpuFtbxOln7HDZQsh09MtMmMm/CipMayNt8hnIL8VQi54u2ZociDbf+iluGYkf7B1A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/@fastify/ajv-compiler": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.6.tgz", @@ -778,6 +797,54 @@ "ipaddr.js": "^2.1.0" } }, + "node_modules/@fastify/send": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@fastify/send/-/send-4.1.1.tgz", + "integrity": "sha512-BYo+EiaKwlxH+WetGk6hAs1d39iP0y1gqB8lGF/qwkJ9ZZ/cBY1vx5NvExb9Sc3yRMFjD5X4Eyh4e4+TzRkzdw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@lukeed/ms": "^2.0.2", + "escape-html": "~1.0.3", + "fast-decode-uri-component": "^1.0.1", + "http-errors": "^2.0.0", + "mime": "^3" + } + }, + "node_modules/@fastify/static": { + "version": "10.1.5", + "resolved": "https://registry.npmjs.org/@fastify/static/-/static-10.1.5.tgz", + "integrity": "sha512-3r0ZFXo82nKlPzySOUYhwWVfPZStfiwjmyevkX7cJWL2yXmEzl0Rpxv+L6L1R4OxqvlDGqf3g8OdBzILKgqe6w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/accept-negotiator": "^2.0.0", + "@fastify/error": "^4.0.0", + "@fastify/send": "^4.0.0", + "content-disposition": "^3.0.0", + "fastify-plugin": "^6.0.0", + "fastq": "^1.17.1", + "glob": "^13.0.0" + } + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -806,6 +873,15 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@lukeed/ms": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz", + "integrity": "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/@lzc/api": { "resolved": "apps/api", "link": true @@ -1157,6 +1233,18 @@ "undici-types": ">=7.24.0 <7.24.7" } }, + "node_modules/@types/pg": { + "version": "8.23.1", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.23.1.tgz", + "integrity": "sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, "node_modules/@types/react": { "version": "19.3.0", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", @@ -1679,6 +1767,27 @@ "fastq": "^1.17.1" } }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, "node_modules/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", @@ -1689,6 +1798,19 @@ "node": ">=18" } }, + "node_modules/content-disposition": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-3.0.0.tgz", + "integrity": "sha512-ZH/0Xs9rMIFWCOmGdmS9eHBTF62qqQYNz4nVjQhkdIO/a0fCP4UIM3mRz/wiqL0L14YgAz/1xio4OaSY4+ON/A==", + "license": "MIT", + "engines": { + "node": ">=22" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/cookie": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", @@ -1709,6 +1831,15 @@ "dev": true, "license": "MIT" }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -1777,6 +1908,12 @@ "@esbuild/win32-x64": "0.28.2" } }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, "node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", @@ -1891,6 +2028,22 @@ "toad-cache": "^3.7.0" } }, + "node_modules/fastify-plugin": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-6.0.0.tgz", + "integrity": "sha512-fZOty7z3O7vOliF6d8bHE3wiEh1KcNnKEQensSgTk9C1DvN6nRLS++XVd86v33Hw/8u9Un8A1zDrQ8ujcQDHEg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/fastq": { "version": "1.20.3", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.3.tgz", @@ -1947,6 +2100,49 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, "node_modules/ipaddr.js": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.5.0.tgz", @@ -2291,6 +2487,15 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/magic-string": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.2.tgz", @@ -2301,6 +2506,42 @@ "@jridgewell/sourcemap-codec": "^1.6.0" } }, + "node_modules/mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/nanoid": { "version": "3.3.19", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", @@ -2343,6 +2584,111 @@ "node": ">=14.0.0" } }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/pg": { + "version": "8.23.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz", + "integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.14.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.16.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz", + "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.0.tgz", + "integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -2429,6 +2775,45 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/process-warning": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.1.0.tgz", @@ -2620,6 +3005,12 @@ "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", "license": "MIT" }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, "node_modules/sonic-boom": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", @@ -2648,6 +3039,15 @@ "node": ">= 10.x" } }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/std-env": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", @@ -2719,6 +3119,15 @@ "node": ">=20" } }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, "node_modules/tsx": { "version": "4.23.15", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.15.tgz", @@ -2954,6 +3363,15 @@ "node": ">=20.11" } }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, "node_modules/zod": { "version": "4.6.5", "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz", diff --git a/landing-zone-configurator/app/package.json b/landing-zone-configurator/app/package.json index eef32b5..eebdb86 100644 --- a/landing-zone-configurator/app/package.json +++ b/landing-zone-configurator/app/package.json @@ -28,6 +28,7 @@ "devDependencies": { "@biomejs/biome": "2.5.14", "@types/node": "24.19.0", + "@types/pg": "8.23.1", "tsx": "4.23.15", "typescript": "7.0.2", "vitest": "5.0.2" diff --git a/landing-zone-configurator/app/tests/api.test.ts b/landing-zone-configurator/app/tests/api.test.ts index 16bf28a..a0b31fb 100644 --- a/landing-zone-configurator/app/tests/api.test.ts +++ b/landing-zone-configurator/app/tests/api.test.ts @@ -1,3 +1,6 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { healthResponseSchema } from "@lzc/contracts"; import { afterEach, describe, expect, it } from "vitest"; import { buildApp } from "../apps/api/src/app.js"; @@ -31,3 +34,17 @@ describe("API foundation", () => { expect(response.json()).toEqual({ error: "authentication_required" }); }); }); + +it("serves the compiled UI without bypassing API authentication", async () => { + const root = mkdtempSync(join(tmpdir(), "lzc-web-")); + writeFileSync(join(root, "index.html"), "Configurator"); + const app = buildApp({ webRoot: root }); + try { + expect((await app.inject("/")).body).toContain("Configurator"); + expect((await app.inject("/api/v1/session")).statusCode).toBe(401); + expect((await app.inject("/api/v1/unknown")).statusCode).not.toBe(200); + } finally { + await app.close(); + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/landing-zone-configurator/app/tests/connectivity.test.ts b/landing-zone-configurator/app/tests/connectivity.test.ts new file mode 100644 index 0000000..a4383fe --- /dev/null +++ b/landing-zone-configurator/app/tests/connectivity.test.ts @@ -0,0 +1,42 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { checkSecrets, safeFailure } from "../apps/api/src/connectivity.js"; + +afterEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); +}); +describe("connection diagnostics", () => { + it("never includes raw credentials from driver errors", () => { + expect(safeFailure(new Error("password=very-secret"))).toBe( + "connection-or-response-failed", + ); + expect(safeFailure({ code: "28P01", message: "very-secret" })).toBe( + "28P01", + ); + }); + it("checks the instance policy and revokes the temporary session", async () => { + const id = "00000000-0000-4000-8000-000000000001"; + vi.stubEnv("LZC_SECRETS_ADDRESS", "https://prod.sm.eu01.stackit.cloud"); + vi.stubEnv("LZC_SECRETS_INSTANCE_ID", id); + vi.stubEnv("LZC_SECRETS_USERNAME", "runtime"); + vi.stubEnv("LZC_SECRETS_PASSWORD", "fake-test-password"); + const fetch = vi + .fn() + .mockResolvedValueOnce( + Response.json({ auth: { client_token: "fake-token" } }), + ) + .mockResolvedValueOnce( + Response.json({ + [`${id}/data/configurator/connectivity-probe`]: ["read"], + }), + ) + .mockResolvedValueOnce(new Response(null, { status: 404 })) + .mockResolvedValueOnce(new Response(null, { status: 204 })); + vi.stubGlobal("fetch", fetch); + await expect(checkSecrets()).resolves.toBeUndefined(); + expect(fetch).toHaveBeenLastCalledWith( + "https://prod.sm.eu01.stackit.cloud/v1/auth/token/revoke-self", + expect.objectContaining({ method: "POST", redirect: "error" }), + ); + }); +}); diff --git a/landing-zone-configurator/docs/cicd.md b/landing-zone-configurator/docs/cicd.md index 44ad423..e947805 100644 --- a/landing-zone-configurator/docs/cicd.md +++ b/landing-zone-configurator/docs/cicd.md @@ -1,6 +1,6 @@ # CI/CD für den Landing Zone Configurator -Status: Validierung, Bootstrap-/Backend- und Plattform-Pipelines sind implementiert und erfolgreich ausgeführt. OpenTofu wird direkt in den Workflows aufgerufen, mit normalen Live-Logs. App-Release, Runtime und Drift-Workflow sind noch geplant. Erste Umgebung: **lzc-dev / eu01**. Aktuelle Bedienung: [CI-Betrieb](../infra/ci/README.md). +Status: Validierung, Bootstrap-/Backend- und Plattform-Pipelines sind implementiert und erfolgreich ausgeführt. OpenTofu wird direkt in den Workflows aufgerufen, mit normalen Live-Logs. Runtime und App-Release sind implementiert und werden gegen CF abgenommen; Drift bleibt geplant. Erste Umgebung: **lzc-dev / eu01**. Aktuelle Bedienung: [CI-Betrieb](../infra/ci/README.md). ## Getrennte Lebenszyklen @@ -8,8 +8,9 @@ Status: Validierung, Bootstrap-/Backend- und Plattform-Pipelines sind implementi | --- | --- | --- | | `validate-configurator.yml` (vorhanden) | PR, main, Feature-Branch, manuell | App-Prüfungen, IaC-Validierung und Mock-Tests ohne Cloud-Zugang | | `configurator-bootstrap.yml` | Feature-Push: Plan; manuell auf main vorbereitet | State-Infrastruktur erstellen, Versionierung konfigurieren, später Credentials rotieren | -| `configurator-platform.yml` | Infrastrukturänderungen auf Feature-Branch: Plan; manuell auf main vorbereitet | Plattform-IaC planen, geprüften Plan anwenden; Runtime folgt | -| `configurator-release.yml` | App-/Release-Dateien auf main; manuell für Redeploy/Rollback | Einmal bauen, prüfen, unveränderliches Release nach CF deployen | +| `configurator-platform.yml` | Infrastrukturänderungen auf Feature-Branch: Plan; manuell auf main vorbereitet | Plattform-IaC planen, geprüften Plan anwenden | +| `configurator-runtime.yml` | Runtime-/CI-Änderungen auf Feature-Branch; manuell auf main vorbereitet | CF-Space, Organisationsmitgliedschaft, Space-Rolle und Laufzeitbenutzer | +| `configurator-release.yml` | App-/Release-Dateien auf Feature-Branch; manuell auf main vorbereitet | Web/API bauen, prüfen, gepacktes Release nach CF deployen und Verbindungen aus CF testen | | `configurator-drift.yml` | Optional zeitgesteuert/manuell | Infrastruktur-Drift erkennen und melden; zunächst kein automatisches Apply | Ein normales App-Release startet weder Bootstrap noch Plattform-Apply. Relevante Pfade umfassen auch gemeinsame Deployment-Skripte, Lockfiles und die jeweilige Workflow-Datei. Änderungen ausschließlich am Accelerator lösen keinen Configurator-Release aus. Kunden-Landing-Zone-Deployments bleiben ein eigener, mandantenisolierter Runner-Ablauf und erhalten keine Betreiber-CI-Credentials. @@ -35,7 +36,7 @@ Bootstrap und Backend-Versionierung laufen nacheinander. Nach Bootstrap-Apply wi ## Anwendung häufig und unabhängig ausliefern -Der Release-Workflow prüft und baut Web/API/Worker einmal und erstellt ein über Commit und SHA-256 identifizierbares Release. Redeploy und spätere Promotion verwenden dasselbe geprüfte Paket; historische Artefakte sind entsprechend der Rollback-Frist dauerhaft aufzubewahren. Kein erneuter Build beim Rollback. +Der implementierte Release-Workflow prüft und baut Web/API einmal; der Worker folgt. Er und erstellt ein über Commit und SHA-256 identifizierbares Release. Redeploy und spätere Promotion verwenden dasselbe geprüfte Paket; historische Artefakte sind entsprechend der Rollback-Frist dauerhaft aufzubewahren. Kein erneuter Build beim Rollback. Der Lifecycle-Owner wird eindeutig festgelegt: OpenTofu verwaltet Dienste, CF-Org/Spaces, Rollen und gemeinsame Service-Instanzen. Der deklarative Release-Prozess verwaltet App-Objekte, App-Routen, App-Bindings, Prozesskonfiguration und Code. Diese Ressourcen werden nicht zusätzlich vom Runtime-Root verwaltet. So bleiben auch App-Provisionierung und Bindings als Code reproduzierbar. @@ -47,7 +48,7 @@ Entwicklung: nach erfolgreicher Validierung auf main automatisch deployen, sobal - Bootstrap: gesonderte Betreiberidentität mit den notwendigen Projekt-/Object-Storage-Rechten. - Plattform: scoped STACKIT-Zugang; App-Release erhält diesen Zugang nicht. -- Release: CF-Deployment-Identität auf den Ziel-Space beschränkt; DB-Migrationszugang nur im Migrationsschritt. +- Release-Zielbild: eigene CF-Deployment-Identität nur für den Ziel-Space. Der erste Entwicklungsstand nutzt noch den vorhandenen Org-Manager und liest dessen Zugang aus dem verschlüsselten Plattform-State; App-Zugänge sind davon getrennt. DB-Migrationen sind noch nicht implementiert. - Persönliche GitHub-/STACKIT-Zugänge der Kunden bleiben in der Anwendung und gelangen nicht in Betreiber-Workflows. - GitHub Environments trennen mindestens Bootstrap, Plattform und App je Umgebung. Environment-Regeln und Branch-Schutz werden als Repository-Konfiguration per IaC/API verwaltet; initiale GitHub-Administrationsberechtigung und Secret-Einspeisung bleiben eine notwendige Vertrauensbasis. - Secrets niemals aus PR-Code zugänglich machen; kein privilegiertes `pull_request_target` mit Checkout von Fork-Code. Actions auf Commit-SHAs pinnen, minimale Token-Rechte, keine Shell-Ausgabe von Credentials. @@ -57,7 +58,7 @@ Die Verfügbarkeit von Environment-Freigaberegeln hängt vom GitHub-Tarif und de ## Umsetzungsreihenfolge und Abnahme -- [x] Lebenszyklen und Credentials trennen; Validierung aller fünf IaC-Roots und der App. +- [x] Lebenszyklen und Credentials trennen; Validierung aller sechs IaC-Roots und der App. - [x] Verwaltungs- und Workload-Backend einrichten, Versionierung und State-Verschlüsselung prüfen. - [x] GitHub-Environments und Freigaben einrichten; bestehende Deployment-Schlüssel geschützt übergeben. - [x] Bootstrap-/Backend- und Plattform-Pipelines ausführen; Plattform-Recovery und anschließenden No-op nachweisen. @@ -77,3 +78,5 @@ Nächster Ausbau ist die CF-Runtime mit Zugriffstests zu Datenbank und Secrets M - [GitHub Deployment-Steuerung und Concurrency](https://docs.github.com/en/actions/how-tos/deploy/configure-and-manage-deployments/control-deployments) - [GitHub Environment-Verfügbarkeit und Einrichtung](https://docs.github.com/en/actions/how-tos/deploy/configure-and-manage-deployments/manage-environments) - [Cloud Foundry Rolling Deployments](https://docs.cloudfoundry.org/devguide/deploy-apps/rolling-deploy.html) + +Details zum implementierten Release und seinen Grenzen: [CF-Release](../app/cf/README.md). diff --git a/landing-zone-configurator/infra/ci/configure-github.mjs b/landing-zone-configurator/infra/ci/configure-github.mjs index 7a4c8da..705a020 100644 --- a/landing-zone-configurator/infra/ci/configure-github.mjs +++ b/landing-zone-configurator/infra/ci/configure-github.mjs @@ -8,10 +8,11 @@ const here=dirname(fileURLToPath(import.meta.url)); const infra=resolve(here,".."); const local=resolve(infra,"../.local"); const repoPath=resolve(infra,"../.."); +const release = process.argv[2] === "--release"; const runtime = process.argv[2] === "--runtime"; const platform = process.argv[2] === "--platform" || runtime; -if(process.argv.length > 2 && !platform) throw new Error("Only --platform or --runtime is supported"); -const spec=JSON.parse(readFileSync(resolve(here,runtime ? "github-runtime-environments.json" : platform ? "github-platform-environments.json" : "github-environments.json"),"utf8")); +if(process.argv.length > 3 || (process.argv.length > 2 && !platform && !release)) throw new Error("Only --platform, --runtime or --release is supported"); +const spec=JSON.parse(readFileSync(resolve(here,release ? "github-release-environments.json" : runtime ? "github-runtime-environments.json" : platform ? "github-platform-environments.json" : "github-environments.json"),"utf8")); if(spec.repository!=="stackitcloud/stackit-landing-zone")throw new Error("Unexpected secret destination"); function gh(args,input) { try {return execFileSync("gh",args,{input,encoding:"utf8",stdio:["pipe","pipe","pipe"]});} @@ -41,8 +42,20 @@ const recoverySecrets={...sharedSecrets, LZC_STATE_KEY_SEED:readFileSync(resolve(local,"seed/state.passphrase"),"utf8").trim(), LZC_STATE_KEY_SEED_PROTECTION:readFileSync(resolve(local,"seed-protection/state.passphrase"),"utf8").trim(), }; +let releaseSecrets, workloadBucket; +if (release) { + const bootstrapEnv={...env,TF_DATA_DIR:resolve(local,"bootstrap/data"),TF_ENCRYPTION:JSON.stringify({key_provider:{pbkdf2:{state:{passphrase:deploySecrets.LZC_STATE_KEY_BOOTSTRAP}}}}),AWS_ACCESS_KEY_ID:sharedSecrets.LZC_MANAGEMENT_ACCESS_KEY,AWS_SECRET_ACCESS_KEY:sharedSecrets.LZC_MANAGEMENT_SECRET_KEY}; + let bootstrap; + try { bootstrap=JSON.parse(execFileSync(process.env.LZC_TOFU_BIN??"tofu",[`-chdir=${resolve(infra,"bootstrap")}`,"output","-json"],{env:bootstrapEnv,encoding:"utf8",stdio:["ignore","pipe","pipe"]})); } + catch { throw new Error("Cannot read encrypted workload backend outputs"); } + workloadBucket=bootstrap.backend.value.bucket; + releaseSecrets={LZC_WORKLOAD_ACCESS_KEY:bootstrap.backend_credentials.value.access_key,LZC_WORKLOAD_SECRET_KEY:bootstrap.backend_credentials.value.secret_key, + LZC_STATE_KEY_PLATFORM:readFileSync(resolve(local,"platform/state.passphrase"),"utf8").trim(), + LZC_STATE_KEY_RUNTIME:readFileSync(resolve(local,"runtime/state.passphrase"),"utf8").trim()}; +} const variables={LZC_PROJECT_ID:config.PROJECT_ID,LZC_REGION:config.REGION,LZC_NAME_PREFIX:config.NAME_PREFIX, LZC_MANAGEMENT_BUCKET:outputs.backend.value.bucket,LZC_CREDENTIAL_EXPIRATION:config.STATE_CREDENTIAL_EXPIRATION}; +if(release) variables.LZC_WORKLOAD_BUCKET=workloadBucket; if(Object.values(variables).some(v=>!v))throw new Error("Missing environment configuration"); for(const target of spec.environments) { const path=`repos/${spec.repository}/environments/${target.name}`; @@ -59,10 +72,12 @@ for(const target of spec.environments) { if(checked.deployment_branch_policy?.custom_branch_policies!==true)throw new Error("Environment branch protection missing"); if(target.review&&!checked.protection_rules.some(r=>r.type==="required_reviewers"&&r.reviewers.some(x=>x.reviewer.id===reviewer.id)))throw new Error("Required reviewer missing"); for(const [name,value] of Object.entries(variables))gh(["variable","set",name,"--repo",spec.repository,"--env",target.name],value); - for(const [name,value] of Object.entries(target.name.endsWith("recovery")?recoverySecrets:deploySecrets)) { + for(const [name,value] of Object.entries(release?releaseSecrets:target.name.endsWith("recovery")?recoverySecrets:deploySecrets)) { gh(["secret","set",name,"--repo",spec.repository,"--env",target.name],value); } console.log(`Configured ${target.name}: branches=${branches.join(",")}, review=${target.review}; secret values not displayed.`); } if(platform) gh(["variable","set",runtime ? "LZC_RUNTIME_CI_ENABLED" : "LZC_PLATFORM_CI_ENABLED","--repo",spec.repository],"true"); + +if(release) gh(["variable","set","LZC_RELEASE_CI_ENABLED","--repo",spec.repository],"true"); diff --git a/landing-zone-configurator/infra/ci/github-release-environments.json b/landing-zone-configurator/infra/ci/github-release-environments.json new file mode 100644 index 0000000..1fa1d97 --- /dev/null +++ b/landing-zone-configurator/infra/ci/github-release-environments.json @@ -0,0 +1,8 @@ +{ + "repository": "stackitcloud/stackit-landing-zone", + "branch": "main", + "reviewer": "lweberru", + "environments": [ + {"name":"lzc-dev-release","review":true,"branches":["main","feature/landing-zone-configurator"]} + ] +} diff --git a/landing-zone-configurator/infra/ci/prepare-release.mjs b/landing-zone-configurator/infra/ci/prepare-release.mjs new file mode 100644 index 0000000..174dda5 --- /dev/null +++ b/landing-zone-configurator/infra/ci/prepare-release.mjs @@ -0,0 +1,37 @@ +// File preparation only; tofu and cf remain explicit commands in the workflow. +import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { randomUUID } from "node:crypto"; +const mode=process.argv[2]; +if(!["backend","application"].includes(mode)) throw new Error("Expected backend or application"); +const required=name=>{if(!process.env[name])throw new Error(`Missing ${name}`);return process.env[name];}; +if(required("GITHUB_REPOSITORY")!=="stackitcloud/stackit-landing-zone" || !["refs/heads/main","refs/heads/feature/landing-zone-configurator"].includes(required("GITHUB_REF")))throw new Error("Unexpected release source"); +process.umask(0o077); +const dir=resolve(required("RUNNER_TEMP"),"lzc-release");mkdirSync(dir,{recursive:true,mode:0o700}); +const mask=value=>console.log(`::add-mask::${String(value).replaceAll("%","%25").replaceAll("\r","%0D").replaceAll("\n","%0A")}`); +function publish(name,value,secret=false){if(secret)mask(value);const d=`lzc_${randomUUID()}`;appendFileSync(required("GITHUB_ENV"),`${name}<<${d}\n${value}\n${d}\n`);} +if(mode==="backend") { + const bucket=required("LZC_WORKLOAD_BUCKET"); + if(bucket!=="lzc-dev-state-7dbff805")throw new Error("Unexpected workload bucket"); + for(const root of ["platform","runtime"]){ + const passphrase=required(`LZC_STATE_KEY_${root.toUpperCase()}`).trim(); + if(passphrase.length<32)throw new Error("Invalid state key"); + mask(passphrase); + writeFileSync(resolve(dir,`${root}.encryption.json`),JSON.stringify({key_provider:{pbkdf2:{state:{passphrase}}}}),{mode:0o600}); + const backend={bucket,key:`configurator/lzc-dev/${root}/terraform.tfstate`,region:"eu01",endpoints:{s3:"https://object.storage.eu01.onstackit.cloud"},use_path_style:true,use_lockfile:true,skip_credentials_validation:true,skip_region_validation:true,skip_requesting_account_id:true,skip_metadata_api_check:true,skip_s3_checksum:true}; + writeFileSync(resolve(dir,`${root}.backend.hcl`),Object.entries(backend).map(([k,v])=>`${k} = ${JSON.stringify(v)}`).join("\n"),{mode:0o600}); + } + publish("AWS_ACCESS_KEY_ID",required("LZC_WORKLOAD_ACCESS_KEY"),true); + publish("AWS_SECRET_ACCESS_KEY",required("LZC_WORKLOAD_SECRET_KEY"),true); + publish("CF_HOME",resolve(dir,"cf")); +} else { + const platform=JSON.parse(readFileSync(resolve(dir,"platform-outputs.json"),"utf8")); + const runtime=JSON.parse(readFileSync(resolve(dir,"runtime-outputs.json"),"utf8")); + const cf=platform.cf_runtime?.value, space=runtime.space?.value, values=runtime.app_environment?.value; + if(cf?.api_url!=="https://api.system.01.cf.eu01.stackit.cloud" || cf?.org_id!=="a4514c42-d378-4e36-90eb-a3a1e3017e65" || space?.name!=="configurator" || !space.id || !cf.username || !cf.password || !values)throw new Error("Unexpected CF destination or incomplete runtime"); + const keys=["LZC_DATABASE_HOST","LZC_DATABASE_PORT","LZC_DATABASE_NAME","LZC_DATABASE_USER","LZC_DATABASE_PASSWORD","LZC_SECRETS_ADDRESS","LZC_SECRETS_INSTANCE_ID","LZC_SECRETS_USERNAME","LZC_SECRETS_PASSWORD","LZC_MODEL_SERVING_TOKEN"]; + const vars={};for(const key of keys){if(typeof values[key]!=="string"||!values[key])throw new Error(`Missing runtime input ${key}`);mask(values[key]);vars[key]=values[key];} + writeFileSync(resolve(dir,"app-vars.json"),JSON.stringify(vars),{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 new file mode 100644 index 0000000..9ef48cf --- /dev/null +++ b/landing-zone-configurator/infra/ci/prepare-release.test.mjs @@ -0,0 +1,31 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, writeFileSync, readFileSync, statSync, rmSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +const script=fileURLToPath(new URL("./prepare-release.mjs",import.meta.url)); +test("release uses private inputs, rejects wrong destinations and omits privileged credentials",()=>{ + const dir=mkdtempSync(join(tmpdir(),"lzc-release-test-")); + const env={...process.env,GITHUB_REPOSITORY:"stackitcloud/stackit-landing-zone",GITHUB_REF:"refs/heads/feature/landing-zone-configurator",RUNNER_TEMP:dir,GITHUB_ENV:join(dir,"environment"),LZC_WORKLOAD_BUCKET:"lzc-dev-state-7dbff805",LZC_STATE_KEY_PLATFORM:"p".repeat(40),LZC_STATE_KEY_RUNTIME:"r".repeat(40),LZC_WORKLOAD_ACCESS_KEY:"test-access",LZC_WORKLOAD_SECRET_KEY:"test-secret"}; + const run=mode=>spawnSync(process.execPath,[script,mode],{env,encoding:"utf8"}); + try { + assert.equal(run("backend").status,0); + const privateDir=join(dir,"lzc-release"); + const cf={api_url:"https://api.system.01.cf.eu01.stackit.cloud",org_id:"a4514c42-d378-4e36-90eb-a3a1e3017e65",username:"cf-user",password:"cf-secret"}; + writeFileSync(join(privateDir,"platform-outputs.json"),JSON.stringify({cf_runtime:{value:cf}})); + const keys=["LZC_DATABASE_HOST","LZC_DATABASE_PORT","LZC_DATABASE_NAME","LZC_DATABASE_USER","LZC_DATABASE_PASSWORD","LZC_SECRETS_ADDRESS","LZC_SECRETS_INSTANCE_ID","LZC_SECRETS_USERNAME","LZC_SECRETS_PASSWORD","LZC_MODEL_SERVING_TOKEN"]; + const runtime={space:{value:{id:"space-guid",name:"wrong-space"}},app_environment:{value:Object.fromEntries(keys.map(key=>[key,`test-${key}`]))}}; + runtime.app_environment.value.LZC_SERVICE_ACCOUNT_KEY="must-not-be-forwarded"; + writeFileSync(join(privateDir,"runtime-outputs.json"),JSON.stringify(runtime)); + assert.notEqual(run("application").status,0); + assert.equal(existsSync(join(privateDir,"app-vars.json")),false); + runtime.space.value.name="configurator"; + writeFileSync(join(privateDir,"runtime-outputs.json"),JSON.stringify(runtime)); + const result=run("application");assert.equal(result.status,0,result.stderr); + const vars=join(privateDir,"app-vars.json");assert.equal(statSync(vars).mode&0o777,0o600); + assert.deepEqual(Object.keys(JSON.parse(readFileSync(vars))),keys); + assert.ok(!readFileSync(env.GITHUB_ENV,"utf8").includes("must-not-be-forwarded")); + } finally {rmSync(dir,{recursive:true,force:true});} +}); From f4c9242bbd4dd07aa224c40ebbad0dda064600fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Weberru=C3=9F?= Date: Wed, 30 Sep 2026 09:15:50 +0200 Subject: [PATCH 020/175] fix(configurator): diagnose CF network and separate route checks --- .github/workflows/configurator-release.yml | 7 ++- .../app/apps/api/src/check-network.ts | 51 +++++++++++++++++++ .../app/apps/api/src/connectivity.ts | 2 + .../infra/ci/prepare-release.mjs | 2 +- 4 files changed, 60 insertions(+), 2 deletions(-) create mode 100644 landing-zone-configurator/app/apps/api/src/check-network.ts diff --git a/.github/workflows/configurator-release.yml b/.github/workflows/configurator-release.yml index e1d2bfe..41d0343 100644 --- a/.github/workflows/configurator-release.yml +++ b/.github/workflows/configurator-release.yml @@ -139,15 +139,20 @@ jobs: cf target -o lzc-dev -s configurator test "$(cf space configurator --guid)" = "$LZC_CF_SPACE_ID" - name: CF push application + id: push run: cf push -f app/cf/manifest.yml -p .local/release --vars-file "$LZC_PRIVATE/app-vars.json" --redact-env + - name: CF network diagnostics + timeout-minutes: 3 + run: cf run-task landing-zone-configurator --command './runtime/bin/node apps/api/dist/check-network.js' --name "network-$GITHUB_RUN_ID" -m 128M -k 512M --wait - name: CF service connection tests id: connectivity timeout-minutes: 5 run: cf run-task landing-zone-configurator --command './runtime/bin/node apps/api/dist/check-connections.js' --name "connections-$GITHUB_RUN_ID" -m 128M -k 512M --wait - name: CF application and connection logs - if: always() && steps.connectivity.outcome != '' && steps.connectivity.outcome != 'skipped' + if: always() && steps.push.outcome == 'success' run: cf logs landing-zone-configurator --recent - name: Public route checks + if: always() && steps.push.outcome == 'success' env: LZC_URL: https://lzc-dev-configurator-7dbff805.apps.01.cf.eu01.stackit.cloud run: | diff --git a/landing-zone-configurator/app/apps/api/src/check-network.ts b/landing-zone-configurator/app/apps/api/src/check-network.ts new file mode 100644 index 0000000..b1b4ec7 --- /dev/null +++ b/landing-zone-configurator/app/apps/api/src/check-network.ts @@ -0,0 +1,51 @@ +// Non-secret diagnostics confined to the deployment's own service endpoints. +import { lookup } from "node:dns/promises"; +import { createConnection } from "node:net"; + +const host = process.env.LZC_DATABASE_HOST; +const port = Number(process.env.LZC_DATABASE_PORT); +if (!host || !Number.isInteger(port) || port < 1 || port > 65535) { + throw new Error("Database endpoint missing"); +} +try { + const addresses = await lookup(host, { all: true }); + console.log(JSON.stringify({ check: "postgresql-dns", addresses })); + const started = Date.now(); + await new Promise((resolve, reject) => { + const socket = createConnection({ host, port, timeout: 5000 }); + socket.once("connect", () => { + socket.destroy(); + resolve(); + }); + socket.once("timeout", () => { + socket.destroy(); + reject(new Error("tcp-timeout")); + }); + socket.once("error", () => { + socket.destroy(); + reject(new Error("tcp-error")); + }); + }); + console.log( + JSON.stringify({ + check: "postgresql-tcp", + ok: true, + elapsedMs: Date.now() - started, + }), + ); +} catch { + console.log(JSON.stringify({ check: "postgresql-network", ok: false })); +} +try { + // CF router access logs record the source of this request to our own app. + const response = await fetch( + "https://lzc-dev-configurator-7dbff805.apps.01.cf.eu01.stackit.cloud/healthz?probe=cf-network", + { redirect: "error", signal: AbortSignal.timeout(10000) }, + ); + await response.arrayBuffer(); + console.log( + JSON.stringify({ check: "own-route-from-cf", status: response.status }), + ); +} catch { + console.log(JSON.stringify({ check: "own-route-from-cf", ok: false })); +} diff --git a/landing-zone-configurator/app/apps/api/src/connectivity.ts b/landing-zone-configurator/app/apps/api/src/connectivity.ts index fabf0ed..fd1f5c5 100644 --- a/landing-zone-configurator/app/apps/api/src/connectivity.ts +++ b/landing-zone-configurator/app/apps/api/src/connectivity.ts @@ -100,6 +100,8 @@ export async function checkSecrets(): Promise { export function safeFailure(error: unknown): string { // Raw network/driver errors may contain credentials or service responses. + if (error instanceof Error && error.message === "timeout expired") + return "connection-timeout"; if ( error instanceof Error && /^(missing-configuration|tls-required|unexpected-service-address|invalid-instance|missing-session-token|unexpected-runtime-policy|session-revoke-failed|(?:login|capabilities|metadata)-http-\d{3})$/.test( diff --git a/landing-zone-configurator/infra/ci/prepare-release.mjs b/landing-zone-configurator/infra/ci/prepare-release.mjs index 174dda5..8c45248 100644 --- a/landing-zone-configurator/infra/ci/prepare-release.mjs +++ b/landing-zone-configurator/infra/ci/prepare-release.mjs @@ -30,7 +30,7 @@ if(mode==="backend") { const cf=platform.cf_runtime?.value, space=runtime.space?.value, values=runtime.app_environment?.value; if(cf?.api_url!=="https://api.system.01.cf.eu01.stackit.cloud" || cf?.org_id!=="a4514c42-d378-4e36-90eb-a3a1e3017e65" || space?.name!=="configurator" || !space.id || !cf.username || !cf.password || !values)throw new Error("Unexpected CF destination or incomplete runtime"); const keys=["LZC_DATABASE_HOST","LZC_DATABASE_PORT","LZC_DATABASE_NAME","LZC_DATABASE_USER","LZC_DATABASE_PASSWORD","LZC_SECRETS_ADDRESS","LZC_SECRETS_INSTANCE_ID","LZC_SECRETS_USERNAME","LZC_SECRETS_PASSWORD","LZC_MODEL_SERVING_TOKEN"]; - const vars={};for(const key of keys){if(typeof values[key]!=="string"||!values[key])throw new Error(`Missing runtime input ${key}`);mask(values[key]);vars[key]=values[key];} + const vars={};for(const key of keys){if(typeof values[key]!=="string"||!values[key])throw new Error(`Missing runtime input ${key}`);if(/_(PASSWORD|TOKEN|USERNAME|USER)$/.test(key))mask(values[key]);vars[key]=values[key];} writeFileSync(resolve(dir,"app-vars.json"),JSON.stringify(vars),{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); } From 6840d4ee17a5143d0b2a036e818795ad180c9508 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Weberru=C3=9F?= Date: Wed, 30 Sep 2026 09:18:59 +0200 Subject: [PATCH 021/175] fix(configurator): scope pipeline triggers to their deployment lifecycle --- .github/workflows/configurator-bootstrap.yml | 10 +++++++++- .github/workflows/configurator-platform.yml | 8 +++++++- .github/workflows/configurator-runtime.yml | 9 +++++++-- 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/.github/workflows/configurator-bootstrap.yml b/.github/workflows/configurator-bootstrap.yml index 56dbd59..bb8d509 100644 --- a/.github/workflows/configurator-bootstrap.yml +++ b/.github/workflows/configurator-bootstrap.yml @@ -4,7 +4,15 @@ on: push: branches: [feature/landing-zone-configurator] paths: - - 'landing-zone-configurator/infra/**' + - 'landing-zone-configurator/infra/bootstrap/**' + - 'landing-zone-configurator/infra/backend/**' + - 'landing-zone-configurator/infra/ci/prepare.mjs' + - 'landing-zone-configurator/infra/ci/context.mjs' + - 'landing-zone-configurator/infra/ci/review.mjs' + - 'landing-zone-configurator/infra/ci/protect-state.mjs' + - 'landing-zone-configurator/infra/ci/artifact.mjs' + - 'landing-zone-configurator/infra/ci/backend-safety.json' + - 'landing-zone-configurator/infra/plan-guard.mjs' - '.github/workflows/configurator-bootstrap.yml' workflow_dispatch: inputs: diff --git a/.github/workflows/configurator-platform.yml b/.github/workflows/configurator-platform.yml index 612246d..df0d633 100644 --- a/.github/workflows/configurator-platform.yml +++ b/.github/workflows/configurator-platform.yml @@ -6,7 +6,13 @@ on: paths: - 'landing-zone-configurator/infra/platform/**' - 'landing-zone-configurator/infra/environments/lzc-dev.tfvars.json' - - 'landing-zone-configurator/infra/ci/**' + - 'landing-zone-configurator/infra/ci/prepare.mjs' + - 'landing-zone-configurator/infra/ci/context.mjs' + - 'landing-zone-configurator/infra/ci/review.mjs' + - 'landing-zone-configurator/infra/ci/protect-state.mjs' + - 'landing-zone-configurator/infra/ci/artifact.mjs' + - 'landing-zone-configurator/infra/ci/backend-safety.json' + - 'landing-zone-configurator/infra/plan-guard.mjs' - '.github/workflows/configurator-platform.yml' workflow_dispatch: inputs: diff --git a/.github/workflows/configurator-runtime.yml b/.github/workflows/configurator-runtime.yml index ce9d4bf..9f3aab9 100644 --- a/.github/workflows/configurator-runtime.yml +++ b/.github/workflows/configurator-runtime.yml @@ -5,8 +5,13 @@ on: branches: [feature/landing-zone-configurator] paths: - 'landing-zone-configurator/infra/runtime/**' - - 'landing-zone-configurator/infra/environments/lzc-dev.tfvars.json' - - 'landing-zone-configurator/infra/ci/**' + - 'landing-zone-configurator/infra/ci/prepare.mjs' + - 'landing-zone-configurator/infra/ci/context.mjs' + - 'landing-zone-configurator/infra/ci/review.mjs' + - 'landing-zone-configurator/infra/ci/protect-state.mjs' + - 'landing-zone-configurator/infra/ci/artifact.mjs' + - 'landing-zone-configurator/infra/ci/backend-safety.json' + - 'landing-zone-configurator/infra/plan-guard.mjs' - '.github/workflows/configurator-runtime.yml' workflow_dispatch: inputs: From 3384a987a45a07a4bab8e34cf0d20247fbe3f578 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Weberru=C3=9F?= Date: Wed, 30 Sep 2026 09:20:27 +0200 Subject: [PATCH 022/175] fix(configurator): allow verified STACKIT network for CF service access --- .../docs/platform-readiness.md | 43 +++++++++++++++++++ .../infra/environments/lzc-dev.tfvars.json | 6 ++- 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/landing-zone-configurator/docs/platform-readiness.md b/landing-zone-configurator/docs/platform-readiness.md index c44a919..398f649 100644 --- a/landing-zone-configurator/docs/platform-readiness.md +++ b/landing-zone-configurator/docs/platform-readiness.md @@ -112,3 +112,46 @@ Abnahme der direkten CLI-Pipelines: Bootstrap-Run 36677111141 und Plattform-Run Backend-Abnahme des Umbaus: [Run 36677367229](https://github.com/stackitcloud/stackit-landing-zone/actions/runs/36677367229) hat Root `backend` mit getrennten Management-/Workload-Zugängen geplant: `No changes`, Apply übersprungen. Root, Commit, Run, Plan-Hash und verschlüsseltes Artefakt geprüft. Native Logs sichtbar und ohne bekannte Credential-Werte. [Validierung 36677367252](https://github.com/stackitcloud/stackit-landing-zone/actions/runs/36677367252) erfolgreich. Temporäre Root-Auswahl entfernt; keine Apply-Freigaben gesetzt. Bootstrap, Backend und Plattform wurden damit lesend geprüft, ohne Cloud-Apply oder State-Migration durch den Umbau. + + +## CF-Runtime und erster App-Release (2026-09-30) + +- [x] Separater Runtime-Root mit verschlüsseltem S3-State und Cloud-Foundry-Provider 1.17.0. +- [x] Space `configurator`, explizite `organization_user`-Mitgliedschaft und `space_developer`-Rolle erstellt. +- [x] Eigene PostgreSQL-Login-Rolle `configurator_app` und Secrets-Manager-Benutzer mit Read-only-Recht. +- [x] [Runtime-Apply 36681789484](https://github.com/stackitcloud/stackit-landing-zone/actions/runs/36681789484) erfolgreich; fünf Ressourcen unabhängig im verschlüsselten Remote-State bestätigt. +- [x] Web/API über getrennten Release-Workflow mit direkten `cf`-Befehlen bereitgestellt; Model-Serving-Token nur im Backend-Environment. +- [x] [Release 36682016739](https://github.com/stackitcloud/stackit-landing-zone/actions/runs/36682016739): Build und CF-Push erfolgreich, Anwendung gestartet. Native CLI-Logs auf bekannte Credential-Werte geprüft: keine Treffer. +- [ ] PostgreSQL-Verbindung: erster CF-Task scheitert nach 15 Sekunden; Netz-/TCP-Diagnose folgt. +- [ ] Secrets Manager: erster CF-Task erhält HTTP 403 beim Login; laut [STACKIT-Diagnose](https://docs.stackit.cloud/products/security/secrets-manager/how-tos/handle-authentication-lockouts/) Hinweis auf Quell-IP-ACL. + +Der erste Runtime-Apply erstellte Space und Benutzer, scheiterte aber an der fehlenden +CF-Organisationsmitgliedschaft. Der State blieb vollständig erhalten. Ein frischer +Plan ergänzte ausschließlich die zwei noch fehlenden Rollen; kein Import oder +Credential-Reset war erforderlich. + +Die CF-Foundation erlaubt öffentliche Ziele in ihrer globalen `public_networks`- +Security-Group. Das ist noch kein Nachweis für den jeweiligen Dienstpfad. Die bisher +verwendeten zwei STACKIT-Netze decken außerdem nicht den aktuellen öffentlichen +Adresskatalog ab. Die offizielle API `GET /v2/networks/public-ip-ranges` liefert +regionale Netze; Änderungen werden anhand des realen CF-Tests und eines neuen +IaC-Plans beurteilt. Keine beobachtete Einzel-IP wird als stabiler CF-Egress zugesagt. +Quelle für den aktuellen Katalog: [STACKIT IP-Ranges](https://docs.stackit.cloud/products/logging-and-monitoring/logme/how-tos/create-and-manage-logme-services/). + + +### Nachgewiesener ACL-Fehler und Korrekturplan + +Der Diagnose-Task in [Release 36682707060](https://github.com/stackitcloud/stackit-landing-zone/actions/runs/36682707060) +löst den PostgreSQL-Host korrekt auf, bekommt aber keine TCP-Verbindung. Ein +HTTPS-Aufruf desselben CF-Tasks zur eigenen Configurator-Route liefert HTTP 200; +der zugehörige CF-Router-Logeintrag zeigt eine Quelle aus `45.135.244.0/22`. +Dieses eu01-Netz ist im aktuellen STACKIT-IP-Katalog enthalten und wird auch in +der [STACKIT LogMe-FAQ](https://docs.stackit.cloud/products/logging-and-monitoring/logme/faq/) +als STACKIT-Standardnetz aufgeführt. Es fehlte in beiden expliziten ACLs. + +Der neue IaC-Plan ergänzt deshalb genau `45.135.244.0/22` in den getrennten +PostgreSQL- und Secrets-ACLs. Die beobachtete Einzel-IP wird nicht konfiguriert. +Dies bleibt eine Freigabe eines geteilten STACKIT-Netzes, keine mandanteneigene +oder zugesichert stabile CF-Ausgangsadresse. TLS, Benutzerrechte und spätere +Mandantenautorisierung bleiben separate Schutzschichten. Bei Netzänderungen +Katalog und CF-Test erneut prüfen; kein automatisches Öffnen weiterer Netze. diff --git a/landing-zone-configurator/infra/environments/lzc-dev.tfvars.json b/landing-zone-configurator/infra/environments/lzc-dev.tfvars.json index 33fef0a..c177a04 100644 --- a/landing-zone-configurator/infra/environments/lzc-dev.tfvars.json +++ b/landing-zone-configurator/infra/environments/lzc-dev.tfvars.json @@ -11,10 +11,12 @@ }, "database_access_cidrs": [ "193.148.160.0/19", - "45.129.40.0/21" + "45.129.40.0/21", + "45.135.244.0/22" ], "secrets_access_cidrs": [ "193.148.160.0/19", - "45.129.40.0/21" + "45.129.40.0/21", + "45.135.244.0/22" ] } From 7dcef653a7c504ccc9577f58bb0afce3150f16b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Weberru=C3=9F?= Date: Wed, 30 Sep 2026 09:22:33 +0200 Subject: [PATCH 023/175] docs(configurator): document CF network diagnosis and rerun release checks --- landing-zone-configurator/app/cf/README.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/landing-zone-configurator/app/cf/README.md b/landing-zone-configurator/app/cf/README.md index bdb332b..90c36dd 100644 --- a/landing-zone-configurator/app/cf/README.md +++ b/landing-zone-configurator/app/cf/README.md @@ -41,3 +41,17 @@ The initial single-instance `cf push` can interrupt service briefly. Immutable archive/checksum retention is seven days; explicit rollback/promotion and rolling releases are subsequent milestones. DB migrations, schema grants, RLS, personal credential storage and customer deployment execution are not part of this release. + +## Network verification + +The release includes a diagnostic CF task for DNS/TCP and an HTTPS request to the +Configurator's own route. The matching CF router entry identifies the observed +source network without contacting an external IP-diagnostic service. This is a +point-in-time observation, not a promise of stable CF egress addresses. + +The first connection test exposed a missing STACKIT network in the service ACLs. +The official eu01 catalog and our own router logs support adding +`45.135.244.0/22` through platform IaC. Network policy changes require a reviewed +platform plan and then a fresh CF connectivity test. The public UI/API checks run +even if a service connection fails, so route health and service health remain +separately visible. See [platform acceptance](../../docs/platform-readiness.md). From 795a18a15767f53f373a67b0dc9d53a5f47f0fc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Weberru=C3=9F?= Date: Wed, 30 Sep 2026 09:28:30 +0200 Subject: [PATCH 024/175] fix(configurator): check documented Secrets API and align release layout --- .github/workflows/configurator-release.yml | 3 +- landing-zone-configurator/README.md | 3 +- landing-zone-configurator/app/README.md | 4 +- .../app/apps/api/src/connectivity.ts | 39 +++++------- landing-zone-configurator/app/cf/README.md | 57 ----------------- .../app/tests/connectivity.test.ts | 60 ++++++++++-------- .../deploy/cloud-foundry/README.md | 61 ++++++++++++++++++- .../cf => deploy/cloud-foundry}/manifest.yml | 0 landing-zone-configurator/docs/cicd.md | 2 +- landing-zone-configurator/docs/planning.md | 40 +++++++++++- landing-zone-configurator/infra/README.md | 8 +-- landing-zone-configurator/infra/ci/README.md | 27 +++++++- 12 files changed, 179 insertions(+), 125 deletions(-) delete mode 100644 landing-zone-configurator/app/cf/README.md rename landing-zone-configurator/{app/cf => deploy/cloud-foundry}/manifest.yml (100%) diff --git a/.github/workflows/configurator-release.yml b/.github/workflows/configurator-release.yml index 41d0343..e9ddb44 100644 --- a/.github/workflows/configurator-release.yml +++ b/.github/workflows/configurator-release.yml @@ -5,6 +5,7 @@ on: branches: [feature/landing-zone-configurator] paths: - 'landing-zone-configurator/app/**' + - 'landing-zone-configurator/deploy/cloud-foundry/**' - 'landing-zone-configurator/infra/ci/prepare-release.mjs' - '.github/workflows/configurator-release.yml' workflow_dispatch: @@ -140,7 +141,7 @@ jobs: test "$(cf space configurator --guid)" = "$LZC_CF_SPACE_ID" - name: CF push application id: push - run: cf push -f app/cf/manifest.yml -p .local/release --vars-file "$LZC_PRIVATE/app-vars.json" --redact-env + run: cf push -f deploy/cloud-foundry/manifest.yml -p .local/release --vars-file "$LZC_PRIVATE/app-vars.json" --redact-env - name: CF network diagnostics timeout-minutes: 3 run: cf run-task landing-zone-configurator --command './runtime/bin/node apps/api/dist/check-network.js' --name "network-$GITHUB_RUN_ID" -m 128M -k 512M --wait diff --git a/landing-zone-configurator/README.md b/landing-zone-configurator/README.md index 982d4f6..df70eae 100644 --- a/landing-zone-configurator/README.md +++ b/landing-zone-configurator/README.md @@ -2,7 +2,7 @@ Zentral gehostete, mandantenfähige Anwendung zum Erstellen, Bearbeiten und Deployen von Konfigurationen des STACKIT Landing Zone Accelerators. -Status: React-/Fastify-Entwicklungsbasis mit Tests. Verwaltungs-Backend, Bootstrap und neun Plattform-Ressourcen sind per IaC provisioniert und geprüft. Die Pipelines führen OpenTofu direkt mit sichtbaren CLI-Logs aus. CF-Runtime und App-Deployment stehen noch aus. [IaC-Bedienung](infra/README.md), [CI-Betrieb](infra/ci/README.md). +Status: React-/Fastify-Entwicklungsbasis mit Tests. Verwaltungs-Backend, Bootstrap und neun Plattform-Ressourcen sind per IaC provisioniert und geprüft. Die Pipelines führen OpenTofu direkt mit sichtbaren CLI-Logs aus. CF-Space und Laufzeitidentitäten sind provisioniert, Web/API auf CF bereitgestellt. Die Verbindungstests werden aus CF ausgeführt; Details im [Betriebsstand](docs/platform-readiness.md). [IaC-Bedienung](infra/README.md), [CI-Betrieb](infra/ci/README.md). Start und Prüfungen: [Entwicklungsanleitung](app/README.md). Ergebnisse der lesenden Bestandsaufnahme: [Plattformprüfung](docs/platform-check.md). @@ -30,6 +30,7 @@ landing-zone-configurator/ bootstrap/ # State-/Betriebsgrundlage vor Plattformaufbau backend/ # S3-Versionierung nach dem State-Bootstrap platform/ # Eigener OpenTofu-Root für Configurator-Dienste + runtime/ # CF-Space, Rollen und Laufzeitidentitäten modules/ # Nur Configurator-Infrastrukturmodule environments/ # Secretfreie Umgebungsparameter deploy/ diff --git a/landing-zone-configurator/app/README.md b/landing-zone-configurator/app/README.md index 7e63026..277be93 100644 --- a/landing-zone-configurator/app/README.md +++ b/landing-zone-configurator/app/README.md @@ -36,7 +36,7 @@ npm run dev:web Auch diese Befehle können bei abweichender lokaler Node-Version mit dem oben gezeigten `npm exec`-Präfix ausgeführt werden. Der Vite-Entwicklungsserver leitet `/healthz` und `/api` an die lokale API weiter. Gemeinsame Pakete vor UI-Start bauen (`npm run build:packages`, bereits Teil von `npm run dev`). Nach Änderungen an gemeinsamen Paketen erneut bauen; automatischer Workspace-Watch folgt später. -`npm run build` erzeugt API-/Paket-Artefakte und `apps/web/dist`. `npm start` startet nur die gebaute API. Die Auslieferung der statischen UI auf CF gehört zum nächsten Deployment-Schritt; `vite preview` ist kein Produktionsserver. Bei CF später `HOST=0.0.0.0` und den von CF bereitgestellten `PORT` nutzen. +`npm run build` erzeugt API-/Paket-Artefakte und `apps/web/dist`. `npm start` startet die gebaute API. Mit `LZC_WEB_ROOT` liefert sie auch das gebaute UI aus. Das [CF-Manifest](../deploy/cloud-foundry/manifest.yml) setzt den Pfad, `HOST=0.0.0.0` und verwendet den von CF bereitgestellten `PORT`. `vite preview` ist kein Produktionsserver. ## Aktueller Funktionsumfang @@ -46,7 +46,7 @@ Auch diese Befehle können bei abweichender lokaler Node-Version mit dem oben ge - Fachliche Tenant-/Rollen-Policy mit Negativtests. Mitgliedschaften müssen später aus authentisiertem serverseitigem Speicher kommen. - Worker ist ein kompilierbarer Platzhalter und beendet sich beim Start ausdrücklich mit Fehler, bis dauerhafte Queue und Runner integriert sind. -Noch keine GitHub-Anmeldung, Datenbank, RLS, Templates, Credential-Speicherung oder Deployments. Die getestete Policy allein ist kein Nachweis vollständiger Mehrmandantenfähigkeit. +PostgreSQL und Secrets Manager sind provisioniert; CF-Tasks prüfen deren Anbindung. Noch keine GitHub-Anmeldung, Anwendungsschemata/RLS, Templates, persönliche Credential-Speicherung oder Kunden-Deployments. Die getestete Policy allein ist kein Nachweis vollständiger Mehrmandantenfähigkeit. ## Lesende Plattformprüfung diff --git a/landing-zone-configurator/app/apps/api/src/connectivity.ts b/landing-zone-configurator/app/apps/api/src/connectivity.ts index fd1f5c5..08915b1 100644 --- a/landing-zone-configurator/app/apps/api/src/connectivity.ts +++ b/landing-zone-configurator/app/apps/api/src/connectivity.ts @@ -62,32 +62,21 @@ export async function checkSecrets(): Promise { }; let revokeSucceeded = false; try { - // Verify the instance-specific read policy, without reading any tenant secrets. + // Reserved operator probe path; never read tenant paths or list tenant keys. + // STACKIT supports KV v2, not the general Vault system/capabilities API. const path = `${instance}/data/configurator/connectivity-probe`; - const capabilities = await request("sys/capabilities-self", { - method: "POST", - headers, - body: JSON.stringify({ paths: [path] }), - }); - if (!capabilities.ok) - throw new Error(`capabilities-http-${capabilities.status}`); - const data = (await capabilities.json()) as Record; - const permissions = data[path]; - if ( - !Array.isArray(permissions) || - !permissions.includes("read") || - permissions.includes("create") || - permissions.includes("update") - ) { - throw new Error("unexpected-runtime-policy"); + const anonymous = await request(path, {}); + await anonymous.arrayBuffer(); + if (anonymous.status !== 401 && anonymous.status !== 403) { + throw new Error("anonymous-access-not-denied"); } - const listing = await request(`${instance}/metadata/?list=true`, { - headers, - }); - // An empty, authorized KV store returns 404. A denied request returns 403. - if (listing.status !== 200 && listing.status !== 404) - throw new Error(`metadata-http-${listing.status}`); - await listing.arrayBuffer(); // Discard secret names; never log responses. + const read = await request(path, { headers }); + // 404 is expected for the absent probe key, but only after the same endpoint + // has rejected anonymous access. An unsupported route returning 404 to both + // callers must not count as success. This is not a secret write/read roundtrip. + if (read.status !== 200 && read.status !== 404) + throw new Error(`read-http-${read.status}`); + await read.arrayBuffer(); } finally { const revoked = await request("auth/token/revoke-self", { method: "POST", @@ -104,7 +93,7 @@ export function safeFailure(error: unknown): string { return "connection-timeout"; if ( error instanceof Error && - /^(missing-configuration|tls-required|unexpected-service-address|invalid-instance|missing-session-token|unexpected-runtime-policy|session-revoke-failed|(?:login|capabilities|metadata)-http-\d{3})$/.test( + /^(missing-configuration|tls-required|unexpected-service-address|invalid-instance|missing-session-token|anonymous-access-not-denied|session-revoke-failed|(?:login|read)-http-\d{3})$/.test( error.message, ) ) diff --git a/landing-zone-configurator/app/cf/README.md b/landing-zone-configurator/app/cf/README.md deleted file mode 100644 index 90c36dd..0000000 --- a/landing-zone-configurator/app/cf/README.md +++ /dev/null @@ -1,57 +0,0 @@ -# CF application release - -`.github/workflows/configurator-release.yml` builds and tests the application, -packages production dependencies and the checksum-pinned Node 24.21.0 Linux runtime, -and deploys the same archive with explicit `cf` CLI commands. The CF node buildpack -currently offers an older Node version, so this release uses `binary_buildpack`. -Update the version and checksum together when upgrading Node. The CF stack remains -platform-maintained (`cflinuxfs4`). - -`manifest.yml` owns the application, route, process size, health check and backend -environment bindings. OpenTofu owns the CF org/space/roles and backing services; -it does not also manage the application. The worker is not deployed yet. - -The protected `lzc-dev-release` Environment contains workload S3 credentials and -platform/runtime state keys. No project service-account key is supplied to the -release workflow. `tofu output -json` writes private files; `prepare-release.mjs` -only extracts explicitly allowed backend variables and masks them. `cf push` -uses a private variables file and `--redact-env`; neither credentials nor decrypted -states are included in the application artifact. The app receives only its own -PostgreSQL/Secrets credentials and the operator Model Serving token. - -For this development milestone the CF deployment identity is still the existing -org manager with explicit space developer membership, and release reads platform -state. A dedicated space-only deployer and narrower state/credential publication -are required before production. CF environment variables are visible to authorized -space developers; restrict and audit those roles. The public app remains a foundation -page: protected API routes refuse requests until real authentication exists. - -After push, a CF task performs PostgreSQL authentication, a query with verified TLS, -and Secrets Manager authentication plus instance-specific read authorization and -metadata access. No tenant secrets are read or written. Diagnostic logs only show -service names, result and sanitized failure categories. The task revokes its Vault -token. A health check alone does not prove service connectivity. - -All mutations share `configurator-lzc-dev-mutation` with infrastructure workflows. -Feature-branch releases require Environment approval. A superseded branch revision -is rejected before deployment. GitHub concurrency is not FIFO: newer pending runs -can replace older pending runs. Never run local applies or pushes concurrently. - -The initial single-instance `cf push` can interrupt service briefly. Immutable -archive/checksum retention is seven days; explicit rollback/promotion and rolling -releases are subsequent milestones. DB migrations, schema grants, RLS, personal -credential storage and customer deployment execution are not part of this release. - -## Network verification - -The release includes a diagnostic CF task for DNS/TCP and an HTTPS request to the -Configurator's own route. The matching CF router entry identifies the observed -source network without contacting an external IP-diagnostic service. This is a -point-in-time observation, not a promise of stable CF egress addresses. - -The first connection test exposed a missing STACKIT network in the service ACLs. -The official eu01 catalog and our own router logs support adding -`45.135.244.0/22` through platform IaC. Network policy changes require a reviewed -platform plan and then a fresh CF connectivity test. The public UI/API checks run -even if a service connection fails, so route health and service health remain -separately visible. See [platform acceptance](../../docs/platform-readiness.md). diff --git a/landing-zone-configurator/app/tests/connectivity.test.ts b/landing-zone-configurator/app/tests/connectivity.test.ts index a4383fe..a9dc04b 100644 --- a/landing-zone-configurator/app/tests/connectivity.test.ts +++ b/landing-zone-configurator/app/tests/connectivity.test.ts @@ -14,29 +14,39 @@ describe("connection diagnostics", () => { "28P01", ); }); - it("checks the instance policy and revokes the temporary session", async () => { - const id = "00000000-0000-4000-8000-000000000001"; - vi.stubEnv("LZC_SECRETS_ADDRESS", "https://prod.sm.eu01.stackit.cloud"); - vi.stubEnv("LZC_SECRETS_INSTANCE_ID", id); - vi.stubEnv("LZC_SECRETS_USERNAME", "runtime"); - vi.stubEnv("LZC_SECRETS_PASSWORD", "fake-test-password"); - const fetch = vi - .fn() - .mockResolvedValueOnce( - Response.json({ auth: { client_token: "fake-token" } }), - ) - .mockResolvedValueOnce( - Response.json({ - [`${id}/data/configurator/connectivity-probe`]: ["read"], - }), - ) - .mockResolvedValueOnce(new Response(null, { status: 404 })) - .mockResolvedValueOnce(new Response(null, { status: 204 })); - vi.stubGlobal("fetch", fetch); - await expect(checkSecrets()).resolves.toBeUndefined(); - expect(fetch).toHaveBeenLastCalledWith( - "https://prod.sm.eu01.stackit.cloud/v1/auth/token/revoke-self", - expect.objectContaining({ method: "POST", redirect: "error" }), - ); - }); + it.each([403, 404])( + "rejects anonymous access (%s) and revokes the temporary session", + async (anonymousStatus) => { + const id = "00000000-0000-4000-8000-000000000001"; + vi.stubEnv("LZC_SECRETS_ADDRESS", "https://prod.sm.eu01.stackit.cloud"); + vi.stubEnv("LZC_SECRETS_INSTANCE_ID", id); + vi.stubEnv("LZC_SECRETS_USERNAME", "runtime"); + vi.stubEnv("LZC_SECRETS_PASSWORD", "fake-test-password"); + const fetch = vi + .fn() + .mockResolvedValueOnce( + Response.json({ auth: { client_token: "fake-token" } }), + ) + .mockResolvedValueOnce(new Response(null, { status: anonymousStatus })); + if (anonymousStatus === 403) + fetch.mockResolvedValueOnce(new Response(null, { status: 404 })); + fetch.mockResolvedValueOnce(new Response(null, { status: 204 })); + vi.stubGlobal("fetch", fetch); + if (anonymousStatus === 403) + await expect(checkSecrets()).resolves.toBeUndefined(); + else + await expect(checkSecrets()).rejects.toThrow( + "anonymous-access-not-denied", + ); + expect(fetch).toHaveBeenLastCalledWith( + "https://prod.sm.eu01.stackit.cloud/v1/auth/token/revoke-self", + expect.objectContaining({ method: "POST", redirect: "error" }), + ); + expect(fetch).toHaveBeenNthCalledWith( + 2, + `https://prod.sm.eu01.stackit.cloud/v1/${id}/data/configurator/connectivity-probe`, + expect.not.objectContaining({ headers: expect.anything() }), + ); + }, + ); }); diff --git a/landing-zone-configurator/deploy/cloud-foundry/README.md b/landing-zone-configurator/deploy/cloud-foundry/README.md index a83d0e9..3659ba0 100644 --- a/landing-zone-configurator/deploy/cloud-foundry/README.md +++ b/landing-zone-configurator/deploy/cloud-foundry/README.md @@ -1,5 +1,60 @@ -# Cloud-Foundry-Releases +# CF application release -Hier künftig Manifeste und Release-Einstellungen für Web/API/Worker beziehungsweise getrennte Runner-Apps verwalten. Infrastruktur-Lifecycle in `../../infra/`; App-Versionen und Startkommandos hier. Eine Ressource erhält einen eindeutigen Lifecycle-Owner, keine konkurrierende Verwaltung durch OpenTofu und `cf push`. Secrets nur geschützt zur Laufzeit injizieren. +`.github/workflows/configurator-release.yml` builds and tests the application, +packages production dependencies and the checksum-pinned Node 24.21.0 Linux runtime, +and deploys the same archive with explicit `cf` CLI commands. The CF node buildpack +currently offers an older Node version, so this release uses `binary_buildpack`. +Update the version and checksum together when upgrading Node. The CF stack remains +platform-maintained (`cflinuxfs4`). -Das [CI/CD-Konzept](../../docs/cicd.md) legt den Release-Prozess als Owner für App-Objekte, App-Routen, App-Bindings und Code fest. OpenTofu verwaltet die zugrunde liegenden Dienste, Spaces und Rollen. Deployment-Workflows und Release-Manifeste sind noch umzusetzen. +`manifest.yml` owns the application, route, process size, health check and backend +environment bindings. OpenTofu owns the CF org/space/roles and backing services; +it does not also manage the application. The worker is not deployed yet. + +The protected `lzc-dev-release` Environment contains workload S3 credentials and +platform/runtime state keys. No project service-account key is supplied to the +release workflow. `tofu output -json` writes private files; `prepare-release.mjs` +only extracts explicitly allowed backend variables and masks them. `cf push` +uses a private variables file and `--redact-env`; neither credentials nor decrypted +states are included in the application artifact. The app receives only its own +PostgreSQL/Secrets credentials and the operator Model Serving token. + +For this development milestone the CF deployment identity is still the existing +org manager with explicit space developer membership, and release reads platform +state. A dedicated space-only deployer and narrower state/credential publication +are required before production. CF environment variables are visible to authorized +space developers; restrict and audit those roles. The public app remains a foundation +page: protected API routes refuse requests until real authentication exists. + +After push, a CF task performs PostgreSQL authentication, a query with verified TLS, +and Secrets Manager authentication plus a GET on the reserved operator probe path +`configurator/connectivity-probe`. Anonymous access must be denied; authenticated +access may return 404 for the absent key. No tenant secrets are read or written. +This does not test a secret write/read roundtrip or prove all runtime permissions. +The read-only identity is separately defined and tested in IaC. Diagnostic logs only show +service names, result and sanitized failure categories. The task revokes its Vault +token. A health check alone does not prove service connectivity. + +All mutations share `configurator-lzc-dev-mutation` with infrastructure workflows. +Feature-branch releases require Environment approval. A superseded branch revision +is rejected before deployment. GitHub concurrency is not FIFO: newer pending runs +can replace older pending runs. Never run local applies or pushes concurrently. + +The initial single-instance `cf push` can interrupt service briefly. Immutable +archive/checksum retention is seven days; explicit rollback/promotion and rolling +releases are subsequent milestones. DB migrations, schema grants, RLS, personal +credential storage and customer deployment execution are not part of this release. + +## Network verification + +The release includes a diagnostic CF task for DNS/TCP and an HTTPS request to the +Configurator's own route. The matching CF router entry identifies the observed +source network without contacting an external IP-diagnostic service. This is a +point-in-time observation, not a promise of stable CF egress addresses. + +The first connection test exposed a missing STACKIT network in the service ACLs. +The official eu01 catalog and our own router logs support adding +`45.135.244.0/22` through platform IaC. Network policy changes require a reviewed +platform plan and then a fresh CF connectivity test. The public UI/API checks run +even if a service connection fails, so route health and service health remain +separately visible. See [platform acceptance](../../docs/platform-readiness.md). diff --git a/landing-zone-configurator/app/cf/manifest.yml b/landing-zone-configurator/deploy/cloud-foundry/manifest.yml similarity index 100% rename from landing-zone-configurator/app/cf/manifest.yml rename to landing-zone-configurator/deploy/cloud-foundry/manifest.yml diff --git a/landing-zone-configurator/docs/cicd.md b/landing-zone-configurator/docs/cicd.md index e947805..bfb422f 100644 --- a/landing-zone-configurator/docs/cicd.md +++ b/landing-zone-configurator/docs/cicd.md @@ -79,4 +79,4 @@ Nächster Ausbau ist die CF-Runtime mit Zugriffstests zu Datenbank und Secrets M - [GitHub Environment-Verfügbarkeit und Einrichtung](https://docs.github.com/en/actions/how-tos/deploy/configure-and-manage-deployments/manage-environments) - [Cloud Foundry Rolling Deployments](https://docs.cloudfoundry.org/devguide/deploy-apps/rolling-deploy.html) -Details zum implementierten Release und seinen Grenzen: [CF-Release](../app/cf/README.md). +Details zum implementierten Release und seinen Grenzen: [CF-Release](../deploy/cloud-foundry/README.md). diff --git a/landing-zone-configurator/docs/planning.md b/landing-zone-configurator/docs/planning.md index 120c065..b635cb5 100644 --- a/landing-zone-configurator/docs/planning.md +++ b/landing-zone-configurator/docs/planning.md @@ -1,7 +1,7 @@ # Landing Zone Configurator – Architektur und Planung -> Stand: 2026-09-29 · Repo-Ausgangsstand: `2161871` -> Status: Architektur- und Umsetzungsplanung, noch keine Implementierung. +> Stand: 2026-09-30 · Architektur-Ausgangsstand: `2161871` +> Status: Plattform und CF-App-Grundgerüst implementiert; Service-Anbindung in Abnahme. Fachlicher MVP noch offen. > `[x]` = dokumentiert oder entschieden; `[ ]` = offen. Technische Empfehlungen sind noch keine beschlossenen Produktentscheidungen. ## 1. Zielbild und bestätigte Entscheidungen @@ -14,7 +14,7 @@ Eine zentral auf STACKIT Cloud Foundry gehostete Anwendung begleitet Benutzer vo - [x] Sämtliche Configurator-Dienste inklusive Aktivierung werden via IaC erstellt; keine manuelle Portal-Provisionierung. - [x] Erste Entwicklungsumgebung: lzc-dev in eu01. - [x] App, IaC und Dokumentation unter `landing-zone-configurator/` getrennt vom Accelerator organisieren. -- [x] Betreiberprojekt und Service-Account-Datei sind lokal bereitgestellt; `NAME=WERT`-Format geprüft; Projekt-Lesezugriff geprüft; Schreibrechte noch zu prüfen. +- [x] Betreiberprojekt und Service-Account-Datei sind lokal bereitgestellt; `NAME=WERT`-Format geprüft; Projektzugriff sowie Bereitstellung von Plattform und Runtime durch CI-Applies nachgewiesen. - [x] Von Beginn an mehrere unabhängige Kundenorganisationen unterstützen. Mandantentrennung ist Voraussetzung der ersten Freigabe, kein späterer Ausbau. - [x] Zunächst ausschließlich Konfigurationen verändern und mit freigegebenem Accelerator-Code ausführen. Eigene Terraform-Modifikationen aus Forks folgen später. @@ -617,3 +617,37 @@ CI-Entscheidung 2026-09-30: Auf Benutzerwunsch den eigenen JavaScript-Deployment CI-Vereinfachung abgenommen: echte direkte OpenTofu-Pläne für Bootstrap (36677111141), Plattform (36677111032) und Backend (36677367229) jeweils ohne Änderungen; Apply jeweils übersprungen. Native Logs sichtbar und gegen bekannte Credentials geprüft. Validierung einschließlich Linux-Timeout-Test erfolgreich. Temporäre Backend-Auswahl entfernt; Feature-Branch bleibt getrennt von main. + + +## Aktueller Meilenstein: CF-Hosting und Service-Verbindungen + +Owner: `@lweberru`. Alle Änderungen bleiben auf `feature/landing-zone-configurator`. +Die Benutzerfreigabe vom 2026-09-30 umfasst notwendige Configurator-Deployment- +Applies bis zum MVP. Konkrete Pläne werden weiterhin geprüft; die Freigabe ist +keine Erlaubnis, Kundenressourcen oder fremde Projekte zu ändern. + +- [x] Separate Runtime-IaC für CF-Space und Rollen. +- [x] Separate PostgreSQL-/Secrets-Laufzeitidentitäten; kein Migrations- oder Provisionierungszugang in der App. +- [x] Deklaratives CF-Manifest unter `deploy/cloud-foundry/`; OpenTofu und Release haben getrennte Zuständigkeiten. +- [x] Unabhängige Release-Pipeline mit direktem `cf push`, versioniertem Node-Runtime-Paket und Verbindungstest als CF-Task. +- [x] Öffentliche HTTPS-Route, UI-/JS-Auslieferung und gesperrte API (401) geprüft. +- [x] PostgreSQL: Login, SELECT und geprüfte TLS-Verbindung aus CF erfolgreich. +- [ ] Secrets Manager: authentifizierten Lese-Endpunkt und Ablehnung ohne Token abschließend prüfen; Login und TLS funktionieren. +- [ ] Abschließender komplett erfolgreicher Release-Run und Abnahme dokumentiert. + +Nachweise und Netzkorrektur: [Plattform-Betriebsstand](platform-readiness.md). +Das Grundgerüst ist noch kein nutzbarer Configurator und verarbeitet keine +Kundenkonfigurationen oder persönlichen Cloud-Zugangsdaten. + +Nächster fachlicher Meilenstein: + +- [ ] GitHub-App/OAuth-Integration festlegen, Callback-URL registrieren und Benutzerlogin umsetzen. +- [ ] Mandanten, Mitgliedschaften und Sessions mit Datenbankschema/Migrationen/RLS absichern. +- [ ] Ein vorhandenes Accelerator-Template als ersten vertikalen Ablauf importieren, fachlich bearbeiten und im persönlichen Fork speichern. +- [ ] Persönliche Deployment-Credentials: mandantenbezogene Zugriffsregeln, Secrets-Schreibidentität und Write/Read/Rotation-Test ergänzen. +- [ ] Danach Plan/Apply-Runner, Freigaben, grafische Konfiguration und begrenzten Chat integrieren. + +Vor Produktion zusätzlich: eigener CF-Deployer mit nur Space-Rechten, schmalere +Credential-Veröffentlichung für Releases, Rotation, Restore-Abnahme, Rolling +Deployment und definierter Rollback. Diese Punkte werden nicht durch den ersten +Konnektivitätstest als erledigt markiert. diff --git a/landing-zone-configurator/infra/README.md b/landing-zone-configurator/infra/README.md index 37040aa..6884dab 100644 --- a/landing-zone-configurator/infra/README.md +++ b/landing-zone-configurator/infra/README.md @@ -11,15 +11,15 @@ Alle Configurator-Dienste werden per OpenTofu verwaltet. Die Infrastruktur bleib | `bootstrap` | Workload-State-Bucket und dessen S3-Zugang | Verwaltungs-Bucket | | `backend` | Versionierung des Workload-State-Buckets | Verwaltungs-Bucket | | `platform` | CF-Organisation/Manager, PostgreSQL/Datenbank/Migrationsnutzer, Secrets Manager/Provisionierungsnutzer, Artefakt-Bucket und Model-Serving-Token | Workload-Bucket | -| `runtime` (geplant) | CF-Spaces, Apps, Bindings, eingeschränkte Laufzeitidentitäten | separater State | +| `runtime` | CF-Space, Organisations-/Space-Rollen, PostgreSQL-/Secrets-Laufzeitbenutzer | Workload-Bucket, eigener Schlüssel | -Bootstrap, Backend und Plattform sind in `lzc-dev/eu01` provisioniert. Die Plattform enthält neun verwaltete Ressourcen; der [Betriebsstand](../docs/platform-readiness.md) dokumentiert Abnahme und offene Runtime-Arbeiten. +Bootstrap, Backend, Plattform und Runtime sind in `lzc-dev/eu01` provisioniert. Die Plattform enthält neun verwaltete Ressourcen; der [Betriebsstand](../docs/platform-readiness.md) dokumentiert Abnahme und offene Konnektivitätsarbeiten. Der STACKIT-Provider aktiviert Object Storage und Model Serving bei der ersten Ressourcenerstellung. Für Bucket-Versionierung spricht der AWS-Provider ausschließlich die STACKIT-S3-API an; es werden keine AWS-Ressourcen angelegt. ## Deployment: direkte OpenTofu-Schritte in CI -[Bootstrap-Workflow](../../.github/workflows/configurator-bootstrap.yml) und [Plattform-Workflow](../../.github/workflows/configurator-platform.yml) führen `tofu init`, `validate`, `plan` und `apply` direkt aus. Normale CLI-Ausgaben sind live sichtbar. Kleine Hilfsprogramme bereiten Credentials vor, prüfen das gespeicherte Plan-Artefakt und verschlüsseln Recovery-Snapshots; sie führen keine Deployments aus. +[Bootstrap-Workflow](../../.github/workflows/configurator-bootstrap.yml), [Plattform-Workflow](../../.github/workflows/configurator-platform.yml) und [Runtime-Workflow](../../.github/workflows/configurator-runtime.yml) führen `tofu init`, `validate`, `plan` und `apply` direkt aus. Normale CLI-Ausgaben sind live sichtbar. Kleine Hilfsprogramme bereiten Credentials vor, prüfen das gespeicherte Plan-Artefakt und verschlüsseln Recovery-Snapshots; sie führen keine Deployments aus. [CI-Betriebsanleitung](ci/README.md): Environments, Planfreigabe, Timeouts, State-Sicherung und Feature-Branch-Tests. Remote-Applies ausschließlich über die gemeinsam serialisierte CI; keine parallelen lokalen Applies. S3-Locking hat den Integrationstest nicht bestanden und wird nicht als funktionierender Schutz behauptet. @@ -77,7 +77,7 @@ Verschlüsselte Seed-Sicherungen liegen im Verwaltungs-Bucket unter `recovery/se Bei einem fehlgeschlagenen Apply zuerst CI-Logs, verschlüsselte Recovery-Artefakte und tatsächlichen Ressourcenbestand prüfen. Keinen alten Plan blind wiederholen, keinen existierenden State überschreiben. Imports und Credential-Rotation müssen zum konkreten Vorfall passen und unter derselben Single-Writer-Regel erfolgen. Der frühere automatische Einmal-Recovery-Pfad wurde nach Abschluss entfernt. -Offen sind CF-Runtime/Bindings und Verbindungstests aus CF, eingeschränkte App-Rollen/RLS, automatisierte Credential-Rotation sowie ein vollständiger getesteter Betriebs-/Restore-Prozess. [Planung](../docs/planning.md). +Die App und ihre CF-Umgebungsvariablen werden im separaten [Release-Workflow](../../.github/workflows/configurator-release.yml) durch das deklarative [CF-Manifest](../deploy/cloud-foundry/manifest.yml) verwaltet. Offen sind erfolgreiche Verbindungstests aus CF, eingeschränkte App-Rollen/RLS, automatisierte Credential-Rotation sowie ein vollständiger getesteter Betriebs-/Restore-Prozess. [Planung](../docs/planning.md). Direkte CLI-Pipelines geprüft (2026-09-30): [Bootstrap 36677111141](https://github.com/stackitcloud/stackit-landing-zone/actions/runs/36677111141) und [Plattform 36677111032](https://github.com/stackitcloud/stackit-landing-zone/actions/runs/36677111032) zeigen jeweils `No changes`, mit sichtbaren nativen Init-/Plan-Logs und übersprungenem Apply. [Validierung 36677111055](https://github.com/stackitcloud/stackit-landing-zone/actions/runs/36677111055) bestätigt auch den GNU-Timeout-Test auf Linux. Aktuelle weitere Abnahmen stehen im [Plattform-Betriebsstand](../docs/platform-readiness.md). diff --git a/landing-zone-configurator/infra/ci/README.md b/landing-zone-configurator/infra/ci/README.md index 49f64ee..96cf490 100644 --- a/landing-zone-configurator/infra/ci/README.md +++ b/landing-zone-configurator/infra/ci/README.md @@ -4,7 +4,9 @@ Die ausführbaren Deployments stehen direkt in den Workflows: - [Configurator Bootstrap](../../../.github/workflows/configurator-bootstrap.yml): `bootstrap` oder `backend`. - [Configurator Platform](../../../.github/workflows/configurator-platform.yml): `platform`. -- [Validierung](../../../.github/workflows/validate-configurator.yml): Formatierung, Validierung und Mock-Tests aller fünf Roots ohne Cloud-Credentials. +- [Configurator Runtime](../../../.github/workflows/configurator-runtime.yml): `runtime`. +- [Configurator Release](../../../.github/workflows/configurator-release.yml): gebautes Web/API-Paket mit direkten `cf`-Befehlen ausliefern. +- [Validierung](../../../.github/workflows/validate-configurator.yml): Formatierung, Validierung und Mock-Tests aller sechs Roots ohne Cloud-Credentials. Jeder Deployment-Job zeigt die einzelnen Schritte: Credential-Vorbereitung, gegebenenfalls Lesen der vorherigen Backend-Outputs, `tofu init`, `tofu validate`, `tofu plan` beziehungsweise `tofu apply`. Es gibt keinen eigenen JavaScript-Deployment-Runner. Einmalige Recovery-Operationen gehören nicht in diesen normalen Ablauf. @@ -37,6 +39,7 @@ Quellen: [OpenTofu sensitive outputs](https://opentofu.org/docs/language/values/ | Datei | Begrenzte Aufgabe | | --- | --- | | `prepare.mjs` | Private Credential-/Backend-Dateien und Umgebungsvariablen vorbereiten; vorhandene Workload-Outputs/Versionierung prüfen. Keine Prozessausführung. | +| `prepare-release.mjs` | Private Backend-Konfiguration und erlaubte App-Variablen für CF vorbereiten. Keine Prozessausführung. | | `review.mjs` | Verschlüsselten Plan mit Manifest versehen beziehungsweise vor Apply prüfen. Keine Prozessausführung. | | `protect-state.mjs`, `artifact.mjs` | Bereits erfasste State-Dateien für Recovery verschlüsseln. Keine Prozessausführung. | | `context.mjs` | Eingaben, Workflow-Identität und Planbindung prüfen. | @@ -50,15 +53,17 @@ Bootstrap und Backend verwenden den Verwaltungs-Bucket. Der Plattform-Job initia | --- | --- | | `lzc-dev-bootstrap-plan` / `lzc-dev-bootstrap-apply` | Bootstrap-/Backend-Plan und freizugebender Apply | | `lzc-dev-platform-plan` / `lzc-dev-platform-apply` | Plattform-Plan und freizugebender Apply | +| `lzc-dev-runtime-plan` / `lzc-dev-runtime-apply` | Space, Rollen und Laufzeitbenutzer | +| `lzc-dev-release` | CF-Release; Workload-S3-Zugang und Plattform-/Runtime-State-Schlüssel, kein Projekt-Service-Account | | `lzc-dev-recovery` | Separat geschützte Seed-Recovery-Secrets, kein automatischer Recovery-Job | -Die Konfiguration steht in `github-environments.json` und `github-platform-environments.json`. Änderungen an Secret-Zielen oder Schlüsselübertragungen benötigen die entsprechende Benutzerautorisierung. Das Operator-Werkzeug lautet `node infra/ci/configure-github.mjs` beziehungsweise mit `--platform` für die Plattform-Environments. +Die Konfiguration steht in `github-environments.json`, `github-platform-environments.json`, `github-runtime-environments.json` und `github-release-environments.json`. Änderungen an Secret-Zielen oder Schlüsselübertragungen benötigen die entsprechende Benutzerautorisierung. Das Operator-Werkzeug lautet `node infra/ci/configure-github.mjs` beziehungsweise mit `--platform`, `--runtime` oder `--release` für die jeweiligen Environments. ## Serialisierung und Feature-Branch Alle mutierenden Configurator-Workflows verwenden `concurrency.group: configurator-lzc-dev-mutation` und `cancel-in-progress: false`, einschließlich Plan und Freigabewartezeit. Native S3-Sperren haben den Integrationstest nicht bestanden; `backend-safety.json` hält diesen Befund fest. Lokale Remote-Applies und schreibende Parallelzugriffe außerhalb dieser CI-Gruppe bleiben im vereinbarten Betrieb ausgeschlossen. Direkte CLI-Aufrufe durch Credential-Inhaber lassen sich nicht durch einen lokalen Wrapper oder GitHub-Concurrency verhindern. -Feature-Pushes auf `feature/landing-zone-configurator` erzeugen normalerweise nur Pläne. `LZC_PLATFORM_CI_ENABLED=true` aktiviert die Plattform-Pipeline. Ein Feature-Apply braucht zusätzlich eine passende vollständige Commit-SHA in `LZC_PLATFORM_APPLY_COMMIT` beziehungsweise `LZC_BOOTSTRAP_APPLY_COMMIT` und die Environment-Freigabe. Für einen Backend-Lauf wird `LZC_BOOTSTRAP_ROOT=backend` gesetzt. Diese Einmalvariablen nach dem Lauf entfernen. Der manuelle Hauptbranch-Pfad ist für die spätere Übernahme vorbereitet; derzeit wird nichts nach main gemergt. +Feature-Pushes auf `feature/landing-zone-configurator` erzeugen normalerweise nur Pläne. `LZC_PLATFORM_CI_ENABLED=true` aktiviert die Plattform-Pipeline. Ein Feature-Apply braucht zusätzlich eine passende vollständige Commit-SHA in `LZC_PLATFORM_APPLY_COMMIT` beziehungsweise `LZC_BOOTSTRAP_APPLY_COMMIT` oder `LZC_RUNTIME_APPLY_COMMIT` und die Environment-Freigabe. Für einen Backend-Lauf wird `LZC_BOOTSTRAP_ROOT=backend` gesetzt. Diese Einmalvariablen nach dem Lauf entfernen. Der manuelle Hauptbranch-Pfad ist für die spätere Übernahme vorbereitet; derzeit wird nichts nach main gemergt. Die vorhandene Plan-Service-Account-Rolle ist nicht rein lesend. Deshalb dürfen diese Workflows ausschließlich vertrauenswürdigen Code ausführen; sie sind nicht für externe Pull Requests vorgesehen. @@ -79,3 +84,19 @@ Nach einem gestarteten Apply versucht ein `always()`-Schritt ausdrücklich `tofu ## Betriebsnachweise Die bestehende Infrastruktur wurde durch diesen Umbau weder ersetzt noch migriert. Die erfolgreiche [Recovery und Plattform-Bereitstellung 36675347654](https://github.com/stackitcloud/stackit-landing-zone/actions/runs/36675347654) und der [No-op-Plan 36675895279](https://github.com/stackitcloud/stackit-landing-zone/actions/runs/36675895279) sind dokumentiert. Das abgeschlossene Recovery-Inventar liegt ausschließlich unter [docs/incidents](../../docs/incidents/2026-09-30-recovery-inventory.json); es ist kein ausführbarer Bestandteil der Pipeline. Aktuelle Abnahmen: [Plattform-Betriebsstand](../../docs/platform-readiness.md). + +## Runtime und Release + +Der Runtime-Root liest vorhandene Plattform-Ausgaben und verwaltet den CF-Space, +die ausdrücklich benötigte `organization_user`-Mitgliedschaft vor `space_developer`, +sowie separate PostgreSQL-/Secrets-Laufzeitbenutzer. Sein verschlüsselter State +liegt im Workload-Bucket. Der erste Teil-Apply wurde regulär gespeichert; ein +frischer Plan ergänzte die fehlenden CF-Rollen ohne Duplikate oder Recovery-Import. + +`LZC_RUNTIME_CI_ENABLED` und `LZC_RELEASE_CI_ENABLED` aktivieren die entsprechenden +Workflows. Die Benutzerfreigabe vom 2026-09-30 umfasst notwendige Configurator- +Deployment-Applies bis MVP; Scope-/Planprüfung und Environment-Freigaben bleiben. +Der Release-Workflow baut ohne Cloud-Secrets, lädt ein Paket desselben Runs und +prüft dessen SHA-256. Erst der geschützte Deploy-Job liest Zugangsdaten. Die normalen +`cf push`-/Task-Logs bleiben sichtbar; Umgebungswerte sind maskiert. Der API-Zugang +bleibt bis zur Login-Implementierung gesperrt. [Release-Details](../../deploy/cloud-foundry/README.md). From 4abe60d75d980669d167b55e21cb45e17b662628 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Weberru=C3=9F?= Date: Wed, 30 Sep 2026 09:33:19 +0200 Subject: [PATCH 025/175] docs(configurator): record successful CF and service connectivity acceptance --- landing-zone-configurator/README.md | 4 ++- landing-zone-configurator/docs/cicd.md | 4 +-- landing-zone-configurator/docs/planning.md | 6 ++-- .../docs/platform-readiness.md | 35 +++++++++++++++++++ landing-zone-configurator/infra/README.md | 2 +- 5 files changed, 44 insertions(+), 7 deletions(-) diff --git a/landing-zone-configurator/README.md b/landing-zone-configurator/README.md index df70eae..15c12af 100644 --- a/landing-zone-configurator/README.md +++ b/landing-zone-configurator/README.md @@ -2,7 +2,9 @@ Zentral gehostete, mandantenfähige Anwendung zum Erstellen, Bearbeiten und Deployen von Konfigurationen des STACKIT Landing Zone Accelerators. -Status: React-/Fastify-Entwicklungsbasis mit Tests. Verwaltungs-Backend, Bootstrap und neun Plattform-Ressourcen sind per IaC provisioniert und geprüft. Die Pipelines führen OpenTofu direkt mit sichtbaren CLI-Logs aus. CF-Space und Laufzeitidentitäten sind provisioniert, Web/API auf CF bereitgestellt. Die Verbindungstests werden aus CF ausgeführt; Details im [Betriebsstand](docs/platform-readiness.md). [IaC-Bedienung](infra/README.md), [CI-Betrieb](infra/ci/README.md). +Status: React-/Fastify-Entwicklungsbasis mit Tests. Verwaltungs-Backend, Bootstrap und neun Plattform-Ressourcen sind per IaC provisioniert und geprüft. Die Pipelines führen OpenTofu direkt mit sichtbaren CLI-Logs aus. CF-Space und Laufzeitidentitäten sind provisioniert, Web/API auf CF bereitgestellt. PostgreSQL und Secrets Manager wurden aus CF mit TLS geprüft; Details im [Betriebsstand](docs/platform-readiness.md). [IaC-Bedienung](infra/README.md), [CI-Betrieb](infra/ci/README.md). + +Entwicklungsumgebung: [Configurator öffnen](https://lzc-dev-configurator-7dbff805.apps.01.cf.eu01.stackit.cloud). Noch ohne Benutzerlogin und fachlichen Editor. Start und Prüfungen: [Entwicklungsanleitung](app/README.md). Ergebnisse der lesenden Bestandsaufnahme: [Plattformprüfung](docs/platform-check.md). diff --git a/landing-zone-configurator/docs/cicd.md b/landing-zone-configurator/docs/cicd.md index bfb422f..1498476 100644 --- a/landing-zone-configurator/docs/cicd.md +++ b/landing-zone-configurator/docs/cicd.md @@ -1,6 +1,6 @@ # CI/CD für den Landing Zone Configurator -Status: Validierung, Bootstrap-/Backend- und Plattform-Pipelines sind implementiert und erfolgreich ausgeführt. OpenTofu wird direkt in den Workflows aufgerufen, mit normalen Live-Logs. Runtime und App-Release sind implementiert und werden gegen CF abgenommen; Drift bleibt geplant. Erste Umgebung: **lzc-dev / eu01**. Aktuelle Bedienung: [CI-Betrieb](../infra/ci/README.md). +Status: Validierung, Bootstrap-/Backend- und Plattform-Pipelines sind implementiert und erfolgreich ausgeführt. OpenTofu wird direkt in den Workflows aufgerufen, mit normalen Live-Logs. Runtime und App-Release einschließlich Verbindungstests aus CF sind erfolgreich abgenommen; Drift bleibt geplant. Erste Umgebung: **lzc-dev / eu01**. Aktuelle Bedienung: [CI-Betrieb](../infra/ci/README.md). ## Getrennte Lebenszyklen @@ -65,7 +65,7 @@ Die Verfügbarkeit von Environment-Freigaberegeln hängt vom GitHub-Tarif und de - [x] Native S3-Sperren testen und als nicht zuverlässig dokumentieren; CI-only-Betrieb explizit festlegen. - [x] OpenTofu direkt in den Workflows aufrufen; Live-Logs, Standard-Timeout und geschützte State-Sicherung. - [ ] Unabhängige Schlüsselverwahrung, vollständiger Restore-Prozess und automatisierte Credential-Rotation. -- [ ] CF-Runtime, Bindings und Konnektivitätstests aus CF. +- [x] CF-Runtime, Backend-Umgebungsvariablen und Konnektivitätstests aus CF. - [ ] Release-Manifeste, Paketierung, Migrationen, Readiness und Worker-Drain. - [ ] Zwei App-Releases, unabhängigen Redeploy und Rollback ohne Infrastrukturänderung nachweisen. - [ ] Optionalen Drift-Workflow und Alarmierung aktivieren. diff --git a/landing-zone-configurator/docs/planning.md b/landing-zone-configurator/docs/planning.md index b635cb5..5952516 100644 --- a/landing-zone-configurator/docs/planning.md +++ b/landing-zone-configurator/docs/planning.md @@ -1,7 +1,7 @@ # Landing Zone Configurator – Architektur und Planung > Stand: 2026-09-30 · Architektur-Ausgangsstand: `2161871` -> Status: Plattform und CF-App-Grundgerüst implementiert; Service-Anbindung in Abnahme. Fachlicher MVP noch offen. +> Status: Plattform, CF-App-Grundgerüst und Service-Anbindung implementiert und abgenommen. Fachlicher MVP noch offen. > `[x]` = dokumentiert oder entschieden; `[ ]` = offen. Technische Empfehlungen sind noch keine beschlossenen Produktentscheidungen. ## 1. Zielbild und bestätigte Entscheidungen @@ -632,8 +632,8 @@ keine Erlaubnis, Kundenressourcen oder fremde Projekte zu ändern. - [x] Unabhängige Release-Pipeline mit direktem `cf push`, versioniertem Node-Runtime-Paket und Verbindungstest als CF-Task. - [x] Öffentliche HTTPS-Route, UI-/JS-Auslieferung und gesperrte API (401) geprüft. - [x] PostgreSQL: Login, SELECT und geprüfte TLS-Verbindung aus CF erfolgreich. -- [ ] Secrets Manager: authentifizierten Lese-Endpunkt und Ablehnung ohne Token abschließend prüfen; Login und TLS funktionieren. -- [ ] Abschließender komplett erfolgreicher Release-Run und Abnahme dokumentiert. +- [x] Secrets Manager: Login, TLS, authentifizierter KV-Lese-Endpunkt und Ablehnung ohne Token aus CF geprüft. +- [x] Abschließender [Release-Run 36683898762](https://github.com/stackitcloud/stackit-landing-zone/actions/runs/36683898762) vollständig erfolgreich; [Validierung 36683898795](https://github.com/stackitcloud/stackit-landing-zone/actions/runs/36683898795) ebenfalls grün. Nachweise und Netzkorrektur: [Plattform-Betriebsstand](platform-readiness.md). Das Grundgerüst ist noch kein nutzbarer Configurator und verarbeitet keine diff --git a/landing-zone-configurator/docs/platform-readiness.md b/landing-zone-configurator/docs/platform-readiness.md index 398f649..f5f5c7f 100644 --- a/landing-zone-configurator/docs/platform-readiness.md +++ b/landing-zone-configurator/docs/platform-readiness.md @@ -155,3 +155,38 @@ Dies bleibt eine Freigabe eines geteilten STACKIT-Netzes, keine mandanteneigene oder zugesichert stabile CF-Ausgangsadresse. TLS, Benutzerrechte und spätere Mandantenautorisierung bleiben separate Schutzschichten. Bei Netzänderungen Katalog und CF-Test erneut prüfen; kein automatisches Öffnen weiterer Netze. + + +## Abnahme CF-Hosting und Dienstverbindungen abgeschlossen + +[Release 36683898762](https://github.com/stackitcloud/stackit-landing-zone/actions/runs/36683898762) +auf Commit `795a18a`: **vollständig erfolgreich**. +[Validierung 36683898795](https://github.com/stackitcloud/stackit-landing-zone/actions/runs/36683898795) +ist ebenfalls erfolgreich (App mit neun Tests und alle sechs IaC-Roots). + +- [x] [ACL-Apply 36683137454](https://github.com/stackitcloud/stackit-landing-zone/actions/runs/36683137454): zwei In-place-Updates, keine Neuanlage/Löschung. Beide Dienst-ACLs unabhängig im verschlüsselten Remote-State geprüft. +- [x] App `landing-zone-configurator` im Space `configurator` gestartet; deklaratives Manifest unter `deploy/cloud-foundry/`. +- [x] PostgreSQL: DNS und TCP erreichbar, Laufzeitbenutzer authentifiziert, `SELECT 1` und `pg_stat_ssl` erfolgreich, Zertifikats-/Hostname-Prüfung aktiv. +- [x] Secrets Manager: TLS und Userpass-Login erfolgreich; reservierter KV-Pfad verweigert anonymen Zugriff und akzeptiert den authentifizierten Aufruf; Sitzungstoken danach widerrufen. +- [x] CF-Tasks `network-36683898762` und `connections-36683898762` sind `SUCCEEDED`. +- [x] HTTPS-Startseite und JavaScript-Asset ausgeliefert; `/healthz` HTTP 200, `/api/v1/session` HTTP 401. +- [x] Alle zehn Backend-Variablen mit verschlüsselten Runtime-Ausgaben verglichen; keine Projekt-/S3-/CF-Deployment-Credentials in der App. +- [x] Erfolgreiche Runtime-/ACL-/Release-Logs auf bekannte Schlüssel, Passwörter und Token geprüft, ohne Treffer. +- [x] Einmalige Runtime-/Plattform-Apply-Variablen entfernt; Feature-Branch beibehalten, kein Merge nach main. + +Der Secrets Manager unterstützt nicht die allgemeine Vault-System-API +`sys/capabilities-self` (im Test HTTP 404). Der endgültige Test nutzt den +[dokumentierten KV-Lese-Endpunkt](https://docs.stackit.cloud/products/security/secrets-manager/how-tos/cli-and-api/) +mit anonymem Negativtest. Für den absichtlich nicht angelegten Operator-Probe-Key +ist authentifiziertes HTTP 404 zulässig; HTTP 404 auch ohne Token würde den Test +fehlschlagen lassen. Es werden weder Kunden-Secrets gelesen noch Probe-Secrets +angelegt. **Ein Secret-Write/Read-Roundtrip, Mandantenautorisierung und Rotation +sind damit noch nicht abgenommen.** Die Laufzeitidentität ist in IaC read-only. + +Erreichbar: [lzc-dev Configurator](https://lzc-dev-configurator-7dbff805.apps.01.cf.eu01.stackit.cloud). +Dies ist das technische App-Grundgerüst. GitHub-Login, persistentes Mandantenschema, +Template-Editor, persönliche Credentials, Kunden-Plan/Apply und Chat folgen in der +[aktuellen MVP-Checkliste](planning.md#aktueller-meilenstein-cf-hosting-und-service-verbindungen). + +Die stehende Benutzerfreigabe für notwendige Configurator-Applies gilt bis MVP; +Planprüfung, Environment-Schutz und die gemeinsame CI-Concurrency bleiben aktiv. diff --git a/landing-zone-configurator/infra/README.md b/landing-zone-configurator/infra/README.md index 6884dab..133654a 100644 --- a/landing-zone-configurator/infra/README.md +++ b/landing-zone-configurator/infra/README.md @@ -77,7 +77,7 @@ Verschlüsselte Seed-Sicherungen liegen im Verwaltungs-Bucket unter `recovery/se Bei einem fehlgeschlagenen Apply zuerst CI-Logs, verschlüsselte Recovery-Artefakte und tatsächlichen Ressourcenbestand prüfen. Keinen alten Plan blind wiederholen, keinen existierenden State überschreiben. Imports und Credential-Rotation müssen zum konkreten Vorfall passen und unter derselben Single-Writer-Regel erfolgen. Der frühere automatische Einmal-Recovery-Pfad wurde nach Abschluss entfernt. -Die App und ihre CF-Umgebungsvariablen werden im separaten [Release-Workflow](../../.github/workflows/configurator-release.yml) durch das deklarative [CF-Manifest](../deploy/cloud-foundry/manifest.yml) verwaltet. Offen sind erfolgreiche Verbindungstests aus CF, eingeschränkte App-Rollen/RLS, automatisierte Credential-Rotation sowie ein vollständiger getesteter Betriebs-/Restore-Prozess. [Planung](../docs/planning.md). +Die App und ihre CF-Umgebungsvariablen werden im separaten [Release-Workflow](../../.github/workflows/configurator-release.yml) durch das deklarative [CF-Manifest](../deploy/cloud-foundry/manifest.yml) verwaltet. Verbindungstests aus CF sind erfolgreich. Offen sind Anwendungsschema/RLS, automatisierte Credential-Rotation sowie ein vollständiger getesteter Betriebs-/Restore-Prozess. [Planung](../docs/planning.md). Direkte CLI-Pipelines geprüft (2026-09-30): [Bootstrap 36677111141](https://github.com/stackitcloud/stackit-landing-zone/actions/runs/36677111141) und [Plattform 36677111032](https://github.com/stackitcloud/stackit-landing-zone/actions/runs/36677111032) zeigen jeweils `No changes`, mit sichtbaren nativen Init-/Plan-Logs und übersprungenem Apply. [Validierung 36677111055](https://github.com/stackitcloud/stackit-landing-zone/actions/runs/36677111055) bestätigt auch den GNU-Timeout-Test auf Linux. Aktuelle weitere Abnahmen stehen im [Plattform-Betriebsstand](../docs/platform-readiness.md). From c5e5a7fb9b6bd6d699ae8d5bb2036a9513b50ad8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Weberru=C3=9F?= Date: Wed, 30 Sep 2026 09:46:13 +0200 Subject: [PATCH 026/175] feat(configurator): prepare user-authorized GitHub login and portal design acceptance --- .../app/apps/api/src/auth/github-flow.ts | 92 +++++++++++++++++++ .../app/tests/github-flow.test.ts | 71 ++++++++++++++ .../deploy/github/README.md | 36 ++++++++ .../deploy/github/app-manifest.json | 16 ++++ .../docs/design-reference.md | 38 ++++++++ landing-zone-configurator/docs/planning.md | 13 +++ 6 files changed, 266 insertions(+) create mode 100644 landing-zone-configurator/app/apps/api/src/auth/github-flow.ts create mode 100644 landing-zone-configurator/app/tests/github-flow.test.ts create mode 100644 landing-zone-configurator/deploy/github/README.md create mode 100644 landing-zone-configurator/deploy/github/app-manifest.json create mode 100644 landing-zone-configurator/docs/design-reference.md diff --git a/landing-zone-configurator/app/apps/api/src/auth/github-flow.ts b/landing-zone-configurator/app/apps/api/src/auth/github-flow.ts new file mode 100644 index 0000000..eefae24 --- /dev/null +++ b/landing-zone-configurator/app/apps/api/src/auth/github-flow.ts @@ -0,0 +1,92 @@ +import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; + +export type PendingGitHubLogin = { + stateHash: string; + bindingHash: string; + verifier: string; + expiresAt: number; +}; +const sha256 = (value: string) => + createHash("sha256").update(value).digest("base64url"); +const nonce = () => randomBytes(32).toString("base64url"); + +function constantTimeHashMatch(value: string, expectedHash: string): boolean { + const actual = Buffer.from(sha256(value)); + const expected = Buffer.from(expectedHash); + return actual.length === expected.length && timingSafeEqual(actual, expected); +} + +export function createGitHubLogin( + clientId: string, + callback: string, + now = Date.now(), +) { + const redirect = new URL(callback); + if ( + !clientId || + redirect.protocol !== "https:" || + redirect.username || + redirect.password || + redirect.search || + redirect.hash || + redirect.pathname !== "/auth/github/callback" + ) { + throw new Error("Invalid GitHub login configuration"); + } + const state = nonce(); + const cookieBinding = nonce(); + const verifier = nonce(); + const authorizationUrl = new URL("https://github.com/login/oauth/authorize"); + authorizationUrl.search = new URLSearchParams({ + client_id: clientId, + redirect_uri: redirect.href, + state, + code_challenge: sha256(verifier), + code_challenge_method: "S256", + allow_signup: "false", + }).toString(); + return { + authorizationUrl: authorizationUrl.href, + cookieBinding, + pending: { + stateHash: sha256(state), + bindingHash: sha256(cookieBinding), + verifier, + expiresAt: now + 5 * 60 * 1000, + } satisfies PendingGitHubLogin, + }; +} + +// The caller must atomically DELETE the pending record before exchanging the code. +// This pure check is not replay protection or a substitute for persistent storage. +export function validGitHubCallback( + pending: PendingGitHubLogin, + state: string, + cookieBinding: string, + now = Date.now(), +): boolean { + return ( + now < pending.expiresAt && + state.length === 43 && + cookieBinding.length === 43 && + constantTimeHashMatch(state, pending.stateHash) && + constantTimeHashMatch(cookieBinding, pending.bindingHash) + ); +} + +export function sessionCookie(value: string, maxAgeSeconds: number): string { + if ( + !/^[A-Za-z0-9_-]{43}$/.test(value) || + !Number.isSafeInteger(maxAgeSeconds) || + maxAgeSeconds < 0 || + maxAgeSeconds > 8 * 60 * 60 + ) { + throw new Error("Invalid session cookie"); + } + return `__Host-lzc-session=${value}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${maxAgeSeconds}`; +} + +export function newSessionToken() { + const token = nonce(); + return { token, hash: sha256(token) }; +} diff --git a/landing-zone-configurator/app/tests/github-flow.test.ts b/landing-zone-configurator/app/tests/github-flow.test.ts new file mode 100644 index 0000000..835cf62 --- /dev/null +++ b/landing-zone-configurator/app/tests/github-flow.test.ts @@ -0,0 +1,71 @@ +import { createHash } from "node:crypto"; +import { describe, expect, it } from "vitest"; +import { + createGitHubLogin, + newSessionToken, + sessionCookie, + validGitHubCallback, +} from "../apps/api/src/auth/github-flow.js"; + +const callback = "https://example.invalid/auth/github/callback"; + +describe("GitHub login foundation (not yet wired to HTTP)", () => { + it("binds S256 PKCE, state and a separate browser cookie", () => { + const flow = createGitHubLogin("test-client", callback, 1000); + const url = new URL(flow.authorizationUrl); + const state = url.searchParams.get("state") ?? ""; + expect(url.origin).toBe("https://github.com"); + expect(url.searchParams.get("code_challenge_method")).toBe("S256"); + expect(url.searchParams.get("code_challenge")).toBe( + createHash("sha256").update(flow.pending.verifier).digest("base64url"), + ); + expect(url.searchParams.get("scope")).toBeNull(); + expect(flow.authorizationUrl).not.toContain(flow.pending.verifier); + expect( + validGitHubCallback(flow.pending, state, flow.cookieBinding, 2000), + ).toBe(true); + expect(validGitHubCallback(flow.pending, state, "x".repeat(43), 2000)).toBe( + false, + ); + expect( + validGitHubCallback( + flow.pending, + "x".repeat(43), + flow.cookieBinding, + 2000, + ), + ).toBe(false); + expect( + validGitHubCallback( + flow.pending, + state, + flow.cookieBinding, + flow.pending.expiresAt, + ), + ).toBe(false); + }); + it("rejects insecure and ambiguous callback configuration", () => { + for (const value of [ + "http://example.invalid/auth/github/callback", + `${callback}?next=evil`, + `${callback}#fragment`, + "https://user:pass@example.invalid/auth/github/callback", + "https://example.invalid/other", + ]) { + expect(() => createGitHubLogin("test-client", value)).toThrow(); + } + }); + it("stores only a session hash and creates a host-bound secure cookie", () => { + const first = newSessionToken(); + const second = newSessionToken(); + expect(first.token).not.toBe(second.token); + expect(first.hash).not.toBe(first.token); + expect(sessionCookie(first.token, 3600)).toContain( + "Path=/; HttpOnly; Secure; SameSite=Lax", + ); + expect(() => + sessionCookie(`${first.token}; Domain=example.invalid`, 3600), + ).toThrow(); + expect(() => sessionCookie(first.token, 9999999)).toThrow(); + }); +}); diff --git a/landing-zone-configurator/deploy/github/README.md b/landing-zone-configurator/deploy/github/README.md new file mode 100644 index 0000000..c981a7a --- /dev/null +++ b/landing-zone-configurator/deploy/github/README.md @@ -0,0 +1,36 @@ +# GitHub App registration draft + +`app-manifest.json` is the reviewable registration draft for lzc-dev. It does not +register an app, create credentials, install an app or activate login by itself. +Owner and final available app name must be chosen during registration. + +Use GitHub App user authorization with expiring user access tokens. Repository +operations must use the signed-in user's token, never the operator's GitHub CLI +session, a central PAT, or an installation token. Keep token expiration enabled. + +Requested permissions: + +- Metadata read: repository identity and access checks. +- Contents write: read templates and commit a configuration to the chosen fork. +- Administration write: required by the fork endpoint; expose no repository + administration operation through the Configurator beyond creating a fork. + +A GitHub App needs installation at the upstream account and the destination +account for API-created forks. Without those prerequisites, offer GitHub's manual +fork page and connect the existing fork afterwards. Do not silently broaden scopes +or use operator credentials. Check these prerequisites with the registered app +before claiming end-to-end fork support. + +Callback URL: +`https://lzc-dev-configurator-7dbff805.apps.01.cf.eu01.stackit.cloud/auth/github/callback` + +Backend inputs to provision after registration: `LZC_GITHUB_CLIENT_ID` and +`LZC_GITHUB_CLIENT_SECRET`. The client secret must be supplied through protected +runtime configuration, never committed or included in a frontend build. Registration +and binding are not complete yet. Login stays disabled until session persistence, +PKCE/state validation and safe token storage are implemented and verified. + +Sources: +- https://docs.github.com/en/apps/creating-github-apps/setting-up-a-github-app/registering-a-github-app +- https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/generating-a-user-access-token-for-a-github-app +- https://docs.github.com/en/rest/repos/forks#create-a-fork diff --git a/landing-zone-configurator/deploy/github/app-manifest.json b/landing-zone-configurator/deploy/github/app-manifest.json new file mode 100644 index 0000000..3d0e5db --- /dev/null +++ b/landing-zone-configurator/deploy/github/app-manifest.json @@ -0,0 +1,16 @@ +{ + "name": "Landing Zone Configurator Dev", + "url": "https://lzc-dev-configurator-7dbff805.apps.01.cf.eu01.stackit.cloud", + "description": "User-authorized configuration management for the STACKIT Landing Zone Accelerator.", + "public": true, + "callback_urls": [ + "https://lzc-dev-configurator-7dbff805.apps.01.cf.eu01.stackit.cloud/auth/github/callback" + ], + "hook_attributes": { "active": false }, + "default_permissions": { + "metadata": "read", + "contents": "write", + "administration": "write" + }, + "default_events": [] +} diff --git a/landing-zone-configurator/docs/design-reference.md b/landing-zone-configurator/docs/design-reference.md new file mode 100644 index 0000000..63603a7 --- /dev/null +++ b/landing-zone-configurator/docs/design-reference.md @@ -0,0 +1,38 @@ +# Designreferenz: offizielles STACKIT Portal + +Benutzeranforderung vom 2026-09-30: Look & Feel des Landing Zone Configurators +soll dem offiziellen STACKIT Portal entsprechen. Referenz ist die bereits vom +Benutzer geöffnete Projektstartseite. Kein frei interpretiertes STACKIT-ähnliches +Theme als Ersatz für diesen Abgleich. + +## Abnahme vor der UI-Implementierung + +- [ ] Geöffnete Projektstartseite im Benutzerbrowser ansehen und Referenz erfassen. +- [ ] Portal-Shell: Header, Projekt-/Organisationsauswahl, Navigation, Breadcrumbs und Inhaltsbreite bestimmen. +- [ ] Tatsächliche Farben, Typografie, Schriftgewichte, Abstände, Radien, Rahmen und Schatten dokumentieren. +- [ ] Formulare, Buttons, Karten, Tabellen, Dialoge sowie Fokus-/Fehler-/Disabled-Zustände vergleichen. +- [ ] Verfügbare offizielle Design-Tokens, Fonts, Icons und Komponenten prüfen; kompatible Originalbausteine bevorzugen. +- [ ] Gemeinsame UI-Komponenten und Tokens im Configurator implementieren. +- [ ] Login, Template-Auswahl, fachlichen Editor und Fork-Auswahl gegen die Referenz visuell abnehmen. +- [ ] Tastaturbedienung, Fokus, Kontrast und schmale Ansichten prüfen. + +Die tatsächlichen Portalwerte wurden noch nicht erfasst. Der Browser-Zugriff +scheitert vor der Seitenauswahl: Die Verbindung referenziert eine fehlende ältere +Plugin-Version (`26.917.71314`), obwohl das verfügbare Plugin `26.924.22138` ist. +Neuverbindung und Rücksetzen der Browser-Ausführungsumgebung halfen nicht. +Das ist kein Login-Problem des STACKIT Portals. Keine Browser-Sitzungen, Cookies +oder Anmeldedaten wurden aus anderen Quellen ausgelesen. + +## Unabhängig vorbereitete Arbeit + +- [x] GitHub-App-Registrierungsentwurf mit fester Callback-URL unter `deploy/github/`. +- [x] Reine Auth-Bausteine für S256-PKCE, State-/Browserbindung und sichere Cookie-Ausgabe mit Negativtests. +- [ ] GitHub App registrieren/zuordnen und Benutzerautorisierung konfigurieren. +- [ ] Persistente, atomar konsumierte Login-Vorgänge und serverseitige Sessions implementieren. +- [ ] Mandanten-/Mitgliedschaftsmodell, Migrationen und echte PostgreSQL-RLS-Negativtests. +- [ ] Benutzergebundene GitHub-Tokens geschützt ablegen; ausschließlich damit Repository-Aktionen ausführen. +- [ ] Erster vertikaler Template-bis-Fork-Ablauf mit tatsächlichem Benutzerlogin. + +Die Auth-Helfer sind noch nicht an HTTP-Routen angebunden. Insbesondere ersetzt +ein erfolgreicher State-Vergleich keine atomare Einmalverwendung in PostgreSQL. +Die bestehende Anwendung bleibt bis zur vollständigen Integration geschlossen. diff --git a/landing-zone-configurator/docs/planning.md b/landing-zone-configurator/docs/planning.md index 5952516..b665f36 100644 --- a/landing-zone-configurator/docs/planning.md +++ b/landing-zone-configurator/docs/planning.md @@ -651,3 +651,16 @@ Vor Produktion zusätzlich: eigener CF-Deployer mit nur Space-Rechten, schmalere Credential-Veröffentlichung für Releases, Rotation, Restore-Abnahme, Rolling Deployment und definierter Rollback. Diese Punkte werden nicht durch den ersten Konnektivitätstest als erledigt markiert. + + +## Portal-Design und Beginn des Login-Meilensteins (2026-09-30) + +Neue bestätigte Anforderung: Das Look & Feel soll identisch zum offiziellen +STACKIT Portal werden. Referenz ist die vom Benutzer geöffnete Projektstartseite. +[Design-Abgleich und Fortschritt](design-reference.md) dokumentieren die Abnahme. +Die Portalansicht konnte wegen eines Browser-Verbindungsfehlers noch nicht gelesen +werden; keine konkreten Designwerte sind bereits bestätigt. + +Der [GitHub-App-Entwurf](../deploy/github/README.md) und die getesteten +PKCE-/State-/Session-Token-Helfer sind vorbereitet. Dies aktiviert noch keinen +Login und bedeutet keine abgeschlossene Session-, RLS- oder Token-Store-Integration. From 2bd0120cc002e48ffccdd2d3a17a6e354b661025 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Weberru=C3=9F?= Date: Wed, 30 Sep 2026 09:57:38 +0200 Subject: [PATCH 027/175] docs(configurator): capture public portal design sources reproducibly --- .../docs/design-reference.md | 41 ++++++++-- landing-zone-configurator/docs/planning.md | 7 +- .../tools/portal-reference/README.md | 37 +++++++++ .../tools/portal-reference/fetch.py | 80 +++++++++++++++++++ 4 files changed, 157 insertions(+), 8 deletions(-) create mode 100644 landing-zone-configurator/tools/portal-reference/README.md create mode 100644 landing-zone-configurator/tools/portal-reference/fetch.py diff --git a/landing-zone-configurator/docs/design-reference.md b/landing-zone-configurator/docs/design-reference.md index 63603a7..524b926 100644 --- a/landing-zone-configurator/docs/design-reference.md +++ b/landing-zone-configurator/docs/design-reference.md @@ -16,12 +16,41 @@ Theme als Ersatz für diesen Abgleich. - [ ] Login, Template-Auswahl, fachlichen Editor und Fork-Auswahl gegen die Referenz visuell abnehmen. - [ ] Tastaturbedienung, Fokus, Kontrast und schmale Ansichten prüfen. -Die tatsächlichen Portalwerte wurden noch nicht erfasst. Der Browser-Zugriff -scheitert vor der Seitenauswahl: Die Verbindung referenziert eine fehlende ältere -Plugin-Version (`26.917.71314`), obwohl das verfügbare Plugin `26.924.22138` ist. -Neuverbindung und Rücksetzen der Browser-Ausführungsumgebung halfen nicht. -Das ist kein Login-Problem des STACKIT Portals. Keine Browser-Sitzungen, Cookies -oder Anmeldedaten wurden aus anderen Quellen ausgelesen. +## Öffentliche Designquellen erfasst (2026-09-30) + +- [x] Portal-HTML, referenzierte Stylesheets und Haupt-JavaScript ohne Login per `curl` abgerufen. +- [x] Originale `--nds-*`-Deklarationen mit Marken-/Theme-Selektoren und Font-Face-Definitionen extrahiert. +- [x] Wiederholbaren Abruf mit Quellen-URLs, UTC-Zeitpunkt und SHA-256 dokumentiert. +- [ ] Aktive Marke, Theme und berechnete Styles der Projektansicht visuell bestätigen. + +Abruf: [Anleitung und Werkzeug](../tools/portal-reference/README.md). +Die lokalen Quelldateien liegen unter `.local/portal-reference/` und werden nicht +versioniert. Persönliche Tokens, Cookies oder Projektzugriffe sind dafür unnötig. + +Der erfasste öffentliche [Portal-Stylesheet](https://portal.stackit.cloud/styles-HOVMV7T5.css) +enthält folgende Referenzwerte (Dateiname gilt für diesen Snapshot): + +| Geltungsbereich | Beobachtete Deklaration | +| --- | --- | +| `html.brand--digits` | Fließtext: DIN 2014; Überschriften: Univia Pro | +| `html.brand--digits` | Heller Branding-Header: `#0a1e2dff` | +| `html.brand--digits` | CTA: `#00c2ccff`, Hover `#00b2bcff`, aktiv `#00a2acff` | +| `html.brand--neutral` | Fließtext und Überschriften: Inter | +| `html.brand--schwarz` | Fließtext und Überschriften: Helvetica Now Var | +| Theme-Selektoren | `theme--light`, `theme--dark`, `theme--a11y` | +| Body-Regel | 16 px, Gewicht 400, Zeilenhöhe 1.56; Farben über Theme-Tokens | + +Die HTML-Antwort enthält zunächst nur `html lang="en"`, keine ausgewählte Marke +oder Theme-Klasse. Die Tokens dürfen daher nicht über alle Selektoren hinweg +zusammengeführt werden. Die Extraktion ist kein vollständiger CSS-Parser und +berechnet weder Kaskade noch responsive Styles. Schriftdateien und Portal-Code +wurden nicht als App-Abhängigkeit übernommen. Vor Übernahme der Schriften ist der +vorgesehene interne Bezug bzw. ihre Wiederverwendung zu klären. + +`curl` liefert die SPA-Quelldateien, aber keine gerenderte, angemeldete +Projektstartseite. Shell-Geometrie, konkrete Komponenten und Interaktionszustände +bleiben deshalb offene Abnahmepunkte. Der frühere Browser-Verbindungsfehler +(fehlende Plugin-Version `26.917.71314`) verhindert den öffentlichen Abruf nicht. ## Unabhängig vorbereitete Arbeit diff --git a/landing-zone-configurator/docs/planning.md b/landing-zone-configurator/docs/planning.md index b665f36..7aa8a47 100644 --- a/landing-zone-configurator/docs/planning.md +++ b/landing-zone-configurator/docs/planning.md @@ -658,8 +658,11 @@ Konnektivitätstest als erledigt markiert. Neue bestätigte Anforderung: Das Look & Feel soll identisch zum offiziellen STACKIT Portal werden. Referenz ist die vom Benutzer geöffnete Projektstartseite. [Design-Abgleich und Fortschritt](design-reference.md) dokumentieren die Abnahme. -Die Portalansicht konnte wegen eines Browser-Verbindungsfehlers noch nicht gelesen -werden; keine konkreten Designwerte sind bereits bestätigt. +Die öffentlichen Portal-Assets wurden anschließend erfolgreich ohne Anmeldung per +`curl` erfasst. Originale NDS-Tokens und Schriftdefinitionen sind mit ihren +Marken-/Theme-Selektoren dokumentiert; [Abrufwerkzeug](../tools/portal-reference/README.md). +Die gerenderte Projektansicht und ihre aktive Marken-/Theme-Auswahl sind noch +nicht visuell bestätigt. Der [GitHub-App-Entwurf](../deploy/github/README.md) und die getesteten PKCE-/State-/Session-Token-Helfer sind vorbereitet. Dies aktiviert noch keinen diff --git a/landing-zone-configurator/tools/portal-reference/README.md b/landing-zone-configurator/tools/portal-reference/README.md new file mode 100644 index 0000000..a15fbd9 --- /dev/null +++ b/landing-zone-configurator/tools/portal-reference/README.md @@ -0,0 +1,37 @@ +# Öffentliche STACKIT-Portal-Designreferenz abrufen + +Vom Repository-Verzeichnis aus (Python 3 und curl erforderlich): + +```sh +python3 landing-zone-configurator/tools/portal-reference/fetch.py +``` + +Das Werkzeug lädt `https://portal.stackit.cloud/`, entdeckt die aktuellen +Stylesheet- und Haupt-JavaScript-Dateinamen im HTML und lädt diese direkt per +`curl`. Dadurch bleiben wechselnde Asset-Hashes ohne manuelles Nachführen nutzbar. +Keine Anmeldung, Bearer-Tokens, Cookies oder Projekt-API-Aufrufe erforderlich. + +Der grundlegende Abruf lässt sich auch direkt ausführen: + +```sh +curl -q --fail --silent --show-error --max-time 30 \ + https://portal.stackit.cloud/ --output /tmp/stackit-portal.html +``` + +`-q` unterbindet persönliche curlrc-Vorgaben. Zertifikate werden normal geprüft; +Redirects werden nicht verfolgt. Bei Zscaler-/Zertifikatsfehlern die Verbindung +prüfen und erneut ausführen; TLS-Prüfung nicht deaktivieren. + +Ausgabe im ignorierten Verzeichnis `landing-zone-configurator/.local/portal-reference/`: + +- `portal.html`, `stylesheet-*.css`, `main-*.js`: unveränderte öffentliche Quellen. +- `sources.json`: Quellen-URLs, Abrufzeitpunkt, Dateigrößen und SHA-256. +- `design-tokens.json`: NDS-Deklarationen nach Selektor und Font-Face-Definitionen. + +Das Verzeichnis enthält jeweils den letzten Abruf. Die Token-Extraktion ist eine +Quellenanalyse, kein vollständiger CSS-Parser oder Browser. Marken, Themes und +Media-Queries müssen getrennt ausgewertet werden. Ein HTTP-Erfolg bestätigt +keine gerenderte Projektansicht. Fonts werden nicht heruntergeladen; vollständige +Portal-Bundles werden nicht in die App übernommen oder eingecheckt. + +Erfasste Werte und offene visuelle Abnahme: [Designreferenz](../../docs/design-reference.md). diff --git a/landing-zone-configurator/tools/portal-reference/fetch.py b/landing-zone-configurator/tools/portal-reference/fetch.py new file mode 100644 index 0000000..ac61927 --- /dev/null +++ b/landing-zone-configurator/tools/portal-reference/fetch.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +"""Capture public portal design sources without credentials or browser access.""" +import hashlib +import json +import re +import subprocess +from datetime import datetime, timezone +from html.parser import HTMLParser +from pathlib import Path +from urllib.parse import urljoin, urlsplit + +ORIGIN = "https://portal.stackit.cloud" +BASE = Path(__file__).resolve().parents[2] +OUTPUT = BASE / ".local" / "portal-reference" +OUTPUT.mkdir(parents=True, exist_ok=True) + + +def fetch(url, name): + if urlsplit(url).scheme != "https" or urlsplit(url).netloc != "portal.stackit.cloud": + raise ValueError("Only public portal assets are permitted") + target = OUTPUT / name + # -q ignores personal curlrc (including any default authentication options). + subprocess.run([ + "curl", "-q", "--fail", "--silent", "--show-error", "--max-time", "30", + "--max-filesize", "10000000", url, "--output", str(target), + ], check=True) + raw = target.read_bytes() + return raw.decode("utf-8"), { + "url": url, "file": name, "bytes": len(raw), + "sha256": hashlib.sha256(raw).hexdigest(), + } + + +class Assets(HTMLParser): + def __init__(self): + super().__init__() + self.styles = [] + self.main = [] + + def handle_starttag(self, tag, attributes): + attrs = dict(attributes) + if tag == "link" and attrs.get("rel") == "stylesheet": + self.styles.append(attrs["href"]) + if tag == "script" and re.fullmatch(r"main-[A-Za-z0-9]+\.js", attrs.get("src", "")): + self.main.append(attrs["src"]) + + +html, html_source = fetch(ORIGIN + "/", "portal.html") +assets = Assets() +assets.feed(html) +sources = [html_source] +token_groups = [] +fonts = [] +for index, href in enumerate(dict.fromkeys(assets.styles)): + css, source = fetch(urljoin(ORIGIN + "/", href), f"stylesheet-{index}.css") + sources.append(source) + fonts.extend(re.findall(r"@font-face\s*\{[^}]+\}", css)) + # Capture declarations with their selector. This is source extraction, not a + # CSS cascade evaluator: selected brand/theme and viewport still need review. + for rule in css.split("}"): + selector, separator, values = rule.rpartition("{") + declarations = re.findall(r"(--nds-[\w-]+)\s*:\s*([^;}]+)", values) + if separator and declarations: + token_groups.append({"source": source["file"], "selector": selector.strip(), + "declarations": dict(declarations)}) +for index, src in enumerate(dict.fromkeys(assets.main)): + _, source = fetch(urljoin(ORIGIN + "/", src), f"main-{index}.js") + sources.append(source) +if not token_groups: + raise RuntimeError("No NDS tokens found; inspect the portal asset structure") +(OUTPUT / "design-tokens.json").write_text(json.dumps({ + "note": "Source declarations only; not computed styles or a rendered project page.", + "groups": token_groups, "fontFaces": list(dict.fromkeys(fonts)), +}, indent=2) + "\n") +(OUTPUT / "sources.json").write_text(json.dumps({ + "fetchedAt": datetime.now(timezone.utc).isoformat(), + "authentication": "none", "sources": sources, +}, indent=2) + "\n") +print(f"Saved {len(sources)} public sources and {len(token_groups)} token groups to {OUTPUT}") +print("No login, bearer token, cookies or project API requests used.") From 76db2526970c80c619b83ae546a43b5cc723b555 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Weberru=C3=9F?= Date: Wed, 30 Sep 2026 10:23:48 +0200 Subject: [PATCH 028/175] feat(configurator): add Nebula-based template catalogue and standalone editor --- .github/workflows/configurator-release.yml | 11 + .github/workflows/validate-configurator.yml | 34 + .../app/apps/web/README.md | 28 + .../app/apps/web/package.json | 3 +- .../apps/web/public/brand/DIN2014-bold.woff2 | Bin 0 -> 32484 bytes .../web/public/brand/DIN2014-normal.woff2 | Bin 0 -> 31920 bytes .../public/brand/UniviaProMedium-normal.woff2 | Bin 0 -> 52892 bytes .../app/apps/web/public/brand/stackit.svg | 11 + .../src/components/ConfigurationEditor.tsx | 299 ++++++ .../app/apps/web/src/components/Field.tsx | 56 ++ .../apps/web/src/components/ProjectsStep.tsx | 240 +++++ .../app/apps/web/src/components/Topology.tsx | 83 ++ .../app/apps/web/src/generated/templates.json | 542 +++++++++++ .../app/apps/web/src/main.tsx | 328 ++++++- .../app/apps/web/src/nebula-tokens.css | 125 +++ .../app/apps/web/src/style.css | 857 +++++++++++++++++- .../app/apps/web/src/templates.ts | 75 ++ landing-zone-configurator/app/biome.json | 9 +- .../app/e2e/configurator.spec.ts | 134 +++ .../app/package-lock.json | 47 + landing-zone-configurator/app/package.json | 6 +- .../app/packages/domain/src/configuration.ts | 194 ++++ .../app/packages/domain/src/index.ts | 2 + .../app/playwright.config.ts | 36 + .../app/tests/configuration.test.ts | 95 ++ .../app/tsconfig.tests.json | 7 +- .../docs/brand-assets.md | 19 + .../docs/design-reference.md | 52 +- landing-zone-configurator/docs/planning.md | 25 + .../tools/hcl-adapter/README.md | 29 +- .../tools/hcl-adapter/go.mod | 19 + .../tools/hcl-adapter/go.sum | 28 + .../tools/hcl-adapter/main.go | 105 +++ .../tools/hcl-adapter/main_test.go | 47 + .../tools/portal-reference/README.md | 13 + .../tools/portal-reference/export-tokens.py | 54 ++ 36 files changed, 3555 insertions(+), 58 deletions(-) create mode 100644 landing-zone-configurator/app/apps/web/public/brand/DIN2014-bold.woff2 create mode 100644 landing-zone-configurator/app/apps/web/public/brand/DIN2014-normal.woff2 create mode 100644 landing-zone-configurator/app/apps/web/public/brand/UniviaProMedium-normal.woff2 create mode 100644 landing-zone-configurator/app/apps/web/public/brand/stackit.svg create mode 100644 landing-zone-configurator/app/apps/web/src/components/ConfigurationEditor.tsx create mode 100644 landing-zone-configurator/app/apps/web/src/components/Field.tsx create mode 100644 landing-zone-configurator/app/apps/web/src/components/ProjectsStep.tsx create mode 100644 landing-zone-configurator/app/apps/web/src/components/Topology.tsx create mode 100644 landing-zone-configurator/app/apps/web/src/generated/templates.json create mode 100644 landing-zone-configurator/app/apps/web/src/nebula-tokens.css create mode 100644 landing-zone-configurator/app/apps/web/src/templates.ts create mode 100644 landing-zone-configurator/app/e2e/configurator.spec.ts create mode 100644 landing-zone-configurator/app/packages/domain/src/configuration.ts create mode 100644 landing-zone-configurator/app/playwright.config.ts create mode 100644 landing-zone-configurator/app/tests/configuration.test.ts create mode 100644 landing-zone-configurator/docs/brand-assets.md create mode 100644 landing-zone-configurator/tools/hcl-adapter/go.mod create mode 100644 landing-zone-configurator/tools/hcl-adapter/go.sum create mode 100644 landing-zone-configurator/tools/hcl-adapter/main.go create mode 100644 landing-zone-configurator/tools/hcl-adapter/main_test.go create mode 100644 landing-zone-configurator/tools/portal-reference/export-tokens.py diff --git a/.github/workflows/configurator-release.yml b/.github/workflows/configurator-release.yml index e9ddb44..8f2206b 100644 --- a/.github/workflows/configurator-release.yml +++ b/.github/workflows/configurator-release.yml @@ -39,6 +39,17 @@ jobs: npm install --global npm@11.19.0 npm ci npm run check + - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 + with: + go-version: '1.27.1' + cache-dependency-path: landing-zone-configurator/tools/hcl-adapter/go.sum + - name: Verify source templates match the release catalogue + working-directory: landing-zone-configurator/tools/hcl-adapter + run: go run . -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/public ../.local/release/runtime diff --git a/.github/workflows/validate-configurator.yml b/.github/workflows/validate-configurator.yml index da0dccb..bacc765 100644 --- a/.github/workflows/validate-configurator.yml +++ b/.github/workflows/validate-configurator.yml @@ -4,6 +4,7 @@ on: pull_request: paths: - 'landing-zone-configurator/**' + - 'src/config/**' - '.github/workflows/validate-configurator.yml' - '.github/workflows/configurator-bootstrap.yml' - '.github/workflows/configurator-platform.yml' @@ -13,6 +14,7 @@ on: branches: [main, feature/landing-zone-configurator] paths: - 'landing-zone-configurator/**' + - 'src/config/**' - '.github/workflows/validate-configurator.yml' - '.github/workflows/configurator-bootstrap.yml' - '.github/workflows/configurator-platform.yml' @@ -28,6 +30,25 @@ concurrency: cancel-in-progress: true jobs: + template-catalogue: + runs-on: ubuntu-latest + timeout-minutes: 10 + defaults: + run: + working-directory: landing-zone-configurator/tools/hcl-adapter + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 + with: + go-version: '1.27.1' + cache-dependency-path: landing-zone-configurator/tools/hcl-adapter/go.sum + - name: Test HCL importer and verify catalogue freshness + run: | + go test ./... + go run . -check + application: runs-on: ubuntu-latest timeout-minutes: 15 @@ -51,6 +72,19 @@ jobs: run: npm ci - name: Lint, typecheck, build and test run: npm run check + - name: Install isolated browser for UI acceptance + run: npx playwright install --with-deps chromium + - name: Test template-to-draft flow on desktop and mobile + run: npm run test:e2e + - name: Upload UI evidence + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: configurator-ui-evidence + path: landing-zone-configurator/.local/browser-tests + include-hidden-files: true + retention-days: 7 + infrastructure: name: OpenTofu (${{ matrix.root }}) diff --git a/landing-zone-configurator/app/apps/web/README.md b/landing-zone-configurator/app/apps/web/README.md index d3857d4..bdbff6e 100644 --- a/landing-zone-configurator/app/apps/web/README.md +++ b/landing-zone-configurator/app/apps/web/README.md @@ -1,3 +1,31 @@ # Web-Oberfläche Geplant: React/TypeScript, Fachformulare, Topologie, Chat und Plan-/Run-Ansicht. Keine Cloud- oder GitHub-Tokens im Browser. + +## Template-Editor (erster fachlicher Stand) + +- Acht aus `src/config` generierte Templates mit Suche und Strukturvorschau. +- Standalone-Kopie bearbeiten: Organisation, Region, Verantwortliche, Landing Zones, + Umgebungen, Secrets-Manager-Auswahl und Sandboxes. +- Originale Portal-Design-Tokens (digits/light), lokal ausgelieferte Markenassets; + Herkunft und noch offene visuelle Abnahme in `docs/design-reference.md`. +- Zustand nur im aktuellen Tab-Speicher; keine Cloud-/GitHub-Schreiboperationen. +- Download `landing-zone.lzc.json`: versionierter Entwurf mit Template-Quellhash + und fachlich bearbeiteten Konfigurationswerten. Kein direkt ausführbarer + `.tfvars.json`-Export und noch kein Import dieses Entwurfformats. +- Netzwerk-Templates bleiben vorerst schreibgeschützt. Der Standalone-Editor setzt + ausdrücklich `corporate: false`, weil das aktuelle Quelltemplate ohne Hub sonst + den Accelerator-Default `true` erben würde. + +Lokale Abnahme aus `app/` nach `npm run build`: + +```sh +npx playwright install chromium +npm run test:e2e +``` + +Alternativ mit installiertem Chrome: `LZC_TEST_CHROME=true npm run test:e2e`. +Tests verwenden isolierte Browser-Kontexte ohne vorhandene Benutzersitzung. +Screenshots/Fehlertraces liegen ignoriert unter `.local/browser-tests` und werden +in der Validierung als kurzlebige CI-Artefakte bereitgestellt. Desktop- und +Mobilbreite prüfen Suche, Vorschau, Validierung, Download und Quellisolierung. diff --git a/landing-zone-configurator/app/apps/web/package.json b/landing-zone-configurator/app/apps/web/package.json index 50ee3a0..ab6ae40 100644 --- a/landing-zone-configurator/app/apps/web/package.json +++ b/landing-zone-configurator/app/apps/web/package.json @@ -11,7 +11,8 @@ "dependencies": { "@lzc/contracts": "0.1.0", "react": "19.3.0", - "react-dom": "19.3.0" + "react-dom": "19.3.0", + "@lzc/domain": "0.1.0" }, "devDependencies": { "@types/react": "19.3.0", diff --git a/landing-zone-configurator/app/apps/web/public/brand/DIN2014-bold.woff2 b/landing-zone-configurator/app/apps/web/public/brand/DIN2014-bold.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..235ff47b2e80aa4ef44ea279ff5e26b8d694ad52 GIT binary patch literal 32484 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 literal 0 HcmV?d00001 diff --git a/landing-zone-configurator/app/apps/web/public/brand/DIN2014-normal.woff2 b/landing-zone-configurator/app/apps/web/public/brand/DIN2014-normal.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..12fc8fc23f600c7cea13962b43f3636d462104ec GIT binary patch literal 31920 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 HDs@r-9+hNJ literal 0 HcmV?d00001 diff --git a/landing-zone-configurator/app/apps/web/public/brand/UniviaProMedium-normal.woff2 b/landing-zone-configurator/app/apps/web/public/brand/UniviaProMedium-normal.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..b179363ab4fb3421cc4e09619f72ec8b4f97cff7 GIT binary patch literal 52892 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 literal 0 HcmV?d00001 diff --git a/landing-zone-configurator/app/apps/web/public/brand/stackit.svg b/landing-zone-configurator/app/apps/web/public/brand/stackit.svg new file mode 100644 index 0000000..9185d86 --- /dev/null +++ b/landing-zone-configurator/app/apps/web/public/brand/stackit.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/landing-zone-configurator/app/apps/web/src/components/ConfigurationEditor.tsx b/landing-zone-configurator/app/apps/web/src/components/ConfigurationEditor.tsx new file mode 100644 index 0000000..e020dff --- /dev/null +++ b/landing-zone-configurator/app/apps/web/src/components/ConfigurationEditor.tsx @@ -0,0 +1,299 @@ +import { + buildConfiguration, + type ConfigurationDraft, + type Template, + validateDraft, +} from "@lzc/domain"; +import { useState } from "react"; +import { Field } from "./Field"; +import { ProjectsStep } from "./ProjectsStep"; +import { Topology } from "./Topology"; + +type Step = "basics" | "projects" | "review"; +const steps: { id: Step; title: string }[] = [ + { id: "basics", title: "Grundlagen" }, + { id: "projects", title: "Projekte" }, + { id: "review", title: "Prüfen" }, +]; + +function issueLabel(field: string, draft: ConfigurationDraft): string { + const labels: Record = { + name: "Name", + company: "Unternehmen", + companyCode: "Unternehmenskürzel", + organization: "Organisations-ID", + owner: "Verantwortlich", + region: "Region", + projects: "Projekte", + key: "Kennung", + code: "Projektkürzel", + environment: "Umgebung", + }; + const [kind, id, property] = field.split("."); + if (property) { + const item = + kind === "project" + ? draft.projects.find((p) => p.id === id) + : draft.sandboxes.find((s) => s.id === id); + return `${item?.name || (kind === "project" ? "Landing Zone" : "Sandbox")} · ${labels[property] || property}`; + } + return labels[field] || field; +} + +export function ConfigurationEditor({ + template, + draft, + onChange, +}: { + template: Template; + draft: ConfigurationDraft; + onChange: (draft: ConfigurationDraft) => void; +}) { + const [step, setStep] = useState("basics"); + const [showErrors, setShowErrors] = useState(false); + const [notice, setNotice] = useState(""); + const issues = validateDraft(draft); + const values = buildConfiguration(template, draft); + const error = (field: string) => + showErrors ? issues.find((i) => i.field === field)?.message : undefined; + const patch = (update: Partial) => { + onChange({ ...draft, ...update }); + setNotice(""); + }; + const focusIssue = (field: string) => { + setStep( + field.startsWith("project") || field.startsWith("sandbox") + ? "projects" + : "basics", + ); + requestAnimationFrame(() => document.getElementById(field)?.focus()); + }; + const review = () => { + setShowErrors(true); + setStep("review"); + }; + const download = () => { + if (issues.length) { + setShowErrors(true); + const first = issues[0]; + if (first) focusIssue(first.field); + return; + } + const source = template; + const file = new Blob( + [ + JSON.stringify( + { + schemaVersion: 1, + kind: "landing-zone-configurator-draft", + name: draft.name, + template: { + id: source.id, + source: source.source, + sha256: source.sha256, + }, + configuration: buildConfiguration(source, draft), + }, + null, + 2, + ), + ], + { type: "application/json" }, + ); + const url = URL.createObjectURL(file); + const link = document.createElement("a"); + link.href = url; + link.download = "landing-zone.lzc.json"; + link.click(); + setTimeout(() => URL.revokeObjectURL(url), 1000); + setNotice( + "Der Entwurf wurde zum Download bereitgestellt. Er ist noch nicht im Fork gespeichert.", + ); + }; + + return ( + <> +
+ + Der Entwurf bleibt bis zum Neuladen in diesem Tab. Du kannst ihn nach + der Prüfung herunterladen. + + GitHub-Speicherung folgt +
+ +
+
+ {step === "basics" && ( +
+
+

Grundlagen

+ * Pflichtfelder +
+

+ Wer betreibt diese Landing Zone und wo soll sie entstehen? +

+
+ patch({ name })} + error={error("name")} + /> + patch({ company })} + error={error("company")} + /> + patch({ companyCode })} + error={error("companyCode")} + hint="Wird als Präfix für Ressourcennamen verwendet." + /> + patch({ owner })} + error={error("owner")} + hint="E-Mail-Adresse des in STACKIT registrierten Verantwortlichen." + /> + patch({ organization })} + error={error("organization")} + hint="Die ID findest du in den Organisationsdetails im STACKIT Portal." + /> + patch({ region })} + error={error("region")} + > + + + +
+
+ +
+
+ )} + {step === "projects" && ( + setStep("basics")} + onReview={review} + /> + )} + {step === "review" && ( +
+

Entwurf prüfen

+ {issues.length ? ( +
+

{issues.length} Angaben bitte prüfen

+
    + {issues.map((issue) => ( +
  • + +
  • + ))} +
+
+ ) : ( +
+ Die Eingaben sind vollständig und formal gültig. +
+ )} +
+
Organisation
+
{draft.company}
+
Region
+
{draft.region}
+
Landing Zones
+
{draft.projects.length}
+
Sandboxes
+
{draft.sandboxes.length}
+
Vorlage
+
Standalone
+
+

+ Der Download enthält deinen Konfigurationsentwurf. + Berechtigungen, Dienstverfügbarkeit und die tatsächliche + Ressourcenplanung werden erst beim späteren Deployment geprüft. +

+
+ + +
+

+ Speichern im GitHub-Fork und Deployment werden mit der Anmeldung + ergänzt. +

+

{notice}

+
+ Konfigurationsdaten ansehen +
{JSON.stringify(values, null, 2)}
+
+
+ )} +
+ +
+ + ); +} diff --git a/landing-zone-configurator/app/apps/web/src/components/Field.tsx b/landing-zone-configurator/app/apps/web/src/components/Field.tsx new file mode 100644 index 0000000..48b2748 --- /dev/null +++ b/landing-zone-configurator/app/apps/web/src/components/Field.tsx @@ -0,0 +1,56 @@ +import type { ReactNode } from "react"; + +export function Field({ + id, + label, + value, + onChange, + error, + hint, + type = "text", + children, +}: { + id: string; + label: string; + value: string; + onChange: (value: string) => void; + error?: string | undefined; + hint?: string; + type?: "text" | "email"; + children?: ReactNode; +}) { + const shared = { + id, + value, + "aria-invalid": !!error, + "aria-describedby": error ? `${id}-error` : hint ? `${id}-hint` : undefined, + }; + return ( +
+ + {children ? ( + + ) : ( + onChange(e.target.value)} + /> + )} + {error ? ( +

+ {error} +

+ ) : hint ? ( +

+ {hint} +

+ ) : null} +
+ ); +} diff --git a/landing-zone-configurator/app/apps/web/src/components/ProjectsStep.tsx b/landing-zone-configurator/app/apps/web/src/components/ProjectsStep.tsx new file mode 100644 index 0000000..3f01b3b --- /dev/null +++ b/landing-zone-configurator/app/apps/web/src/components/ProjectsStep.tsx @@ -0,0 +1,240 @@ +import type { ConfigurationDraft } from "@lzc/domain"; +import { Field } from "./Field"; + +export function ProjectsStep({ + draft, + patch, + error, + onBack, + onReview, +}: { + draft: ConfigurationDraft; + patch: (update: Partial) => void; + error: (field: string) => string | undefined; + onBack: () => void; + onReview: () => void; +}) { + return ( +
+

Projekte & Sandboxes

+

+ Landing Zones bilden deine Workloads ab. Sandboxes bieten Platz zum + Experimentieren. +

+
+

+ Standalone-Projekte werden ohne Anbindung an einen zentralen + Netzwerk-Hub erstellt. Dafür setzen wir die Netzwerkzuordnung der + Vorlage ausdrücklich auf eigenständig. +

+
+ {draft.projects.map((p) => ( +
+ {p.name || "Neue Landing Zone"} +
+ + patch({ + projects: draft.projects.map((x) => + x.id === p.id ? { ...x, name } : x, + ), + }) + } + error={error(`project.${p.id}.name`)} + /> + + patch({ + projects: draft.projects.map((x) => + x.id === p.id ? { ...x, key } : x, + ), + }) + } + error={error(`project.${p.id}.key`)} + /> + + patch({ + projects: draft.projects.map((x) => + x.id === p.id ? { ...x, code } : x, + ), + }) + } + error={error(`project.${p.id}.code`)} + /> + + patch({ + projects: draft.projects.map((x) => + x.id === p.id ? { ...x, owner } : x, + ), + }) + } + error={error(`project.${p.id}.owner`)} + /> + + patch({ + projects: draft.projects.map((x) => + x.id === p.id ? { ...x, environment } : x, + ), + }) + } + error={error(`project.${p.id}.environment`)} + > + + + + + +
+ + +
+ ))} + +

Sandboxes

+ {draft.sandboxes.map((s) => ( +
+ {s.name || "Neue Sandbox"} +
+ + patch({ + sandboxes: draft.sandboxes.map((x) => + x.id === s.id ? { ...x, name } : x, + ), + }) + } + error={error(`sandbox.${s.id}.name`)} + /> + + patch({ + sandboxes: draft.sandboxes.map((x) => + x.id === s.id ? { ...x, owner } : x, + ), + }) + } + error={error(`sandbox.${s.id}.owner`)} + /> +
+ +
+ ))} + + {error("projects") &&

{error("projects")}

} +
+ + +
+
+ ); +} diff --git a/landing-zone-configurator/app/apps/web/src/components/Topology.tsx b/landing-zone-configurator/app/apps/web/src/components/Topology.tsx new file mode 100644 index 0000000..8262f4d --- /dev/null +++ b/landing-zone-configurator/app/apps/web/src/components/Topology.tsx @@ -0,0 +1,83 @@ +import { objectValue, textValue, type Values } from "@lzc/domain"; + +export function Topology({ values }: { values: Values }) { + const projects = Object.entries(objectValue(values.landing_zones)); + const sandboxes = Array.isArray(values.sandboxes) ? values.sandboxes : []; + const regionalHubs = objectValue(values.connectivity_regions); + const hubs = Object.keys(regionalHubs).length + ? Object.keys(regionalHubs) + : values.connectivity + ? [textValue(values.region)] + : []; + return ( +
+
Struktur deiner Landing Zone
+

+ Organisationsstruktur · keine Netzwerk- oder Ressourcenplanung +

+
+ +
+ Organisation + + {textValue(values.company_name) || "Deine Organisation"} + +
+
+
+ {hubs.map((region) => ( +
+ +
+ Konnektivität · {region} + Netzwerk-Hub +
+
+ ))} + {projects.map(([key, raw]) => { + const p = objectValue(raw); + return ( +
+ +
+ Landing Zone · {textValue(p.env) || "dev"} + + {textValue(p.project_name) || "Unbenanntes Projekt"} + + + {textValue(p.region) || + textValue(values.region) || + "Region aus Vorlage"} + +
+
+ ); + })} + {sandboxes.map((raw, index) => { + const s = objectValue(raw); + // Catalogue entries have no persistent ID; name plus position identifies the read-only diagram node. + const key = `${textValue(s.project_name)}-${index}`; + return ( +
+ +
+ Sandbox + + {textValue(s.project_name) || "Unbenannte Sandbox"} + +
+
+ ); + })} +
+
+ ); +} diff --git a/landing-zone-configurator/app/apps/web/src/generated/templates.json b/landing-zone-configurator/app/apps/web/src/generated/templates.json new file mode 100644 index 0000000..7be478c --- /dev/null +++ b/landing-zone-configurator/app/apps/web/src/generated/templates.json @@ -0,0 +1,542 @@ +{ + "schemaVersion": 1, + "templates": [ + { + "id": "hub-and-spoke-finance-research", + "source": "src/config/hub-and-spoke-finance-research.tfvars", + "sha256": "b57cdf13a38ad9f262e8c02d7985dd6730eeb91ddf77cd5a583440e6c12eda21", + "values": { + "company_code": "exc", + "company_name": "Example Corp", + "connectivity": { + "naming_pattern": "exc-connectivity", + "network_areas": { + "finance": { + "default_prefix_length": 26, + "max_prefix_length": 28, + "min_prefix_length": 24, + "name": "finance-sna", + "ranges": [ + "10.0.0.0/16" + ], + "transfer_network": "10.1.0.0/24" + }, + "research": { + "default_prefix_length": 26, + "max_prefix_length": 28, + "min_prefix_length": 24, + "name": "research-sna", + "ranges": [ + "10.2.0.0/16" + ], + "transfer_network": "10.3.0.0/24" + } + } + }, + "landing_zones": { + "finance": { + "corporate": true, + "env": "prod", + "network_area_key": "finance", + "network_prefix_length": 24, + "owner_email": "finance-platform@example.com", + "project_code": "finance", + "project_name": "Finance Workload" + }, + "research": { + "corporate": true, + "env": "prod", + "network_area_key": "research", + "network_prefix_length": 24, + "owner_email": "research-platform@example.com", + "project_code": "research", + "project_name": "Research Workload" + } + }, + "organization_id": "00000000-0000-0000-0000-000000000000", + "owner_email": "platform@example.com", + "region": "eu01" + } + }, + { + "id": "hub-and-spoke-firewall", + "source": "src/config/hub-and-spoke-firewall.tfvars", + "sha256": "79352e9384ddf09a38c6742a3be08c4030d57b047daafd62c883361cb3bf91f5", + "values": { + "company_code": "exc", + "company_name": "Example Corp", + "connectivity": { + "dns_zones": { + "example-corp": { + "dns_name": "example-corp.stackit.run" + } + }, + "firewall": { + "flavor": "c1.2", + "lan_network_range": "10.0.2.0/28", + "name": "opnsense-26.1", + "wan_network_range": "10.0.2.16/28", + "zone": "eu01-m" + }, + "network_area": { + "default_prefix_length": 25, + "max_prefix_length": 28, + "min_prefix_length": 24, + "ranges": [ + "10.0.0.0/16" + ], + "transfer_network": "10.255.0.0/24" + } + }, + "labels": { + "managed_by": "opentofu" + }, + "landing_zones": { + "corp-exmpl": { + "corporate": true, + "env": "prod", + "network_prefix_length": 24, + "owner_email": "owner@example.com", + "project_code": "data", + "project_name": "Data Platform" + }, + "public-exmpl": { + "corporate": false, + "env": "prod", + "owner_email": "owner@example.com", + "project_code": "api", + "project_name": "External API Gateway" + } + }, + "organization_id": "00000000-0000-0000-0000-000000000000", + "owner_email": "owner@example.com", + "region": "eu01", + "sandboxes": [ + { + "project_name": "Sandbox Team Alpha", + "project_owner_email": "owner@example.com" + } + ] + } + }, + { + "id": "hub-and-spoke-multi-area", + "source": "src/config/hub-and-spoke-multi-area.tfvars", + "sha256": "b97932ffc01c71afc3f69fa5e946a29d961e830eacb2687b6c8d2955d8762961", + "values": { + "company_code": "exc", + "company_name": "Example Corp", + "connectivity": { + "dns_zones": { + "regulated": { + "dns_name": "regulated.example.stackit.run", + "network_area_key": "regulated" + }, + "shared": { + "dns_name": "shared.example.stackit.run", + "network_area_key": "shared" + } + }, + "naming_pattern": "example-connectivity", + "network_areas": { + "regulated": { + "default_prefix_length": 25, + "max_prefix_length": 28, + "min_prefix_length": 24, + "name": "regulated-sna", + "ranges": [ + "10.0.0.0/16" + ], + "transfer_network": "10.255.0.0/24" + }, + "shared": { + "default_prefix_length": 25, + "max_prefix_length": 28, + "min_prefix_length": 24, + "name": "shared-sna", + "ranges": [ + "10.1.0.0/16" + ], + "transfer_network": "10.254.0.0/24" + } + } + }, + "landing_zones": { + "regulated_workload": { + "corporate": true, + "env": "live", + "network_area_key": "regulated", + "network_prefix_length": 24, + "owner_email": "platform@example.com", + "project_code": "regulated", + "project_name": "Regulated Workload" + }, + "shared_workload": { + "corporate": true, + "env": "live", + "network_area_key": "shared", + "network_prefix_length": 24, + "owner_email": "platform@example.com", + "project_code": "shared", + "project_name": "Shared Workload" + } + }, + "organization_id": "00000000-0000-0000-0000-000000000000", + "owner_email": "platform@example.com", + "region": "eu01" + } + }, + { + "id": "hub-and-spoke-multi-region", + "source": "src/config/hub-and-spoke-multi-region.tfvars", + "sha256": "0dac368609d548a38d79f6ac8acb8a989ec82278022568fbf6ab569164a7942f", + "values": { + "company_code": "exc", + "company_name": "Example Corp", + "connectivity_regions": { + "eu01": { + "naming_pattern": "exc-connectivity-eu01", + "network_areas": { + "primary": { + "default_prefix_length": 25, + "max_prefix_length": 28, + "min_prefix_length": 24, + "name": "eu01-primary-sna", + "ranges": [ + "10.0.0.0/16" + ], + "transfer_network": "10.1.0.0/24" + } + } + }, + "eu02": { + "naming_pattern": "exc-connectivity-eu02", + "network_areas": { + "primary": { + "default_prefix_length": 25, + "max_prefix_length": 28, + "min_prefix_length": 24, + "name": "eu02-primary-sna", + "ranges": [ + "10.2.0.0/16" + ], + "transfer_network": "10.3.0.0/24" + } + } + } + }, + "landing_zones": { + "eu01_workload": { + "corporate": true, + "env": "prod", + "network_area_key": "primary", + "network_prefix_length": 24, + "owner_email": "platform@example.com", + "project_code": "eu01app", + "project_name": "EU01 Workload", + "region": "eu01" + }, + "eu02_workload": { + "corporate": true, + "env": "prod", + "network_area_key": "primary", + "network_prefix_length": 24, + "owner_email": "platform@example.com", + "project_code": "eu02app", + "project_name": "EU02 Workload", + "region": "eu02", + "secretsmanager_enabled": false + } + }, + "organization_id": "00000000-0000-0000-0000-000000000000", + "owner_email": "platform@example.com", + "platform_kubernetes": { + "eu01": { + "cluster": { + "name": "platform-eu01" + }, + "network": { + "network_area_key": "primary", + "sna_enabled": true + }, + "region": "eu01" + }, + "eu02": { + "cluster": { + "name": "platform-eu02", + "node_pools": [ + { + "availability_zones": [ + "eu02-1" + ], + "machine_type": "g3i.4", + "maximum": 2, + "minimum": 2, + "name": "system" + }, + { + "availability_zones": [ + "eu02-2" + ], + "machine_type": "g3i.4", + "maximum": 2, + "minimum": 2, + "name": "application" + } + ] + }, + "network": { + "network_area_key": "primary", + "sna_enabled": true + }, + "observability": { + "enabled": false + }, + "region": "eu02" + } + } + } + }, + { + "id": "hub-and-spoke-prod-nonprod-firewall", + "source": "src/config/hub-and-spoke-prod-nonprod-firewall.tfvars", + "sha256": "3b7aafc40a82a146c66c171fee3fa17bc8a6d28de549c7608a95167344411b2e", + "values": { + "company_code": "exc", + "company_name": "Example Corp", + "connectivity": { + "firewalls": { + "nonprod": { + "flavor": "c1.2", + "lan_network_range": "10.2.2.0/28", + "name": "opnsense-nonprod", + "wan_network_range": "10.2.2.16/28", + "zone": "eu01-m" + }, + "prod": { + "flavor": "c1.2", + "lan_network_range": "10.0.2.0/28", + "name": "opnsense-prod", + "wan_network_range": "10.0.2.16/28", + "zone": "eu01-m" + } + }, + "naming_pattern": "exc-connectivity", + "network_areas": { + "nonprod": { + "default_prefix_length": 26, + "max_prefix_length": 28, + "min_prefix_length": 24, + "name": "nonprod-sna", + "ranges": [ + "10.2.0.0/16" + ], + "transfer_network": "10.3.0.0/24" + }, + "prod": { + "default_prefix_length": 26, + "max_prefix_length": 28, + "min_prefix_length": 24, + "name": "prod-sna", + "ranges": [ + "10.0.0.0/16" + ], + "transfer_network": "10.1.0.0/24" + } + } + }, + "landing_zones": { + "development": { + "corporate": true, + "env": "dev", + "network_area_key": "nonprod", + "network_prefix_length": 24, + "owner_email": "platform@example.com", + "project_code": "dev", + "project_name": "Development Workload" + }, + "production": { + "corporate": true, + "env": "prod", + "network_area_key": "prod", + "network_prefix_length": 24, + "owner_email": "platform@example.com", + "project_code": "prod", + "project_name": "Production Workload" + }, + "test": { + "corporate": true, + "env": "test", + "network_area_key": "nonprod", + "network_prefix_length": 24, + "owner_email": "platform@example.com", + "project_code": "test", + "project_name": "Test Workload" + } + }, + "organization_id": "00000000-0000-0000-0000-000000000000", + "owner_email": "platform@example.com", + "region": "eu01" + } + }, + { + "id": "hub-and-spoke-tenant-isolation", + "source": "src/config/hub-and-spoke-tenant-isolation.tfvars", + "sha256": "20c89a9d3c0a5bb1b90d08a561f938ce762e987cdfb535787a4ac61ed512b049", + "values": { + "company_code": "exc", + "company_name": "Example Corp", + "connectivity": { + "naming_pattern": "exc-connectivity", + "network_areas": { + "tenant_a": { + "default_prefix_length": 26, + "max_prefix_length": 28, + "min_prefix_length": 24, + "name": "tenant-a-sna", + "ranges": [ + "10.0.0.0/16" + ], + "transfer_network": "10.1.0.0/24" + }, + "tenant_b": { + "default_prefix_length": 26, + "max_prefix_length": 28, + "min_prefix_length": 24, + "name": "tenant-b-sna", + "ranges": [ + "10.2.0.0/16" + ], + "transfer_network": "10.3.0.0/24" + }, + "tenant_c": { + "default_prefix_length": 26, + "max_prefix_length": 28, + "min_prefix_length": 24, + "name": "tenant-c-sna", + "ranges": [ + "10.4.0.0/16" + ], + "transfer_network": "10.5.0.0/24" + } + } + }, + "landing_zones": { + "tenant_a": { + "corporate": true, + "env": "prod", + "network_area_key": "tenant_a", + "network_prefix_length": 24, + "owner_email": "tenant-a@example.com", + "project_code": "tenanta", + "project_name": "Tenant A Workload" + }, + "tenant_b": { + "corporate": true, + "env": "prod", + "network_area_key": "tenant_b", + "network_prefix_length": 24, + "owner_email": "tenant-b@example.com", + "project_code": "tenantb", + "project_name": "Tenant B Workload" + }, + "tenant_c": { + "corporate": true, + "env": "prod", + "network_area_key": "tenant_c", + "network_prefix_length": 24, + "owner_email": "tenant-c@example.com", + "project_code": "tenantc", + "project_name": "Tenant C Workload" + } + }, + "organization_id": "00000000-0000-0000-0000-000000000000", + "owner_email": "platform@example.com", + "region": "eu01" + } + }, + { + "id": "hub-and-spoke", + "source": "src/config/hub-and-spoke.tfvars", + "sha256": "b0010438e85f71c6bc70f8fc14f7ecb4b49cbabe5fea7634e8dcf21fa12047cc", + "values": { + "company_code": "exc", + "company_name": "Example Corp", + "connectivity": { + "dns_zones": { + "example-corp": { + "dns_name": "example-corp.stackit.run" + } + }, + "network_area": { + "default_prefix_length": 25, + "max_prefix_length": 28, + "min_prefix_length": 24, + "ranges": [ + "10.0.0.0/16" + ], + "transfer_network": "10.255.0.0/24" + } + }, + "labels": { + "managed_by": "opentofu" + }, + "landing_zones": { + "corp-exmpl": { + "corporate": true, + "env": "prod", + "network_prefix_length": 24, + "owner_email": "owner@example.com", + "project_code": "data", + "project_name": "Data Platform" + }, + "public-exmpl": { + "corporate": false, + "env": "prod", + "owner_email": "owner@example.com", + "project_code": "api", + "project_name": "External API Gateway" + } + }, + "organization_id": "00000000-0000-0000-0000-000000000000", + "owner_email": "owner@example.com", + "region": "eu01", + "sandboxes": [ + { + "project_name": "Sandbox Team Alpha", + "project_owner_email": "owner@example.com" + } + ] + } + }, + { + "id": "standalone", + "source": "src/config/standalone.tfvars", + "sha256": "3fe6a764de8f1bb8eb83dbc07034d44d43373e277efdfccc6432c601319e0f03", + "values": { + "company_code": "exc", + "company_name": "Example Corp", + "labels": { + "managed_by": "opentofu" + }, + "landing_zones": { + "public-exmpl": { + "env": "prod", + "owner_email": "owner@example.com", + "project_code": "api", + "project_name": "External API Gateway" + } + }, + "organization_id": "00000000-0000-0000-0000-000000000000", + "owner_email": "owner@example.com", + "region": "eu01", + "sandboxes": [ + { + "project_name": "Sandbox Team Alpha", + "project_owner_email": "owner@example.com" + } + ] + } + } + ] +} diff --git a/landing-zone-configurator/app/apps/web/src/main.tsx b/landing-zone-configurator/app/apps/web/src/main.tsx index b6e29b0..f4ed7c1 100644 --- a/landing-zone-configurator/app/apps/web/src/main.tsx +++ b/landing-zone-configurator/app/apps/web/src/main.tsx @@ -1,41 +1,311 @@ -import { healthResponseSchema } from "@lzc/contracts"; -import { StrictMode, useEffect, useState } from "react"; +import { + type ConfigurationDraft, + createDraft, + objectValue, + type Template, +} from "@lzc/domain"; +import { StrictMode, useEffect, useRef, useState } from "react"; import { createRoot } from "react-dom/client"; +import { ConfigurationEditor } from "./components/ConfigurationEditor"; +import { Topology } from "./components/Topology"; +import { describe, standaloneTemplate, templates } from "./templates"; import "./style.css"; function App() { - const [status, setStatus] = useState("Verbindung wird geprüft …"); + const [view, setView] = useState<"templates" | "preview" | "editor">( + "templates", + ); + const [selected, setSelected] = useState