From c8eb5980bc3164c7dbe791a5004b3c2c67b0aa0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Weberru=C3=9F?= Date: Wed, 21 Oct 2026 06:52:32 +0000 Subject: [PATCH 1/2] fix(configurator): clean up immutable staging directories safely Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../app/scripts/check-staged-runner.mjs | 5 ++- .../app/scripts/remove-staging-work.mjs | 16 +++++++ .../deploy/cloud-foundry/README.de.md | 5 ++- .../deploy/cloud-foundry/README.md | 4 +- .../infra/ci/image-staging.test.mjs | 43 +++++++++++++++++++ 5 files changed, 69 insertions(+), 4 deletions(-) create mode 100644 landing-zone-configurator/app/scripts/remove-staging-work.mjs diff --git a/landing-zone-configurator/app/scripts/check-staged-runner.mjs b/landing-zone-configurator/app/scripts/check-staged-runner.mjs index dae0a9f7..601afe06 100644 --- a/landing-zone-configurator/app/scripts/check-staged-runner.mjs +++ b/landing-zone-configurator/app/scripts/check-staged-runner.mjs @@ -1,11 +1,12 @@ import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; import { constants } from "node:fs"; -import { copyFile, lstat, mkdir, mkdtemp, rm } from "node:fs/promises"; +import { copyFile, lstat, mkdir, mkdtemp } from "node:fs/promises"; import { resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { LocalPlanRunner } from "../apps/api/dist/plans/local.js"; import { platformFirewallImage } from "../packages/contracts/dist/index.js"; +import { removeStagingWork } from "./remove-staging-work.mjs"; const directory = resolve(process.argv[2]); const repository = fileURLToPath(new URL("../../../", import.meta.url)); @@ -90,5 +91,5 @@ try { "PASS: both thin CF archives below 384 MiB restore the exact image-qualified Runner identity during staging; runtime package/disk footprint unchanged.", ); } finally { - await rm(work, { recursive: true, force: true }); + await removeStagingWork(work); } diff --git a/landing-zone-configurator/app/scripts/remove-staging-work.mjs b/landing-zone-configurator/app/scripts/remove-staging-work.mjs new file mode 100644 index 00000000..150757c7 --- /dev/null +++ b/landing-zone-configurator/app/scripts/remove-staging-work.mjs @@ -0,0 +1,16 @@ +import { chmod, lstat, readdir, rm } from "node:fs/promises"; +import { join } from "node:path"; + +export async function removeStagingWork(directory) { + async function writableDirectories(path) { + const info = await lstat(path); + if (!info.isDirectory()) return; + await chmod(path, info.mode | 0o700); + for (const name of await readdir(path)) + await writableDirectories(join(path, name)); + } + // Provider mirrors stay immutable through qualification. Only the temporary + // staging tree needs writable directories for removal; never follow links. + await writableDirectories(directory); + await rm(directory, { recursive: true, force: true }); +} diff --git a/landing-zone-configurator/deploy/cloud-foundry/README.de.md b/landing-zone-configurator/deploy/cloud-foundry/README.de.md index 7e91b2d5..a6a82def 100644 --- a/landing-zone-configurator/deploy/cloud-foundry/README.de.md +++ b/landing-zone-configurator/deploy/cloud-foundry/README.de.md @@ -71,7 +71,10 @@ Hosted-Archive lassen ausschließlich das gepinnte `firewall-image.qcow2` aus; l Archive bleiben eigenständig vollständig. CI qualifiziert zunächst das vollständige imagegestützte Paket, extrahiert dann beide schlanken Archive, stellt das Image über den tatsächlichen Buildpack- Einstiegspunkt wieder her und prüft bytegenaue Runner-Gleichheit sowie dieselbe Paketidentität. -Jedes komprimierte CF-Archiv muss kleiner als 384 MiB sein. +Jedes komprimierte CF-Archiv muss kleiner als 384 MiB sein. Nach der Prüfung +macht die Bereinigung nur Verzeichnisse im temporären Staging-Baum beschreibbar +und folgt keinen Symlinks. Das ursprüngliche Runner-/Provider-Verzeichnis bleibt +unverändert. `prepare-release.mjs` bindet `LZC_IMAGE_BUILDPACK` an `lzc-image-buildpack-`, auch bei Promotion. diff --git a/landing-zone-configurator/deploy/cloud-foundry/README.md b/landing-zone-configurator/deploy/cloud-foundry/README.md index 5b09dcba..7aa73f31 100644 --- a/landing-zone-configurator/deploy/cloud-foundry/README.md +++ b/landing-zone-configurator/deploy/cloud-foundry/README.md @@ -63,7 +63,9 @@ Hosted archives omit only the pinned `firewall-image.qcow2`; local Runner archives remain self-contained. CI first qualifies the full image-backed package, then extracts both thin archives, restores the image through the actual buildpack entrypoint and verifies byte-for-byte Runner equality and the same package identity. -Each compressed CF archive must be smaller than 384 MiB. +Each compressed CF archive must be smaller than 384 MiB. After qualification, +cleanup makes only directories inside the temporary staging tree writable, +without following symlinks. The original Runner/provider mirror stays unchanged. `prepare-release.mjs` binds `LZC_IMAGE_BUILDPACK` to `lzc-image-buildpack-`, including during promotion. diff --git a/landing-zone-configurator/infra/ci/image-staging.test.mjs b/landing-zone-configurator/infra/ci/image-staging.test.mjs index 1bc894e4..03cc6b4c 100644 --- a/landing-zone-configurator/infra/ci/image-staging.test.mjs +++ b/landing-zone-configurator/infra/ci/image-staging.test.mjs @@ -17,6 +17,7 @@ import { resolve } from "node:path"; import test from "node:test"; import { fileURLToPath } from "node:url"; import { prepareFirewallImage } from "../../deploy/cloud-foundry/firewall-image.mjs"; +import { removeStagingWork } from "../../app/scripts/remove-staging-work.mjs"; import { buildpackTag, verifyBuildpackSource } from "./buildpack-source.mjs"; const repository = fileURLToPath(new URL("../../../", import.meta.url)); @@ -385,3 +386,45 @@ esac await rm(work, { recursive: true, force: true }); } }); + +test("staging cleanup removes immutable provider directories without following links into the original package", async () => { + const work = await mkdtemp(resolve(tmpdir(), "lzc-staging-cleanup-")); + const staged = resolve(work, "staging-check"); + const original = resolve(work, "original-providers"); + const mirror = resolve(staged, "runner/providers/registry.opentofu.org"); + try { + await mkdir(mirror, { recursive: true }); + await mkdir(original); + const provider = resolve(original, "verified-provider"); + await writeFile(provider, "immutable provider"); + await chmod(provider, 0o444); + await chmod(original, 0o555); + await writeFile(resolve(mirror, "staged-provider"), "immutable provider", { + mode: 0o444, + }); + await symlink(original, resolve(staged, "original-mirror")); + await chmod(mirror, 0o555); + await chmod(resolve(mirror, ".."), 0o555); + const before = await lstat(provider); + if (process.getuid?.() !== 0) + await assert.rejects(rm(mirror, { recursive: true, force: true }), { + code: "EACCES", + }); + await removeStagingWork(staged); + await assert.rejects(lstat(staged), { code: "ENOENT" }); + assert.equal((await lstat(original)).mode & 0o777, 0o555); + assert.equal((await lstat(provider)).mode, before.mode); + assert.equal((await lstat(provider)).ino, before.ino); + assert.equal(await readFile(provider, "utf8"), "immutable provider"); + const check = await readFile( + resolve( + repository, + "landing-zone-configurator/app/scripts/check-staged-runner.mjs", + ), + "utf8", + ); + assert.match(check, /finally \{\s*await removeStagingWork\(work\);\s*\}/); + } finally { + await removeStagingWork(work); + } +}); From 7145ba0fc7742c8c80b7e0dc51dcaefae1a0ce9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Weberru=C3=9F?= Date: Wed, 21 Oct 2026 06:53:37 +0000 Subject: [PATCH 2/2] test(configurator): run staging regressions before release Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/validate-configurator.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/validate-configurator.yml b/.github/workflows/validate-configurator.yml index 8c64804b..dbbcb1da 100644 --- a/.github/workflows/validate-configurator.yml +++ b/.github/workflows/validate-configurator.yml @@ -152,7 +152,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 infra/ci/buildpack-source.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 infra/ci/buildpack-source.test.mjs infra/ci/image-staging.test.mjs - name: Check formatting run: tofu -chdir=infra fmt -check -recursive - name: Prepare disposable validation encryption