diff --git a/.github/workflows/validate-configurator.yml b/.github/workflows/validate-configurator.yml index cbe96cc8..d3653d1d 100644 --- a/.github/workflows/validate-configurator.yml +++ b/.github/workflows/validate-configurator.yml @@ -109,6 +109,8 @@ jobs: tofu_wrapper: false - name: Check all template exports against native Accelerator variable contracts run: npm run test:common-contract + - name: Test native Plan, Application and governance contracts + run: LZC_TEST_TOFU_BIN="$(command -v tofu)" npm run test:plan - name: Test database migrations and tenant isolation run: npm run test:database -- --maxWorkers=2 --testTimeout=30000 env: diff --git a/landing-zone-configurator/app/apps/api/db/061_application_project_source.sql b/landing-zone-configurator/app/apps/api/db/061_application_project_source.sql new file mode 100644 index 00000000..05805790 --- /dev/null +++ b/landing-zone-configurator/app/apps/api/db/061_application_project_source.sql @@ -0,0 +1,22 @@ +-- New publications can use project-qualified buckets and Public/Corporate project plans. +-- Existing template versions, jobs, State keys and backend delegations remain unchanged. +ALTER TABLE lzc.application_job_grants DROP CONSTRAINT application_job_grants_accelerator_revision_check; +ALTER TABLE lzc.application_job_grants ADD CONSTRAINT application_job_grants_accelerator_revision_check + CHECK(accelerator_revision IN ('4d15d7870afa323badd93559d8b37c5a8d138dcf','c4b43c36af198985980b17626c48d357795e3fbd','88149782bf8e91dcdbb43a203b54337886023f7f','57ad1f6a651c1787694b74ff8aa8b241a3dcd16f','9e7fb46c329aca32e878ce1f0ea3b944ea240354')); +ALTER TABLE lzc.application_runner_tickets DROP CONSTRAINT application_runner_tickets_accelerator_revision_check; +ALTER TABLE lzc.application_runner_tickets ADD CONSTRAINT application_runner_tickets_accelerator_revision_check + CHECK(accelerator_revision IN ('c4b43c36af198985980b17626c48d357795e3fbd','88149782bf8e91dcdbb43a203b54337886023f7f','57ad1f6a651c1787694b74ff8aa8b241a3dcd16f','9e7fb46c329aca32e878ce1f0ea3b944ea240354')); + +DO $$ +DECLARE signature text; definition text; old_source text := '''57ad1f6a651c1787694b74ff8aa8b241a3dcd16f'''; +BEGIN + FOREACH signature IN ARRAY ARRAY[ + 'lzc.issue_application_job_grant()', + 'lzc_auth.reserve_application_dispatch(uuid,uuid)', + 'lzc_auth.delegated_application_plan_operation(uuid,uuid,text,jsonb)' + ] LOOP + definition := pg_get_functiondef(signature::regprocedure); + IF strpos(definition,old_source)=0 THEN RAISE EXCEPTION 'application_source_guard_missing'; END IF; + EXECUTE replace(definition,old_source,old_source || ',''9e7fb46c329aca32e878ce1f0ea3b944ea240354'''); + END LOOP; +END $$; diff --git a/landing-zone-configurator/app/apps/api/src/applications/service.ts b/landing-zone-configurator/app/apps/api/src/applications/service.ts index 684aff26..4f388992 100644 --- a/landing-zone-configurator/app/apps/api/src/applications/service.ts +++ b/landing-zone-configurator/app/apps/api/src/applications/service.ts @@ -21,6 +21,7 @@ import { import { applicationAcceleratorRevisionSchema, applicationOrderSchema, + applicationPlanScopeIssues, applicationPublicationSchema, assertBoundedJson, compileApplicationPlan, @@ -60,6 +61,7 @@ export const applicationRunnerPackageSchema = z.strictObject({ "c4b43c36af198985980b17626c48d357795e3fbd", "88149782bf8e91dcdbb43a203b54337886023f7f", "57ad1f6a651c1787694b74ff8aa8b241a3dcd16f", + "9e7fb46c329aca32e878ce1f0ea3b944ea240354", ]), providerLockSha256: z.literal( "d40debbff204aee590c2a76d09f6ad3234643329b438fd5c6497de60687f6fa5", @@ -1628,6 +1630,7 @@ export class Applications { "c4b43c36af198985980b17626c48d357795e3fbd", "88149782bf8e91dcdbb43a203b54337886023f7f", "57ad1f6a651c1787694b74ff8aa8b241a3dcd16f", + "9e7fb46c329aca32e878ce1f0ea3b944ea240354", ].includes(prepared.plan.acceleratorRevision) ) throw new ApplicationError(409, "application_runner_revision_required"); @@ -2419,6 +2422,7 @@ export class Applications { "c4b43c36af198985980b17626c48d357795e3fbd", "88149782bf8e91dcdbb43a203b54337886023f7f", "57ad1f6a651c1787694b74ff8aa8b241a3dcd16f", + "9e7fb46c329aca32e878ce1f0ea3b944ea240354", ]).parse(acceleratorRevision); if (!this.profiles || !this.secrets) throw new ApplicationError( @@ -2571,9 +2575,11 @@ export class Applications { const settings = resolved.resolution.settings; const observability = objectValue(settings.observability); if ( - template.kind !== "public" || - settings.network_enabled !== true || - Object.keys(template.namespaceServices ?? {}).length + applicationPlanScopeIssues( + template, + settings, + published.accelerator_revision, + ).length ) throw new ApplicationError(409, "application_plan_scope_not_supported"); const plan = compileApplicationPlan({ diff --git a/landing-zone-configurator/app/apps/web/src/components/Applications.tsx b/landing-zone-configurator/app/apps/web/src/components/Applications.tsx index ec929500..53781cfb 100644 --- a/landing-zone-configurator/app/apps/web/src/components/Applications.tsx +++ b/landing-zone-configurator/app/apps/web/src/components/Applications.tsx @@ -10,6 +10,7 @@ import { appliedPlatformSourceSchema, } from "@lzc/contracts"; import { + applicationPlanScopeIssues, type CommonConfiguration, type JsonValue, objectValue, @@ -149,7 +150,7 @@ const errors: Record = { application_parameters_not_qualified: "Die Bestellparameter sind noch nicht vollständig qualifiziert.", application_plan_scope_not_supported: - "Der MVP-Plan unterstützt Public-Projekte mit lokalem Netz, optionaler Observability und ohne Namespace-Dienste.", + "Diese Template-Version enthält noch nicht unterstützte Dienste oder verwendet eine ältere Runner-Revision. Die Hinweise im Katalog zeigen die fehlenden Voraussetzungen.", application_instance_not_found: "Diese eigene Bestellung ist in diesem Arbeitsbereich nicht verfügbar.", }; @@ -2092,7 +2093,7 @@ export function Applications({ setActiveTab("catalogue"); setNotice( formatMessage( - "{{value0}} · Version {{value1}} veröffentlicht. Cloud-Ausführung bleibt gesperrt.", + "{{value0}} · Version {{value1}} veröffentlicht. Es wurde kein Plan oder Apply gestartet.", { value0: published.template.name, value1: published.version }, ), ); @@ -2289,7 +2290,12 @@ export function Applications({ if ( instance.deploymentPolicy === "direct" && planEnabled && - instance.executionConfigured + instance.executionConfigured && + applicationPlanScopeIssues( + selected.template, + instance.settings, + selected.acceleratorRevision, + ).length === 0 ) { await request(`instances/${instance.id}/plan`, session, { idempotencyKey, @@ -2574,6 +2580,11 @@ export function Applications({ {loaded && !appliedPlatforms.length && ( <> +

+ {t( + "Die Auswahl benötigt einen aktuellen persönlichen STACKIT-Organisationsnachweis sowie einen abgeschlossenen Apply auf dem aktuellen, entsperrten State. Ein technischer Service-Account ersetzt den persönlichen Nachweis nicht.", + )} +

{t( "Kein aktueller erfolgreicher Plattform-Apply verfügbar.", @@ -2676,6 +2687,28 @@ export function Applications({ hidden={selectedTab !== "publish"} >

{t("Application Landing Zone Template veröffentlichen")}

+

+ {t( + "Templates stammen aus der geöffneten Konfiguration. Plattformvertrag und State-Backend werden unabhängig davon geladen.", + )} +

+ + {publicationTemplate && + applicationPlanScopeIssues( + publicationTemplate, + publicationTemplate.settings, + runnerRevision ?? "", + ).map((issue) => ( +

+ {t(issue)} +

+ ))} {delegationEnabled && (
{t("Plattform-Ausführung")} @@ -2927,20 +2960,11 @@ export function Applications({ )} ) : ( - <> -

- {t( - "Kein Application Landing Zone Template im aktuellen Entwurf.", - )} -

- - +

+ {t( + "Kein Application Landing Zone Template im aktuellen Entwurf.", + )} +

)} )} @@ -3159,6 +3183,15 @@ export function Applications({ : t("STACKIT-Standard")} + {applicationPlanScopeIssues( + selected.template, + selected.template.settings, + selected.acceleratorRevision, + ).map((issue) => ( +

+ {t(issue)} +

+ ))}
diff --git a/landing-zone-configurator/app/apps/web/src/components/Configurations.tsx b/landing-zone-configurator/app/apps/web/src/components/Configurations.tsx index 1f74f4ed..9ebe8801 100644 --- a/landing-zone-configurator/app/apps/web/src/components/Configurations.tsx +++ b/landing-zone-configurator/app/apps/web/src/components/Configurations.tsx @@ -1,5 +1,6 @@ import { type EditorDraft, + editorIssues, initialPlanIssues, readEditorDraft, recordOrganization, @@ -20,10 +21,15 @@ export function configurationDeploymentSelection( "id" | "revision" | "draft" | "name" >, ): DeploymentSelection { - const record = saveEditorDraft( - configuration.id, - readEditorDraft(configuration.draft), - ); + const draft = readEditorDraft(configuration.draft); + const validationIssues = editorIssues(draft); + if (validationIssues.length) + throw new Error( + validationIssues + .map((issue) => `${issue.field}: ${issue.message}`) + .join(" "), + ); + const record = saveEditorDraft(configuration.id, draft); const issues = initialPlanIssues(record); if (issues.length) throw new Error(issues.map((issue) => issue.message).join(" ")); diff --git a/landing-zone-configurator/app/apps/web/src/locales/en.ts b/landing-zone-configurator/app/apps/web/src/locales/en.ts index e854b372..c4929cd3 100644 --- a/landing-zone-configurator/app/apps/web/src/locales/en.ts +++ b/landing-zone-configurator/app/apps/web/src/locales/en.ts @@ -1,4 +1,18 @@ export const englishMessages: Record = { + "{{value0}} · Version {{value1}} veröffentlicht. Es wurde kein Plan oder Apply gestartet.": + "{{value0}} · Version {{value1}} published. No Plan or Apply was started.", + "Templates stammen aus der geöffneten Konfiguration. Plattformvertrag und State-Backend werden unabhängig davon geladen.": + "Templates come from the open configuration. The platform contract and state backend are loaded separately.", + "Die Auswahl benötigt einen aktuellen persönlichen STACKIT-Organisationsnachweis sowie einen abgeschlossenen Apply auf dem aktuellen, entsperrten State. Ein technischer Service-Account ersetzt den persönlichen Nachweis nicht.": + "This list requires current personal STACKIT organization proof and a completed Apply matching the current unlocked state. A technical service account does not replace personal proof.", + "Diese Template-Version enthält noch nicht unterstützte Dienste oder verwendet eine ältere Runner-Revision. Die Hinweise im Katalog zeigen die fehlenden Voraussetzungen.": + "This template version contains unsupported services or uses an older Runner revision. The catalogue explains the missing prerequisites.", + "Sandbox-Bestellungen sind noch nicht ausführbar.": + "Sandbox orders are not executable yet.", + "Namespace-Dienste benötigen einen gesonderten Kubernetes-Ausführungspfad und sind noch nicht bestellbar.": + "Namespace services require a separate Kubernetes execution path and cannot be provisioned through this order yet.", + "Diese ältere Template-Version unterstützt nur Public-Projekte mit lokalem Netz. Für Corporate-Projekte oder Public-Projekte ohne Netz eine neue Version mit der aktuellen Runner-Revision veröffentlichen.": + "This older template version supports only Public projects with a local network. Publish a new version with the current Runner revision for Corporate projects or Public projects without a network.", "Der gewählte Observability-Tarif und seine Einstellungen passen nicht zusammen oder konnten nicht verifiziert werden. Prüfe Tarif und Aufbewahrung im Editor. Gespeicherte Werte bleiben erhalten.": "The selected Observability plan and its settings are incompatible or could not be verified. Check the plan and retention in the editor. Saved values are preserved.", "Der Observability-Vorschlag passt nicht zu den geprüften Tariffähigkeiten. Tarif und Aufbewahrung prüfen; vorhandene Werte bleiben erhalten.": diff --git a/landing-zone-configurator/app/e2e/organisation.spec.ts b/landing-zone-configurator/app/e2e/organisation.spec.ts index 140afcd6..bf3a6f66 100644 --- a/landing-zone-configurator/app/e2e/organisation.spec.ts +++ b/landing-zone-configurator/app/e2e/organisation.spec.ts @@ -2482,6 +2482,11 @@ for (const { publishing, applied, renewProof, executing } of [ .getByRole("tab", { name: "Plattformanbindung", exact: true }) .click(); if (renewProof) { + await expect( + page.getByText( + /Die Auswahl benötigt einen aktuellen persönlichen STACKIT-Organisationsnachweis/, + ), + ).toBeVisible(); await expect( page .getByLabel("Erfolgreicher Plattform-Apply", { exact: true }) @@ -2568,6 +2573,12 @@ for (const { publishing, applied, renewProof, executing } of [ await expect( page.getByLabel("Plattformziel", { exact: true }), ).toBeDisabled(); + await expect( + page.getByRole("button", { + name: "Konfiguration auswählen", + exact: true, + }), + ).toBeVisible(); await page .getByLabel("Application Landing Zone Template aus meinem Entwurf", { exact: true, diff --git a/landing-zone-configurator/app/integration/fixtures/governance/governance.tftest.hcl b/landing-zone-configurator/app/integration/fixtures/governance/governance.tftest.hcl index eb3f01f5..583208cb 100644 --- a/landing-zone-configurator/app/integration/fixtures/governance/governance.tftest.hcl +++ b/landing-zone-configurator/app/integration/fixtures/governance/governance.tftest.hcl @@ -72,3 +72,13 @@ run "empty_description" { error_message = "Empty description must remain harmless; populated descriptions remain blocked by the Configurator (#82)." } } + +run "non_empty_description_rejected" { + command = plan + variables { + rm_folders = { + platform = { name = "Platform", description = "Must not be silently discarded" } + } + } + expect_failures = [var.rm_folders] +} diff --git a/landing-zone-configurator/app/integration/fixtures/governance/source.json b/landing-zone-configurator/app/integration/fixtures/governance/source.json index 09d10939..ea4c784c 100644 --- a/landing-zone-configurator/app/integration/fixtures/governance/source.json +++ b/landing-zone-configurator/app/integration/fixtures/governance/source.json @@ -1,14 +1,14 @@ { - "acceleratorRevision": "c4b43c36af198985980b17626c48d357795e3fbd", + "acceleratorRevision": "9e7fb46c329aca32e878ce1f0ea3b944ea240354", "files": { - "src/main.tf": "8592367381556c5eb6ae376d4bfe58ff4afe8bb7c37e1b8d8052d7678834d4d9", - "src/variables.tf": "b77e59054c7d2c6dabcb9f9add22c1efb56d79735fd14eb5461e11d99c105f57", + "src/main.tf": "0b2f27c869b6afe2be5614fe8895966739f874f18d1fd8905224735703b249c6", + "src/variables.tf": "c650b337ee9e154b988927c9a129091b6c6627ff90e9d1661bb9c8ba7815bda5", "src/locals.tf": "7c4f7c2887755b8581b31982ada8d63c2c0796c963994e61da95c270db1c8d78", "src/modules/governance/1-rm-folders.tf": "f1ff318487c780914620a3b516f55b2f17f13af4dd38364937d9567c93cd865b", "src/modules/governance/2-custom-roles.tf": "5cf14fc37fb6ad76d0914d38177c0b9bf7600606f2110827f85fded466a1c3d0", "src/modules/governance/3-organization-roles.tf": "209a6456b0d627516c36c8cfcfe0cf8444840c8b62ac01d352cef8f0ba4391da", "src/modules/governance/outputs.tf": "f16f31960dd81166cc1372ef9f2e6cb9f58044d7772785d8a31193e83b1f5b85", "src/modules/governance/terraform.tf": "6407250ce406a1fe3085c8ca64e392f6116c2b13de96a468de031569e5a56220", - "src/modules/governance/variables.tf": "9dd60d5196c884c6957e21cf6e8c6d00e6b8e48a0d8c48abc0abbc428018b736" + "src/modules/governance/variables.tf": "ddd29245d3bd76cf621b2e89663c0571b61313781404b78bb89afc8e57565b23" } } diff --git a/landing-zone-configurator/app/integration/identity.test.ts b/landing-zone-configurator/app/integration/identity.test.ts index 38966ba3..6f9aa3ad 100644 --- a/landing-zone-configurator/app/integration/identity.test.ts +++ b/landing-zone-configurator/app/integration/identity.test.ts @@ -3894,7 +3894,7 @@ describe("real PostgreSQL session and tenant boundaries", () => { expect( await applications.prepareJob(owner, jobOrder.id, jobRequest), ).toEqual(job); - const cliRevision = "57ad1f6a651c1787694b74ff8aa8b241a3dcd16f"; + const cliRevision = "9e7fb46c329aca32e878ce1f0ea3b944ea240354"; const cliVersion = await applications.publish(engineer, { template, ...binding, @@ -3925,6 +3925,106 @@ describe("real PostgreSQL session and tenant boundaries", () => { cliOrder.id, cliJobRequest, ); + // The current immutable source supports networkless Public and routed Corporate + // instances; neither must inherit the platform State or another order's key. + const corporateTarget = { + folder_id: randomUUID(), + region: "eu01", + corporate: true, + network_area_id: randomUUID(), + firewall_next_hop_ip: "10.0.2.6", + ipv4_nameservers: ["192.0.2.53"], + }; + const corporateContract = await applications.approvePlatformContract( + engineer, + { + ...input, + targets: { corporate: corporateTarget }, + }, + ); + for (const kind of ["public", "corporate"] as const) { + const currentVersion = await applications.publish(engineer, { + template: { + ...template, + id: randomUUID(), + key: `current-${kind}`, + kind, + settings: { ...template.settings, network_enabled: false }, + }, + acceleratorRevision: cliRevision, + platformRevision: + kind === "corporate" + ? corporateContract.document.revision + : approved.document.revision, + targetKey: kind, + deploymentPolicy: "direct", + }); + const currentOrder = await applications.order(owner, { + versionId: currentVersion.id, + idempotencyKey: randomUUID(), + name: `Current ${kind}`, + parameters: {}, + }); + const preparedInput = await applications.preparePlanInput( + owner, + currentOrder.id, + ); + expect(preparedInput.plan.variables.application).toMatchObject({ + network_enabled: false, + }); + expect(preparedInput.plan.stateKey).toBe( + `applications/${tenantId}/${currentOrder.id}/terraform.tfstate`, + ); + expect(preparedInput.plan.stateKey).not.toBe(cliOrder.stateKey); + expect( + preparedInput.plan.variables.platform_contract.targets[kind], + ).toEqual( + kind === "corporate" ? corporateTarget : input.targets.public, + ); + const currentJob = await applications.prepareJob( + owner, + currentOrder.id, + { + idempotencyKey: randomUUID(), + confirmPlan: true, + }, + ); + expect( + ( + await migration.query( + "SELECT accelerator_revision,state_key FROM lzc.application_job_grants WHERE job_id=$1", + [currentJob.id], + ) + ).rows[0], + ).toEqual({ + accelerator_revision: cliRevision, + state_key: currentOrder.stateKey, + }); + await expect( + applications.preparePlanInput(engineer, currentOrder.id), + ).rejects.toMatchObject({ status: 404 }); + } + const legacyCorporate = await applications.publish(engineer, { + template: { + ...template, + id: randomUUID(), + key: "legacy-corporate", + kind: "corporate", + }, + acceleratorRevision: "57ad1f6a651c1787694b74ff8aa8b241a3dcd16f", + platformRevision: corporateContract.document.revision, + targetKey: "corporate", + deploymentPolicy: "direct", + }); + const legacyCorporateOrder = await applications.order(owner, { + versionId: legacyCorporate.id, + idempotencyKey: randomUUID(), + name: "Legacy corporate", + parameters: {}, + }); + await expect( + applications.preparePlanInput(owner, legacyCorporateOrder.id), + ).rejects.toMatchObject({ code: "application_plan_scope_not_supported" }); const plannedObservability = ( await withTenant(pool, owner, (client) => client.query( @@ -5604,6 +5704,7 @@ describe("real PostgreSQL session and tenant boundaries", () => { ).json(), ).toMatchObject({ contracts: [ + { document: { revision: corporateContract.document.revision } }, { document: { revision: next.document.revision } }, { document: { revision: approved.document.revision } }, ], diff --git a/landing-zone-configurator/app/packages/contracts/src/plan.ts b/landing-zone-configurator/app/packages/contracts/src/plan.ts index f2192d83..f845e607 100644 --- a/landing-zone-configurator/app/packages/contracts/src/plan.ts +++ b/landing-zone-configurator/app/packages/contracts/src/plan.ts @@ -1,11 +1,12 @@ import { z } from "zod"; export const applicationMvpAcceleratorCommit = - "57ad1f6a651c1787694b74ff8aa8b241a3dcd16f"; + "9e7fb46c329aca32e878ce1f0ea3b944ea240354"; export const applicationRunnerSourceSchema = z.strictObject({ schemaVersion: z.literal(1), acceleratorCommit: z.enum([ "88149782bf8e91dcdbb43a203b54337886023f7f", + "57ad1f6a651c1787694b74ff8aa8b241a3dcd16f", applicationMvpAcceleratorCommit, ]), maintenanceEnabled: z.literal(true).optional(), diff --git a/landing-zone-configurator/app/packages/domain/src/application-catalogue.ts b/landing-zone-configurator/app/packages/domain/src/application-catalogue.ts index 5fff05af..601a6818 100644 --- a/landing-zone-configurator/app/packages/domain/src/application-catalogue.ts +++ b/landing-zone-configurator/app/packages/domain/src/application-catalogue.ts @@ -12,6 +12,7 @@ export const applicationAcceleratorRevisionSchema = z.enum([ "c4b43c36af198985980b17626c48d357795e3fbd", "88149782bf8e91dcdbb43a203b54337886023f7f", "57ad1f6a651c1787694b74ff8aa8b241a3dcd16f", + "9e7fb46c329aca32e878ce1f0ea3b944ea240354", ]); export const applicationPublicationSchema = z @@ -104,3 +105,29 @@ export function resolveApplicationOrder( ); return { order, resolution }; } + +/** Execution support is bound to the published source, never inferred from editor coverage. */ +export function applicationPlanScopeIssues( + template: Pick< + PublishedProjectTemplate["template"], + "kind" | "namespaceServices" + >, + settings: Record, + acceleratorRevision: string, +): string[] { + const issues: string[] = []; + if (template.kind === "sandbox") + issues.push("Sandbox-Bestellungen sind noch nicht ausführbar."); + if (Object.keys(template.namespaceServices ?? {}).length) + issues.push( + "Namespace-Dienste benötigen einen gesonderten Kubernetes-Ausführungspfad und sind noch nicht bestellbar.", + ); + if ( + acceleratorRevision !== "9e7fb46c329aca32e878ce1f0ea3b944ea240354" && + (template.kind !== "public" || settings.network_enabled !== true) + ) + issues.push( + "Diese ältere Template-Version unterstützt nur Public-Projekte mit lokalem Netz. Für Corporate-Projekte oder Public-Projekte ohne Netz eine neue Version mit der aktuellen Runner-Revision veröffentlichen.", + ); + return issues; +} diff --git a/landing-zone-configurator/app/scripts/prepare-application-project-contract.ts b/landing-zone-configurator/app/scripts/prepare-application-project-contract.ts new file mode 100644 index 00000000..272fd39c --- /dev/null +++ b/landing-zone-configurator/app/scripts/prepare-application-project-contract.ts @@ -0,0 +1,151 @@ +// Compile actual order inputs against the immutable Application Runner source. +import { execFileSync } from "node:child_process"; +import { copyFileSync, mkdirSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { applicationMvpAcceleratorCommit } from "../packages/contracts/dist/index.js"; +import { compileApplicationPlan } from "../packages/domain/dist/index.js"; + +const target = process.argv[2]; +if (!target?.startsWith("/")) + throw new Error("Absolute fixture directory required"); +if ( + applicationMvpAcceleratorCommit !== "9e7fb46c329aca32e878ce1f0ea3b944ea240354" +) + throw new Error( + "Requalify Application project scope for the new Runner source", + ); +mkdirSync(resolve(target, "src"), { recursive: true }); +const archive = execFileSync("git", [ + "-C", + fileURLToPath(new URL("../../../", import.meta.url)), + "archive", + applicationMvpAcceleratorCommit, + "src/application", + "src/modules/landing-zone", +]); +execFileSync("tar", ["-x", "-C", target], { input: archive }); +const root = resolve(target, "src/application"); +copyFileSync( + new URL("../../deploy/runner/application.lock.hcl", import.meta.url), + resolve(root, ".terraform.lock.hcl"), +); +const tests = [ + `mock_provider "stackit" { + mock_resource "stackit_resourcemanager_project" { + defaults = { project_id = "44444444-2222-4333-8444-555555555555", container_id = "55555555-2222-4333-8444-555555555555" } + } + mock_resource "stackit_objectstorage_credentials_group" { + defaults = { credentials_group_id = "66666666-2222-4333-8444-555555555555" } + } + mock_resource "stackit_service_account" { + defaults = { email = "automation@sa.stackit.cloud" } + } + mock_resource "stackit_routing_table" { + defaults = { routing_table_id = "77777777-2222-4333-8444-555555555555" } + } +} +mock_provider "time" {} +`, +]; +const id = "11111111-2222-4333-8444-555555555555"; +for (const region of ["eu01", "eu02"]) { + for (const kind of ["public-none", "public-local", "corporate"]) { + const corporate = kind === "corporate"; + for (const observability of [false, true]) { + const name = `${region}_${kind.replaceAll("-", "_")}_${observability ? "observability" : "base"}`; + const compiled = compileApplicationPlan({ + platform: { + schema_version: 1, + tenant_id: id, + revision: id, + organization_id: id, + targets: { + selected: { + folder_id: "platform-folder", + region, + corporate, + network_area_id: corporate ? id : null, + firewall_next_hop_ip: corporate ? "10.0.2.6" : null, + ipv4_nameservers: corporate ? ["192.0.2.53"] : null, + }, + }, + }, + template: { + schema_version: 2, + tenant_id: id, + id: "project-template", + version: 1, + status: "published", + accelerator_revision: applicationMvpAcceleratorCommit, + platform_revision: id, + target_keys: ["selected"], + apply_policy: "approval-required", + env: "dev", + network_enabled: kind === "public-local", + network_prefix_length: 24, + parameter_policy: { schema_version: 1, fields: {} }, + custom_roles: [], + role_assignments: [], + services: { + secretsmanager_enabled: true, + observability: { + enabled: observability, + plan_name: `Observability-Starter-${region.toUpperCase()}`, + acl: ["203.0.113.8/32"], + }, + }, + }, + context: { + tenant_id: id, + user_id: id, + instance_id: id, + role: "application-owner", + verified_stackit_email: "owner@stackit.cloud", + stackit_organization_id: id, + allowed_accelerator_revision: applicationMvpAcceleratorCommit, + }, + request: { + name: "Order contract fixture", + target_key: "selected", + parameters: {}, + }, + }); + writeFileSync( + resolve(root, `${name}.json`), + JSON.stringify(compiled.variables), + ); + const network = + kind === "public-none" + ? "module.application.project_network == null" + : `module.application.project_network != null && module.application.project_network.routed == ${corporate} && module.application.project_network.ipv4_prefix_length == 24`; + tests.push(`run "compiled_${name}" { + command = plan + variables { + platform_contract = jsondecode(file("${name}.json")).platform_contract + application = jsondecode(file("${name}.json")).application + } + assert { + condition = ${network} + error_message = "Compiled order must preserve Public/Corporate network semantics." + } + assert { + condition = local.target.region == "${region}" && local.target.folder_id == "platform-folder" && local.target.corporate == ${corporate} && var.application.owner_email == "owner@stackit.cloud" && var.bucket_naming == "project-id" + error_message = "Compiled owner, platform target, region and unique bucket default must be preserved." + } + assert { + condition = var.application.observability.enabled == ${observability} && var.application.observability.plan_name == "Observability-Starter-${region.toUpperCase()}" && var.application.observability.acl[0] == "203.0.113.8/32" + error_message = "Observability configuration and its explicit ACL must survive compilation." + } + assert { + condition = module.application.secretsmanager_instance_id != null && (module.application.observability_instance_id != null) == ${observability} + error_message = "Compiled optional services must reach the real project module." + } +}`); + } + } +} +writeFileSync( + resolve(root, "tests/compiled-projects.tftest.hcl"), + tests.join("\n\n"), +); diff --git a/landing-zone-configurator/app/scripts/test-application-contract.sh b/landing-zone-configurator/app/scripts/test-application-contract.sh index 0e713eed..f2a5566a 100644 --- a/landing-zone-configurator/app/scripts/test-application-contract.sh +++ b/landing-zone-configurator/app/scripts/test-application-contract.sh @@ -26,3 +26,15 @@ env -i PATH="$PATH" TF_CLI_CONFIG_FILE=/dev/null TF_IN_AUTOMATION=true TF_INPUT= "$tofu_bin" -chdir="$work_dir/src/modules/landing-zone" init -backend=false -input=false -no-color env -i PATH="$PATH" TF_CLI_CONFIG_FILE=/dev/null TF_IN_AUTOMATION=true TF_INPUT=0 \ "$tofu_bin" -chdir="$work_dir/src/modules/landing-zone" test -no-color + +# Exercise the exact source shipped in the Application Runner with compiler-produced +# Public/Corporate orders, both regions, optional networking and Observability. +application_commit="$(node --input-type=module -e 'import {applicationMvpAcceleratorCommit} from "./packages/contracts/dist/index.js"; process.stdout.write(applicationMvpAcceleratorCommit);')" +if ! git -C "$repo_dir" cat-file -e "$application_commit^{commit}" 2>/dev/null; then + git -C "$repo_dir" fetch --no-tags https://github.com/stackitcloud/stackit-landing-zone.git "$application_commit" +fi +node "$app_dir/scripts/prepare-application-project-contract.ts" "$work_dir/pinned" +env -i PATH="$PATH" TF_CLI_CONFIG_FILE=/dev/null TF_IN_AUTOMATION=true TF_INPUT=0 \ + "$tofu_bin" -chdir="$work_dir/pinned/src/application" init -backend=false -lockfile=readonly -input=false -no-color +env -i PATH="$PATH" TF_CLI_CONFIG_FILE=/dev/null TF_IN_AUTOMATION=true TF_INPUT=0 \ + "$tofu_bin" -chdir="$work_dir/pinned/src/application" test -no-color diff --git a/landing-zone-configurator/app/tests/application-plan.test.ts b/landing-zone-configurator/app/tests/application-plan.test.ts index 1746c479..e09c9c27 100644 --- a/landing-zone-configurator/app/tests/application-plan.test.ts +++ b/landing-zone-configurator/app/tests/application-plan.test.ts @@ -1,5 +1,7 @@ +import { applicationMvpAcceleratorCommit } from "@lzc/contracts"; import { expect, it } from "vitest"; import { + applicationPlanScopeIssues, resolveApplicationOrder, validateApplicationPublication, } from "../packages/domain/src/application-catalogue.js"; @@ -424,3 +426,45 @@ it.each([true, false])( expect(plan.executionEnabled).toBe(false); }, ); + +it.each(["public", "corporate"] as const)( + "allows current %s project plans with optional local networking", + (kind) => { + for (const network_enabled of [true, false]) { + expect( + applicationPlanScopeIssues( + { kind }, + { network_enabled }, + applicationMvpAcceleratorCommit, + ), + ).toEqual([]); + } + }, +); +it("retains the previous source scope and rejects namespaces rather than silently dropping them", () => { + const legacy = "57ad1f6a651c1787694b74ff8aa8b241a3dcd16f"; + expect( + applicationPlanScopeIssues( + { kind: "public" }, + { network_enabled: true }, + legacy, + ), + ).toEqual([]); + expect( + applicationPlanScopeIssues( + { kind: "public" }, + { network_enabled: false }, + legacy, + ), + ).toHaveLength(1); + expect( + applicationPlanScopeIssues({ kind: "corporate" }, {}, legacy), + ).toHaveLength(1); + expect( + applicationPlanScopeIssues( + { kind: "public", namespaceServices: { example: {} } }, + { network_enabled: true }, + applicationMvpAcceleratorCommit, + )[0], + ).toContain("Namespace-Dienste"); +}); diff --git a/landing-zone-configurator/deploy/runner/package.sh b/landing-zone-configurator/deploy/runner/package.sh index 8b6b1407..9ade77c9 100644 --- a/landing-zone-configurator/deploy/runner/package.sh +++ b/landing-zone-configurator/deploy/runner/package.sh @@ -62,7 +62,7 @@ test ! -e "$runner_dir/accelerator/terraform.auto.tfvars" test -z "$(find "$runner_dir/accelerator" -type f \( -name '*.tfstate' -o -name '*.tfstate.*' -o -name '.terraform.tfstate.lock.info' -o -name '*.tfplan' -o -name 'plan.bin' -o -name 'saved-plan.bin' -o -name 'credential.json' -o -name '*.log' \) -print -quit)" if [[ "${LZC_PACKAGE_APPLICATION_ROOT:-false}" == "true" ]]; then mkdir -p "$runner_dir/application-src" - application_commit=57ad1f6a651c1787694b74ff8aa8b241a3dcd16f + application_commit=9e7fb46c329aca32e878ce1f0ea3b944ea240354 if [[ "${LZC_HOSTED_EXECUTION_PACKAGE:-false}" == "true" ]]; then git fetch --no-tags https://github.com/stackitcloud/stackit-landing-zone.git "$application_commit" fi diff --git a/landing-zone-configurator/docs/assistant-feature-coverage.json b/landing-zone-configurator/docs/assistant-feature-coverage.json index db79a10a..8f015e69 100644 --- a/landing-zone-configurator/docs/assistant-feature-coverage.json +++ b/landing-zone-configurator/docs/assistant-feature-coverage.json @@ -12,7 +12,7 @@ "packages/domain/src/observability-plans.ts": "04b12f2744c453ff44f7b386ba6d6b7052b53f66fc483d46e9d5af0f5f75bbe5", "packages/domain/src/common-document.ts": "64e00aa276f0186b9d4fa70ae88ec13703766a317f1317cbfd6bab790673cb40", "packages/domain/src/editor-document.ts": "b807d40f04b7739cab026d43a412ed9252d22cb9bd1034fbad858825308c1345", - "packages/domain/src/application-catalogue.ts": "2e1dfe53840775f5acd6212b783d7823673c71620c868abf919dae7e3a0b798a", + "packages/domain/src/application-catalogue.ts": "6060f1877cae659c19a946ee064964b91e672f4278e454bb903c6ab8d75ccec9", "packages/domain/src/application-plan.ts": "748832e7c2284bba61b11f194164c8f513225010d18571f283ee7b72723bf302" }, "groups": [ diff --git a/landing-zone-configurator/docs/plan-execution.de.md b/landing-zone-configurator/docs/plan-execution.de.md index 8899ac8c..97f85444 100644 --- a/landing-zone-configurator/docs/plan-execution.de.md +++ b/landing-zone-configurator/docs/plan-execution.de.md @@ -74,10 +74,10 @@ behalten ihre bisherigen Projekte. [Details und Bestandsschutz](project-template ## Application-Plan-Input und Plattformvertrag -Der lokal aktivierte Application-Katalog hat einen eigenen Freigabe- und Bestellpfad. -Er startet **noch keinen CF-Task** und legt keinen State an. `Plan-Input prüfen` -ist eine erneute serverseitige Qualifikation und zeigt Terraform-Variablen, -Entrypoint und Ausführungssperre, kein berechnetes Ressourcenergebnis. +Der Application-Katalog besitzt einen eigenen Freigabe- und Bestellpfad. +`Plan-Input prüfen` startet keinen CF-Task und legt keinen State an; es zeigt +serverseitig geprüfte Terraform-Variablen und den Entrypoint. Ein tatsächlicher +Cloud-Plan wird über den separat aktivierten Application-Runner ausgeführt. Ein Platform Engineer kann im Application-Katalog einen JSON-Vertrag aus bereits angewendeten, geprüften Plattform-Outputs laden. Das Dokument ist kein Terraform-State @@ -121,20 +121,26 @@ Diese Ressourcenprüfung bleibt vor einer zukünftigen Job-/Credential-Freigabe Client-Identitäts-/Backend-Overrides werden abgewiesen. Identitätsablauf, Widerruf, Rollenentzug und geänderte Besteller-E-Mail werden erneut geprüft. - Der Compiler verwendet exakt die Vertrags- und Template-Version der Bestellung, - feste Parameterregeln und den eigenen Instanz-State-Key. Die MVP-Teilmenge ist - Public mit lokalem Netz, ohne Observability-Ausführung oder Namespace-Dienste. + feste Parameterregeln und den eigenen Instanz-State-Key. Der aktuelle geprüfte + Application-Quellstand unterstützt Public-Projekte mit oder ohne lokales Netz + und Corporate-Projekte am ausgewählten Plattformziel, optional mit Secrets Manager + und Observability. Namespace-Dienste bleiben bis zum eigenen Kubernetes-Ausführungspfad + gesperrt. Ältere unveränderliche Quellversionen behalten die frühere Einschränkung + auf Public-Projekte mit lokalem Netz. Die Antwort meldet ausdrücklich `cloudPlanExecuted: false` und `executionEnabled: false`; keine Schlüssel werden an den Browser geliefert. Die unveraenderliche Versions-/Bestellpolicy wird als `applyPolicy` uebernommen (`approval-required` oder `direct`); `requiresExplicitApplyApproval: true` bleibt fuer beide gesetzt. Eine direkte Policy ist keine Ausfuehrungsfreigabe. -Für den tatsächlichen eigenen Cloud-Plan fehlen noch ein unveränderlich freigegebenes -Application-Runner-Artefakt, getrenntes State-Backend mit Locking und eng begrenzte -serverseitige Nutzung des PE-Zugangs durch genau den autorisierten Job. Der bisherige -Runner-Pin enthält kein Application-Root. Er wird nicht auf ungeprüften Arbeitsbaumcode -umgestellt. Application-Apply bleibt deaktiviert; der freigabegebundene -Plattform-Apply-Pfad ist davon getrennt. +Tatsächliche Application-Plans und -Applies verwenden das separat paketierte, +unveränderliche Application-Root, instanzbezogenen S3-State und auftragsgebundene +serverseitige Credential-Grants. Die Laufzeitfähigkeiten zeigen, ob der konfigurierte +Application-Runner aktiviert ist. `executionEnabled: false` bei Compiler- oder +Vorschauantworten bedeutet, dass diese Aufrufe keine Cloud-Ausführung gestartet haben; +es bedeutet nicht, dass die gesamte Laufzeit deaktiviert ist. Ein gespeicherter +Apply bleibt separat zu bestätigen und an das genaue Planartefakt gebunden. +Ein Quell-Merge aktiviert oder deployt diese Laufzeit nicht. @@ -582,9 +588,19 @@ verwendeten Governance-Modul geprüft: `npm run test:plan` ruft den separaten Accelerator-Pin. OpenTofu 1.12.6 plant mit Mock-Providern in bereinigter Umgebung, ohne Cloud-Credentials und ohne Apply. Geprüft werden Organisationsrollen `owner`/`organization.auditor`, Ordnerrollen `owner`/`auditor`, das Auslassen -doppelter Ordnerzuweisungen für Organisations-Owner sowie beide Elternvarianten. +doppelter Ordnerzuweisungen für Organisations-Owner, beide Elternvarianten sowie +die Ablehnung nicht leerer Ordnerbeschreibungen. Diese lokale Qualifikation ersetzt keinen Plan gegen die konkrete Kundenorganisation. +Der geprüfte Quellnachweis liegt unter +`app/integration/fixtures/governance/source.json`. Bei Änderungen am Plattform- +Runner-Pin die Root- und Governance-Dateihashes gegen genau diesen Commit prüfen +und alle vorhandenen Governance-Pläne erneut ausführen. Die strikten Revisions-/ +Hashprüfungen sowie Rollen- und Eigentümer-Assertions bleiben erhalten. Die +PR-Validierung führt den vollständigen Ablauf `npm run test:plan` einschließlich +dieser Qualifikation aus; Release-Builds wiederholen ihn vor der Paketierung des +isolierten Runners. + **Service-Account-Föderation für CI/CD** ist eine optionale Editor-Komponente. Sie regelt, welche OIDC-Tokens externer Pipelines den Management-Service-Account nutzen dürfen; sie ist keine Benutzeranmeldung am Configurator. diff --git a/landing-zone-configurator/docs/plan-execution.md b/landing-zone-configurator/docs/plan-execution.md index 298d55a7..1d7eca2a 100644 --- a/landing-zone-configurator/docs/plan-execution.md +++ b/landing-zone-configurator/docs/plan-execution.md @@ -75,9 +75,9 @@ The UUIDs are placeholders and must come from the actually applied platform. App - Tenant and revision are determined server-side. Migration 014 persists immutable contract versions with Forced RLS and technical verification/version evidence. An access test or contract is not Apply approval. - POST `/api/v1/applications/templates` can jointly bind `platformRevision` and `targetKey`. Region and project type must match the contract target; cross-tenant contract IDs are not visible. A change creates a new version. - POST `/api/v1/applications/instances/:id/plan-input` accepts only `{}`. Owner is determined from session and order; other people's orders and client identity/backend overrides are rejected. Identity expiry, revocation, role removal and changed requester email are checked again. -- The compiler uses exactly the order's contract and template versions, fixed parameter rules and own instance state key. The MVP subset is Public with local network, without Observability execution or namespace services. The response explicitly reports `cloudPlanExecuted: false` and `executionEnabled: false`; no keys are delivered to the browser. Immutable version/order policy is adopted as `applyPolicy` (`approval-required` or `direct`); `requiresExplicitApplyApproval: true` remains set for both. Direct policy is not execution approval. +- The compiler uses exactly the order's contract and template versions, fixed parameter rules and own instance state key. The current reviewed Application source supports Public projects with or without a local network and Corporate projects attached to the selected platform target, with optional Secrets Manager and Observability. Namespace services require a separate Kubernetes execution path and remain blocked. Older immutable source versions retain their previous Public-with-local-network restriction. The response explicitly reports `cloudPlanExecuted: false` and `executionEnabled: false`; no keys are delivered to the browser. Immutable version/order policy is adopted as `applyPolicy` (`approval-required` or `direct`); `requiresExplicitApplyApproval: true` remains set for both. Direct policy is not execution approval. -An actual independent cloud Plan still needs an immutably approved Application Runner artifact, separate state backend with locking and tightly limited server-side use of PE access by exactly the authorized job. The previous Runner pin contains no Application root. It is not switched to unchecked worktree code. Application Apply remains disabled; the approval-bound platform Apply path is separate. +Actual Application Plan/Apply uses the separately packaged immutable Application root, instance-specific S3 State and job-bound server-side credential grants. Runtime capabilities report whether the configured Application Runner is enabled; `executionEnabled: false` on compiler/preview responses means those calls did not execute a cloud operation, not that the entire runtime is disabled. A saved-plan Apply remains separately confirmed and bound to the exact artifact. Source merge does not enable or deploy this runtime. ## Common editor and execution scope @@ -238,7 +238,14 @@ Commit `9aa3322`, [release 36849188406](https://github.com/stackitcloud/stackit- ## Governance qualification and service-account federation (2026-10-01) -Governance input approval is additionally checked with the actually used governance module: `npm run test:plan` calls separate `test-governance-contract.sh`. Source hashes bind checking to the Accelerator pin. OpenTofu 1.12.6 plans with mock providers in a cleaned environment, without cloud credentials or Apply. Checks cover organization roles `owner`/`organization.auditor`, folder roles `owner`/`auditor`, omission of duplicate folder assignments for organization owners and both parent variants. This local qualification does not replace a Plan against the specific customer organization. +Governance input approval is additionally checked with the actually used governance module: `npm run test:plan` calls separate `test-governance-contract.sh`. Source hashes bind checking to the Accelerator pin. OpenTofu 1.12.6 plans with mock providers in a cleaned environment, without cloud credentials or Apply. Checks cover organization roles `owner`/`organization.auditor`, folder roles `owner`/`auditor`, omission of duplicate folder assignments for organization owners, both parent variants and rejection of non-empty folder descriptions. This local qualification does not replace a Plan against the specific customer organization. + +The reviewed source receipt is `app/integration/fixtures/governance/source.json`. +When updating the platform Runner pin, review the root and governance file hashes +against that exact commit and rerun all existing governance plans. Preserve the +strict revision/hash checks and role/ownership assertions. PR validation runs the +complete `npm run test:plan` flow, including this qualification; release builds +repeat it before packaging the isolated Runner. **Service-account federation for CI/CD** is an optional editor component. It controls which external pipeline OIDC tokens may use the management service account; it is not user login to the Configurator. `issuer` is the trusted issuer, `aud` the token audience and `sub`, for example, the specific GitHub repository with branch. Conditions use comparison `equals` supported by the pinned provider; `aud` is required. The GitHub template limits `sub` to a specified branch and does not apply unchanged to jobs with GitHub environments. diff --git a/landing-zone-configurator/docs/user-guide.de.md b/landing-zone-configurator/docs/user-guide.de.md index 1b253c0b..b03fbc6e 100644 --- a/landing-zone-configurator/docs/user-guide.de.md +++ b/landing-zone-configurator/docs/user-guide.de.md @@ -67,6 +67,29 @@ Version und geben deren freigegebene Parameter an. Ein Vorlagenentwurf ist keine Application-Instanz, und eine Veröffentlichung deployt keine Instanz. Bestehende Instanzen werden bei Veröffentlichung einer neuen Version nicht stillschweigend aktualisiert. +### Veröffentlichte Templates an eine bereitgestellte Plattform binden + +Als Platform Engineer im selben Arbeitsbereich die gespeicherte Plattformkonfiguration +öffnen. Die Veröffentlichung lädt deren Template-Entwürfe aus dieser geöffneten +Konfiguration; Plattformverträge und State-Backends werden unabhängig davon geladen. +Unter **Plattformanbindung** bei leerer Apply-Auswahl den aktuellen persönlichen +**STACKIT-Organisationsnachweis** erneuern. Ein technischer Service-Account ersetzt ihn +nicht. Nur erfolgreiche Applies auf dem aktuellen, entsperrten State werden angezeigt. +Outputs und Zielordner des ausgewählten Applies prüfen und dessen Plattformvertrag mit +dem technischen Zugangsprofil freigeben. + +Unter **Veröffentlichung** Template, freigegebenen Vertrag, passendes Ziel und berechtigte +Application-Owner-Gruppen auswählen. Eine neue unveränderliche Version veröffentlichen; +bestehende Versionen und Bestellungen werden nicht umgebunden. Der eingeladene Owner +wählt diese Version im selben Arbeitsbereich. Public-Projekte unterstützen ein optionales +lokales Netz; Corporate-Projekte verwenden das freigegebene SNA-Ziel. Beide unterstützen +optional Secrets Manager und Observability. Namespace-Dienste benötigen weiterhin einen +eigenen Kubernetes-Ausführungspfad. Symbolische Observability-Netz-ACL-Bindungen bleiben +bis zur Qualifikation ihrer tatsächlichen Egress-Quelle gesperrt. Bestehende Versionen +mit älteren Runner-Quellen behalten ihren früheren Ausführungsumfang. Jede Bestellung +verwendet einen eigenen State-Schlüssel; Veröffentlichung und Bestellung geben keinen +Infrastruktur-Apply frei. + ## Plan und Apply diff --git a/landing-zone-configurator/docs/user-guide.md b/landing-zone-configurator/docs/user-guide.md index e9a7ad84..a35ffbaf 100644 --- a/landing-zone-configurator/docs/user-guide.md +++ b/landing-zone-configurator/docs/user-guide.md @@ -60,6 +60,26 @@ and provide its exposed parameters. A template draft is not an Application instance, and publishing does not deploy one. Existing instances are not silently upgraded when a new version is published. +### Bind published templates to an applied platform + +Open the saved platform configuration as Platform Engineer in the same workspace. +Publication reads its template drafts from this open configuration; platform contracts +and State backends are loaded independently. Under **Platform binding**, renew current +personal **STACKIT organization proof** if the successful-Apply list is empty. A technical +service account does not replace that proof. Only successful Applies matching current, +unlocked State appear. Review the selected Apply's outputs and folders and approve its +platform contract using the technical credential profile. + +Under **Publication**, select the template, approved contract, matching target and +permitted Application Owner groups. Publish a new immutable version; existing versions +and orders are not rebound. The invited owner selects that version in the same workspace. +Public projects support optional local networking; Corporate projects use the approved +SNA target. Both support optional Secrets Manager and Observability. Namespace services +still need a separate Kubernetes execution path. Symbolic Observability network ACL +bindings remain blocked until their effective egress source is qualified. Existing +versions on older Runner sources retain their previous execution scope. Each order uses +its own State key; publication and ordering do not approve an infrastructure Apply. + ## Plan and Apply Choose the correct environment and target, bind the authorized technical