From b1899ffc89c741b6855abdf002f6a2ab76fac1f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Weberru=C3=9F?= Date: Wed, 28 Oct 2026 12:47:20 +0000 Subject: [PATCH] fix(configurator): retain safe runner cleanup and additive permission drafts --- .../app/apps/api/db/076_runner_retention.sql | 54 +++++ .../assistant/knowledge-topics.generated.ts | 8 +- .../apps/api/src/assistant/model-serving.ts | 5 +- .../app/apps/api/src/main.ts | 34 +++ .../app/apps/api/src/plans/cloud-foundry.ts | 149 ++++++++++++ .../app/apps/api/src/plans/runner-cleanup.ts | 83 +++++++ .../app/integration/identity.test.ts | 27 +++ .../app/integration/runner-pool.test.ts | 55 +++++ .../app/scripts/assistant-scenarios.ts | 20 +- .../app/tests/assistant-editor.test.ts | 54 +++++ .../app/tests/runner-cleanup.test.ts | 225 ++++++++++++++++++ .../project-templates.de.md | 5 + .../assistant-knowledge/project-templates.md | 17 +- .../docs/assistant-test-catalogue.json | 20 +- .../docs/plan-execution.de.md | 27 +++ .../docs/plan-execution.md | 25 ++ 16 files changed, 786 insertions(+), 22 deletions(-) create mode 100644 landing-zone-configurator/app/apps/api/db/076_runner_retention.sql create mode 100644 landing-zone-configurator/app/apps/api/src/plans/runner-cleanup.ts create mode 100644 landing-zone-configurator/app/tests/runner-cleanup.test.ts diff --git a/landing-zone-configurator/app/apps/api/db/076_runner_retention.sql b/landing-zone-configurator/app/apps/api/db/076_runner_retention.sql new file mode 100644 index 0000000..999a7d4 --- /dev/null +++ b/landing-zone-configurator/app/apps/api/db/076_runner_retention.sql @@ -0,0 +1,54 @@ +-- Only stopped CF execution apps are candidates. Job/history/State rows are retained. +CREATE TABLE lzc.runner_cleanup_receipts ( + app_id uuid PRIMARY KEY, + runner_id uuid NOT NULL, + kind text NOT NULL CHECK(kind IN ('plan','warm')), + removed_at timestamptz NOT NULL DEFAULT clock_timestamp() +); +ALTER TABLE lzc.runner_cleanup_receipts ENABLE ROW LEVEL SECURITY; +ALTER TABLE lzc.runner_cleanup_receipts FORCE ROW LEVEL SECURITY; +CREATE POLICY runner_cleanup_migration ON lzc.runner_cleanup_receipts TO configurator_migration USING(true) WITH CHECK(true); + +CREATE INDEX plan_runner_retention_app ON lzc.plan_runs(runner_app_id) WHERE runner_app_id IS NOT NULL; +CREATE INDEX application_runner_retention_app ON lzc.application_dispatches(runner_app_id) WHERE runner_app_id IS NOT NULL; +CREATE INDEX pool_runner_retention_app ON lzc.runner_pool_slots(app_id) WHERE app_id IS NOT NULL; +CREATE INDEX recovery_runner_retention_app ON lzc.application_state_recovery_tasks(runner_app_id) WHERE runner_app_id IS NOT NULL; + +-- A missing association is distinct from a durably finished association. Only +-- new origin-labelled apps may use the orphan path; legacy names alone are not ownership. +CREATE FUNCTION lzc_auth.runner_cleanup_disposition(p_runner uuid,p_app uuid,p_kind text) +RETURNS text LANGUAGE plpgsql SECURITY DEFINER SET search_path=pg_catalog AS $$ +DECLARE known boolean:=false; +BEGIN + IF p_kind NOT IN ('plan','warm') THEN RETURN 'protected'; END IF; + IF EXISTS(SELECT 1 FROM lzc.application_jobs j WHERE j.id=p_runner AND NOT EXISTS(SELECT 1 FROM lzc.application_dispatches d WHERE d.job_id=j.id AND d.runner_app_id=p_app)) + OR EXISTS(SELECT 1 FROM lzc.application_state_recovery_tasks WHERE id=p_runner OR runner_app_id=p_app) + OR EXISTS(SELECT 1 FROM lzc.runner_pool_slots WHERE (id=p_runner OR app_id=p_app) AND + (app_id IS DISTINCT FROM p_app OR (p_kind='warm' AND id<>p_runner) OR expires_at>now() OR status IN ('warming','ready','reserved','assigned'))) + OR EXISTS(SELECT 1 FROM lzc.plan_runs WHERE (id=p_runner OR runner_app_id=p_app) AND + (runner_app_id IS DISTINCT FROM p_app OR (p_kind='plan' AND id<>p_runner) OR expires_at>now() OR finished_at IS NULL OR finished_at>now()-interval '30 seconds' OR status NOT IN ('succeeded','failed','cancelled'))) + OR EXISTS(SELECT 1 FROM lzc.application_dispatches d WHERE (job_id=p_runner OR runner_app_id=p_app) AND + (runner_app_id IS DISTINCT FROM p_app OR (p_kind='plan' AND job_id<>p_runner) OR finished_at IS NULL OR finished_at>now()-interval '30 seconds' + OR NOT (status IN ('succeeded','failed') OR (status='reconciliation_required' AND EXISTS(SELECT 1 FROM lzc.application_recovery_replans r WHERE r.job_id=d.job_id))) + OR EXISTS(SELECT 1 FROM lzc.application_runner_tickets t WHERE t.job_id=d.job_id AND t.expires_at>now()) + OR EXISTS(SELECT 1 FROM lzc.application_state_recoveries r WHERE r.job_id=d.job_id AND r.phase IN ('requested','assessing','prepared','committing','reconciliation-required')))) + THEN RETURN 'protected'; END IF; + -- Consumed warm slots without a completed associated job are never mere orphans. + IF EXISTS(SELECT 1 FROM lzc.runner_pool_slots s WHERE s.app_id=p_app AND s.status='consumed' + AND NOT EXISTS(SELECT 1 FROM lzc.plan_runs p WHERE p.id=s.job_id AND p.runner_app_id=p_app) + AND NOT EXISTS(SELECT 1 FROM lzc.application_dispatches d WHERE d.job_id=s.job_id AND d.runner_app_id=p_app)) + THEN RETURN 'protected'; END IF; + SELECT EXISTS(SELECT 1 FROM lzc.plan_runs WHERE runner_app_id=p_app) + OR EXISTS(SELECT 1 FROM lzc.application_dispatches WHERE runner_app_id=p_app) + OR EXISTS(SELECT 1 FROM lzc.runner_pool_slots WHERE app_id=p_app AND id=p_runner AND p_kind='warm') INTO known; + RETURN CASE WHEN known THEN 'completed' ELSE 'orphan' END; +END $$; +REVOKE ALL ON FUNCTION lzc_auth.runner_cleanup_disposition(uuid,uuid,text) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION lzc_auth.runner_cleanup_disposition(uuid,uuid,text) TO configurator_app; + +CREATE FUNCTION lzc_auth.record_runner_cleanup(p_runner uuid,p_app uuid,p_kind text) RETURNS void +LANGUAGE sql SECURITY DEFINER SET search_path=pg_catalog AS $$ + INSERT INTO lzc.runner_cleanup_receipts(app_id,runner_id,kind) VALUES(p_app,p_runner,p_kind) ON CONFLICT(app_id) DO NOTHING; +$$; +REVOKE ALL ON FUNCTION lzc_auth.record_runner_cleanup(uuid,uuid,text) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION lzc_auth.record_runner_cleanup(uuid,uuid,text) TO configurator_app; diff --git a/landing-zone-configurator/app/apps/api/src/assistant/knowledge-topics.generated.ts b/landing-zone-configurator/app/apps/api/src/assistant/knowledge-topics.generated.ts index 920abec..83f5e82 100644 --- a/landing-zone-configurator/app/apps/api/src/assistant/knowledge-topics.generated.ts +++ b/landing-zone-configurator/app/apps/api/src/assistant/knowledge-topics.generated.ts @@ -407,7 +407,7 @@ export const assistantKnowledgeTopics = { { path: "landing-zone-configurator/app/apps/api/src/assistant/model-serving.ts", sha256: - "c52a6b232e658d0d4224d4595baca70c8974284d67448addbb98390846af0e6e", + "f34bfcb1b5d8e0f2149d4779223e663e4bedab6326f955d6416cafd77d585401", }, { path: "landing-zone-configurator/app/apps/api/src/assistant/editor.ts", @@ -1393,9 +1393,9 @@ export const assistantKnowledgeTopics = { ], source: "landing-zone-configurator/docs/assistant-knowledge/project-templates.md", - text: '# Landing-Zone Projects and Application Templates\n\n## Purpose\n\nTemplates/IAM.\n\n## Accelerator Contract\n\nlanding_zones: corporate/region/area, project_code/env/owner_email, network/roles/services.\nPublic:corporate=false. Drafts: projectTemplates/policies, no instance/publication fields.\naccessProfile: granular/owner; separate central initial owner/automation.\nAutomatic=null. Corporate Accelerator reads attached regional SNA default_prefix_length\nbefore Plan; supplies explicit numeric prefix to Provider0.114, preserves DNS.\nProvider0.114 rejects null prefix WITH DNS (even []): Invalid IPv4 configuration.\nAccelerator resolves it; no provider-upgrade fix. Explicit overrides win; Public provider sizing.\n\n## Dependencies\n\nGovernance, verified identity, same-region Corporate SNA, separate credentials.\nPublic!=Corporate spoke. Namespace services require Platform Kubernetes.\n\n## Dialogue\n\nNew: name/kind/region/settings; reuse keys, delegate names only.\nCentral=Corporate; Internet-only=Public; local network=true. Merge, retain Audit/Git,\nOmit unrequested services; ask missing choices. "100 projects"=capacity, not examples/quota;\nReserve platform/firewall space; no free-capacity promise. /26: network_prefix_length=26;\nAutomatic=null; no mandatory /24/numeric choice/disabled DNS. Preserve other settings.\nNew: granular recommended; owner customer choice.\nIDP, Configurator roles and deployed project IAM are separate.\nImmutable accessProfile, never order input. Granular schemaVersion=1,mode=granular:\ninitialOwnerEmail=user/SA, not requester; permissions/automationPermissions use official IDs,\ne.g. iaas.server.get, never compute.*. Default144: VM,Object Storage,Observability,Secrets Manager;\nVM-only narrower. All project rights selectable, including IAM/admin.\nTrusted catalogue IDs only. ADD Kubernetes/SKE=ske rights by union into requested lists\n(default both), preserving owner/rights; no unrelated IAM. SKE IAM!=Kubernetes RBAC.\nIAM grants can escalate; excluded from defaults.\nOwner={schemaVersion:1,mode:owner}: requester/automation have all project rights;\nIAM/project powers; no immutable central audit/routing claim.\nComplete atomic profile; preserve other fields. No legacy custom_roles/role_assignments/\nautomation_role or role-context combination. Ask missing central owner. Absent profile preserves\nlegacy owner; inherited grants stay additive.\n\n## Examples\n\nSNA /28: Automatic /28+DNS.\n\n## Limits\n\nOld versions/State bound; changes need reviewed source/publication/migration. Never delete State.\nRegion/area/DNS match; empty roles/ACLs prove no security. Effective rights need live acceptance, not mock Plans. Not owner-less.\n\n## Credentials\n\nSeparate access/runtime grants; no secrets/profile IDs in chat/exports.\n\n## Sources\n\nIaC.\n', + text: '# Landing-Zone Projects and Application Templates\n\n## Purpose\n\nTemplates/IAM.\n\n## Accelerator Contract\n\nlanding_zones: corporate/region/area, project_code/env/owner_email, network/roles/services.\nPublic:corporate=false. Drafts: projectTemplates/policies, no instance/publication fields.\naccessProfile: granular/owner; separate central initial owner/automation.\nAutomatic=null. Corporate Accelerator reads attached regional SNA default_prefix_length\nbefore Plan; supplies explicit numeric prefix to Provider0.114, preserves DNS.\nProvider0.114 rejects null prefix WITH DNS (even []): Invalid IPv4 configuration.\nAccelerator resolves it; no provider-upgrade fix. Explicit overrides win; Public provider sizing.\n\n## Dependencies\n\nGovernance, verified identity, same-region Corporate SNA, separate credentials.\nPublic!=Corporate spoke. Namespace services require Platform Kubernetes.\n\n## Dialogue\n\nNew: name/kind/region/settings; reuse keys; delegate names only.\nCentral=Corporate; Internet-only=Public; local network=true. Merge, retain Audit/Git,\nOmit unrequested services; ask missing choices. "100 projects"=capacity, not examples/quota;\nReserve platform/firewall space; no free-capacity promise. /26: network_prefix_length=26;\nAutomatic=null; no mandatory /24/numeric choice/disabled DNS. Preserve other settings.\nNew: granular recommended; owner customer choice.\nIDP, Configurator roles and deployed project IAM are separate.\nImmutable accessProfile, never order input. Granular schemaVersion=1,mode=granular:\ninitialOwnerEmail=user/SA, not requester; permissions/automationPermissions: official IDs,\ne.g. iaas.server.get; no compute.*. Default144: VM,Object Storage,Observability,Secrets Manager;\nVM-only narrower. All project rights selectable, including IAM/admin.\nCatalogue IDs only. ADD Kubernetes/SKE=ske union into requested lists\n(default both), preserving owner/rights; no unrelated IAM. SKE IAM!=Kubernetes RBAC.\nIAM grants can escalate; excluded from defaults.\nOwner={schemaVersion:1,mode:owner}: requester/automation have all project rights;\nIAM/project powers; no immutable central audit/routing claim.\nAtomic profile; preserve other fields. No legacy custom_roles/role_assignments/\nautomation_role/role-context combination. ADD preserves even empty owner; no owner question.\nPublication needs valid owner; new profile asks missing owner. Absent profile preserves\nlegacy owner; inherited grants stay additive.\n\n## Examples\n\nSNA /28: Automatic /28+DNS.\n\n## Limits\n\nVersions/State bound; review source/publication/migration. Never delete State.\nMatch region/area/DNS. Empty roles/ACLs prove no security. Rights need live acceptance, not mock Plans. Owner required.\n\n## Credentials\n\nSeparate access/runtime grants; no secrets/profile IDs in chat/exports.\n\n## Sources\n\nIaC.\n', sha256: - "18d2ea300edc1bf8a2fadace7fe615e82f616b0e9274328ff6b9ebcdc8f9a4fa", + "3c2ecdc335042a1b3ff2b53ce1814b1c7e2fbd3fe316193e4feb5222a4aa697b", }, { id: "sandbox-projects", @@ -1580,7 +1580,7 @@ export const assistantKnowledgeTopics = { { path: "landing-zone-configurator/app/apps/api/src/assistant/model-serving.ts", sha256: - "c52a6b232e658d0d4224d4595baca70c8974284d67448addbb98390846af0e6e", + "f34bfcb1b5d8e0f2149d4779223e663e4bedab6326f955d6416cafd77d585401", }, { path: "landing-zone-configurator/app/apps/api/src/assistant/knowledge.ts", diff --git a/landing-zone-configurator/app/apps/api/src/assistant/model-serving.ts b/landing-zone-configurator/app/apps/api/src/assistant/model-serving.ts index 45dc44b..3210595 100644 --- a/landing-zone-configurator/app/apps/api/src/assistant/model-serving.ts +++ b/landing-zone-configurator/app/apps/api/src/assistant/model-serving.ts @@ -1430,7 +1430,7 @@ export class AssistantModelServing { { role: "system", content: - "Application project IAM: distinguish information questions from desired changes. If the CURRENT USER supplies literal arrays for both permissions and automationPermissions, choose applicationAccess custom-granular with only key, initialOwnerEmail and listOperation: the server uses those exact field-labelled lists, so do not echo them and never substitute a preset or generic templateSetup. Set listOperation=replace only for a complete desired set; choose append for Ergänze/add/append requests, preserving each recipient and central owner; choose clarify when the two recipients request different operations or the operation is unclear. Advice remains applicationAccess=null. An accessProfile-only change on an existing template MUST use applicationAccess, not generic templateSetup/editorInputs. Full project Owner rights explicitly requested for requester and automation select choice=owner (schemaVersion 1, mode owner), with no granular fields; do not substitute the workload default. A wish that future orderers receive Kubernetes/other service rights is an explicit template-change request, even without the words add/proposal: use topic=project-templates, mode=draft, adviceTask=other, applicationAccess.choice=add-service-permissions. Granting a future requester the ability to order/use a Kubernetes cluster means adding SKE project IAM to the existing template, NOT creating a cluster, platform Kubernetes or namespace building block. If only Public/Corporate is specified, use key=__select_by_kind__, templateKind and exact templateEvidence from the current message. The server asks which when several match. If the request concerns what the orderer receives, permissionTarget=requester; do not also change Application-service-account permissions unless requested. Preserve the existing central owner and all other settings. Actual advice questions about what roles mean use mode=advice with applicationAccess=null. Never treat a desired permission change as a request for the general access overview. If pendingPermissionSelection is supplied and the current user chooses that template, continue its original USER service request using that controlled serviceEvidenceSource and key, preserving the original services and recipient. The current selection is not a new advice request. Otherwise never reactivate old permission tasks.", + "Application project IAM: distinguish information questions from desired changes. If the CURRENT USER supplies literal arrays for both permissions and automationPermissions, choose applicationAccess custom-granular with only key, initialOwnerEmail and listOperation: the server uses those exact field-labelled lists, so do not echo them and never substitute a preset or generic templateSetup. Set listOperation=replace only for a complete desired set; choose append for Ergänze/add/append requests, preserving each recipient and central owner; choose clarify when the two recipients request different operations or the operation is unclear. Advice remains applicationAccess=null. An accessProfile-only change on an existing template MUST use applicationAccess, not generic templateSetup/editorInputs. Full project Owner rights explicitly requested for requester and automation select choice=owner (schemaVersion 1, mode owner), with no granular fields; do not substitute the workload default. A wish that future orderers receive Kubernetes/other service rights is an explicit template-change request, even without the words add/proposal: use topic=project-templates, mode=draft, adviceTask=other, applicationAccess.choice=add-service-permissions. Granting a future requester the ability to order/use a Kubernetes cluster means adding SKE project IAM to the existing template, NOT creating a cluster, platform Kubernetes or namespace building block. If only Public/Corporate is specified, use key=__select_by_kind__, templateKind and exact templateEvidence from the current message. The server asks which when several match. If the request concerns what the orderer receives, permissionTarget=requester; do not also change Application-service-account permissions unless requested. Preserve the existing central owner and all other settings, including an unfinished empty owner field: adding service permissions to a draft must still produce a proposal, not a new owner/preset question. Publication separately requires a valid initial owner. Actual advice questions about what roles mean use mode=advice with applicationAccess=null. Never treat a desired permission change as a request for the general access overview. If pendingPermissionSelection is supplied and the current user chooses that template, continue its original USER service request using that controlled serviceEvidenceSource and key, preserving the original services and recipient. The current selection is not a new advice request. Otherwise never reactivate old permission tasks.", }, { role: "system", @@ -1892,6 +1892,7 @@ export class AssistantModelServing { } } if ( + selected.choice !== "add-service-permissions" && profile.mode === "granular" && !z.email().safeParse(profile.initialOwnerEmail).success ) { @@ -1926,7 +1927,7 @@ export class AssistantModelServing { settings, objectValue(objectValue(template.parameterPolicy).fields) .role_assignments, - true, + selected.choice !== "add-service-permissions", ); if (unknownApplicationPermissions(profile, livePermissionNames).length) throw new Error("assistant_editor_value_unconfirmed"); diff --git a/landing-zone-configurator/app/apps/api/src/main.ts b/landing-zone-configurator/app/apps/api/src/main.ts index 6288a48..1c88ce9 100644 --- a/landing-zone-configurator/app/apps/api/src/main.ts +++ b/landing-zone-configurator/app/apps/api/src/main.ts @@ -27,6 +27,7 @@ import { CloudFoundryPlanRunner } from "./plans/cloud-foundry.js"; import { ArtifactCrypto } from "./plans/crypto.js"; import { PlatformExecution } from "./plans/execution.js"; import { LocalPlanRunner } from "./plans/local.js"; +import { RunnerCleanup } from "./plans/runner-cleanup.js"; import { RunnerPool, runnerPoolSize } from "./plans/runner-pool.js"; import { Plans } from "./plans/service.js"; import { databaseConfig } from "./storage/database.js"; @@ -48,6 +49,7 @@ let stackit: StackitServices | undefined; let applicationRunner: CloudFoundryPlanRunner | undefined; let applicationRunnerPackageId: string | undefined; let runnerPool: RunnerPool | undefined; +let runnerCleanup: RunnerCleanup | undefined; const warmRunnerCount = runnerPoolSize(process.env.LZC_RUNNER_POOL_SIZE); const executionEnabled = process.env.LZC_EXECUTION_ENABLED === "true"; const applicationExecutionEnabled = @@ -130,6 +132,11 @@ if (process.env.LZC_AUTH_ENABLED === "true") { ) : undefined; if (process.env.LZC_PLANS_ENABLED === "true") { + runnerCleanup = new RunnerCleanup( + pool, + new CloudFoundryPlanRunner(runnerConfiguration()), + origin, + ); runnerPool = new RunnerPool( pool, artifactCrypto, @@ -346,10 +353,37 @@ const poolMaintenance = runnerPool ? setInterval(() => void maintainRunners(), 5000) : undefined; poolMaintenance?.unref(); +let cleanupMaintenance: Promise | undefined; +const cleanupTimer = runnerCleanup + ? setInterval(() => { + if (cleanupMaintenance) return; + cleanupMaintenance = runnerCleanup! + .maintain() + .then((summary) => { + if (summary) + app.log.info( + { event: "runner_retention", ...summary }, + "Runner retention checked", + ); + }) + .catch(() => + app.log.warn( + { event: "runner_retention_failed" }, + "Runner retention failed", + ), + ) + .finally(() => { + cleanupMaintenance = undefined; + }); + }, 60000) + : undefined; +cleanupTimer?.unref(); app.addHook("onClose", async () => { clearInterval(maintenance); clearInterval(poolMaintenance); + clearInterval(cleanupTimer); await runnerMaintenance; + await cleanupMaintenance; await pool?.end(); }); const port = Number(process.env.PORT ?? "3000"); diff --git a/landing-zone-configurator/app/apps/api/src/plans/cloud-foundry.ts b/landing-zone-configurator/app/apps/api/src/plans/cloud-foundry.ts index 380bc6a..2d17cce 100644 --- a/landing-zone-configurator/app/apps/api/src/plans/cloud-foundry.ts +++ b/landing-zone-configurator/app/apps/api/src/plans/cloud-foundry.ts @@ -1,5 +1,6 @@ import { randomBytes, randomUUID } from "node:crypto"; import { z } from "zod"; +import { type CleanupApp, runnerOwner } from "./runner-cleanup.js"; const guid = (value: unknown) => z.uuid().parse(value); export class RunnerRequestError extends Error { @@ -322,6 +323,11 @@ export class CloudFoundryPlanRunner implements PlanRunner { }, relationships: { space: { data: { guid: this.config.spaceId } } }, environment_variables: variables, + metadata: { + labels: { + "lzc.runner-owner": runnerOwner(variables.LZC_BROKER_ORIGIN!), + }, + }, }); const appId = guid(app.guid); await record(appId, guid(droplet.guid)); @@ -506,6 +512,149 @@ export class CloudFoundryPlanRunner implements PlanRunner { guid(id); await this.removeNamed(`lzc-warm-${id}`, appId); } + async cleanupPage( + page: number, + ): Promise<{ apps: CleanupApp[]; nextPage: number | null }> { + if (!Number.isSafeInteger(page) || page < 1) + throw new Error("Invalid cleanup page"); + const call = await this.authorized(); + const result = z + .object({ + pagination: z.object({ + next: z.object({ href: z.string() }).nullable(), + }), + resources: z.array( + z.object({ + guid: z.uuid(), + name: z.string(), + created_at: z.string(), + state: z.string(), + relationships: z.object({ + space: z.object({ + data: z.object({ guid: z.literal(this.config.spaceId) }), + }), + }), + metadata: z + .object({ labels: z.record(z.string(), z.string().nullable()) }) + .optional(), + }), + ), + }) + .parse( + await call( + `apps?space_guids=${this.config.spaceId}&per_page=50&page=${page}&order_by=created_at`, + ), + ); + return { + nextPage: result.pagination.next ? page + 1 : null, + apps: result.resources.map((app) => ({ + id: app.guid, + name: app.name, + createdAt: app.created_at, + state: app.state, + owner: app.metadata?.labels["lzc.runner-owner"] ?? null, + })), + }; + } + async deleteStoppedRunner( + app: CleanupApp, + eligible: () => Promise, + ): Promise { + guid(app.id); + if ( + app.id === this.config.templateId || + !/^lzc-(plan|warm)-[a-f0-9-]{36}$/.test(app.name) + ) + return false; + const call = await this.authorized(); + const unchanged = async () => { + const current = z + .object({ + guid: z.literal(app.id), + name: z.literal(app.name), + created_at: z.literal(app.createdAt), + state: z.string(), + relationships: z.object({ + space: z.object({ + data: z.object({ guid: z.literal(this.config.spaceId) }), + }), + }), + metadata: z + .object({ labels: z.record(z.string(), z.string().nullable()) }) + .optional(), + }) + .parse(await call(`apps/${app.id}`)); + return ( + current.state === "STOPPED" && + (current.metadata?.labels["lzc.runner-owner"] ?? null) === app.owner + ); + }; + if (!(await unchanged())) return false; + for (const path of [ + `apps/${app.id}/routes`, + `service_credential_bindings?app_guids=${app.id}`, + ]) { + const result = z + .object({ + resources: z.array(z.unknown()), + pagination: z.object({ + next: z.object({ href: z.string() }).nullable(), + }), + }) + .parse(await call(path)); + if (result.resources.length || result.pagination.next) return false; + } + const tasks = z + .object({ + resources: z.array(z.object({ state: z.string() })), + pagination: z.object({ + next: z.object({ href: z.string() }).nullable(), + }), + }) + .parse(await call(`apps/${app.id}/tasks?per_page=100`)); + if ( + tasks.pagination.next || + tasks.resources.some( + (task) => !["SUCCEEDED", "FAILED"].includes(task.state), + ) + ) + return false; + const processes = z + .object({ + resources: z.array(z.object({ guid: z.uuid() })), + pagination: z.object({ + next: z.object({ href: z.string() }).nullable(), + }), + }) + .parse(await call(`apps/${app.id}/processes`)); + if (processes.pagination.next) return false; + for (const process of processes.resources) { + const stats = z + .object({ resources: z.array(z.object({ state: z.string() })) }) + .parse(await call(`processes/${process.guid}/stats`)); + if ( + stats.resources.some( + (stat) => !["DOWN", "CRASHED"].includes(stat.state), + ) + ) + return false; + } + if (!(await unchanged()) || !(await eligible())) return false; + await call(`apps/${app.id}`, "DELETE"); + // CF may only accept an asynchronous DELETE. A receipt means absence, + // not merely acceptance of a deletion request. + for (let attempt = 0; attempt < 6; attempt++) { + try { + await call(`apps/${app.id}`); + } catch (error) { + if (error instanceof RunnerRequestError && error.status === 404) + return true; + throw error; + } + if (attempt < 5) await new Promise((resolve) => setTimeout(resolve, 250)); + } + return false; + } private async removeNamed(name: string, appId: string | null) { const call = await this.authorized(); // Look up the deterministic name too: handles a crash before persisting the CF GUID. diff --git a/landing-zone-configurator/app/apps/api/src/plans/runner-cleanup.ts b/landing-zone-configurator/app/apps/api/src/plans/runner-cleanup.ts new file mode 100644 index 0000000..b7aee05 --- /dev/null +++ b/landing-zone-configurator/app/apps/api/src/plans/runner-cleanup.ts @@ -0,0 +1,83 @@ +import { createHash } from "node:crypto"; +import type pg from "pg"; +import { z } from "zod"; +import type { CloudFoundryPlanRunner } from "./cloud-foundry.js"; + +export const runnerOwner = (origin: string) => + createHash("sha256") + .update(new URL(origin).origin) + .digest("hex") + .slice(0, 32); +export type CleanupApp = { + id: string; + name: string; + createdAt: string; + state: string; + owner: string | null; +}; +export class RunnerCleanup { + private page = 1; + private running = false; + constructor( + private readonly database: pg.Pool, + private readonly driver: Pick< + CloudFoundryPlanRunner, + "cleanupPage" | "deleteStoppedRunner" + >, + private readonly origin: string, + ) {} + async maintain() { + if (this.running) return; + this.running = true; + try { + const result = await this.driver.cleanupPage(this.page); + this.page = result.nextPage ?? 1; + const summary = { + scanned: result.apps.length, + deleted: 0, + protected: 0, + unassociated: 0, + }; + for (const app of result.apps) { + const match = /^lzc-(plan|warm)-(.+)$/.exec(app.name); + if ( + !match || + !z.uuid().safeParse(match[2]).success || + app.state !== "STOPPED" || + !Number.isFinite(Date.parse(app.createdAt)) || + Date.parse(app.createdAt) > Date.now() - 86400000 + ) + continue; + if (app.owner !== null && app.owner !== runnerOwner(this.origin)) + continue; + const [kind, id] = [match[1]!, match[2]!]; + const eligible = async (count = false) => { + const { rows } = await this.database.query<{ disposition: string }>( + "SELECT lzc_auth.runner_cleanup_disposition($1,$2,$3) AS disposition", + [id, app.id, kind], + ); + const allowed = + rows[0]?.disposition === "completed" || + (rows[0]?.disposition === "orphan" && + app.owner === runnerOwner(this.origin)); + if (count && !allowed) { + if (rows[0]?.disposition === "orphan") summary.unassociated++; + else summary.protected++; + } + return allowed; + }; + if (!(await eligible(true))) continue; + if (await this.driver.deleteStoppedRunner(app, eligible)) { + await this.database.query( + "SELECT lzc_auth.record_runner_cleanup($1,$2,$3)", + [id, app.id, kind], + ); + summary.deleted++; + } + } + return summary; + } finally { + this.running = false; + } + } +} diff --git a/landing-zone-configurator/app/integration/identity.test.ts b/landing-zone-configurator/app/integration/identity.test.ts index 3e74484..40042fc 100644 --- a/landing-zone-configurator/app/integration/identity.test.ts +++ b/landing-zone-configurator/app/integration/identity.test.ts @@ -10195,6 +10195,33 @@ it.each([false, true])( "UPDATE lzc.plan_runs SET finished_at=now()-interval '1 minute' WHERE id=$1", [run.id], ); + const retentionApp = ( + await migration.query( + "SELECT runner_app_id FROM lzc.plan_runs WHERE id=$1", + [run.id], + ) + ).rows[0].runner_app_id; + const retention = async () => + ( + await pool.query( + "SELECT lzc_auth.runner_cleanup_disposition($1,$2,'plan') AS result", + [run.id, retentionApp], + ) + ).rows[0].result; + expect(await retention()).toBe("protected"); + await migration.query( + "UPDATE lzc.plan_runs SET expires_at=now()-interval '1 second' WHERE id=$1", + [run.id], + ); + expect(await retention()).toBe("completed"); + expect( + ( + await pool.query( + "SELECT lzc_auth.runner_cleanup_disposition($1,$2,'plan') AS result", + [run.id, randomUUID()], + ) + ).rows[0].result, + ).toBe("protected"); await plans.maintain(); expect(runner.remove).toHaveBeenCalledWith(run.id, expect.any(String)); // Expiry prevents another input delivery and releases the user's single active slot. diff --git a/landing-zone-configurator/app/integration/runner-pool.test.ts b/landing-zone-configurator/app/integration/runner-pool.test.ts index c478ab8..8159a4f 100644 --- a/landing-zone-configurator/app/integration/runner-pool.test.ts +++ b/landing-zone-configurator/app/integration/runner-pool.test.ts @@ -647,3 +647,58 @@ it("atomically rewarms only a fresh unassigned exact-source lease and rotates it ).toBe(0); expect((await pool.claim(slot.id, ticket))?.id).toBe(assignment.id); }); + +it("authorizes retention only for expired retired slots and protects active or unassociated consumed slots", async () => { + const runner = randomUUID(), + app = randomUUID(); + const disposition = async () => + ( + await database.query( + "SELECT lzc_auth.runner_cleanup_disposition($1,$2,'warm') AS result", + [runner, app], + ) + ).rows[0].result; + expect(await disposition()).toBe("orphan"); + await database.query( + "INSERT INTO lzc.runner_pool_slots(id,pool_key,origin,space_id,source_droplet_id,app_id,status,expires_at) VALUES($1,'retention-test',$2,$3,$4,$5,'ready',now()+interval '1 hour')", + [runner, config.origin, config.spaceId, config.dropletId, app], + ); + expect(await disposition()).toBe("protected"); + await database.query( + "UPDATE lzc.runner_pool_slots SET status='retiring',expires_at=now()-interval '1 hour' WHERE id=$1", + [runner], + ); + expect(await disposition()).toBe("completed"); + await database.query( + "UPDATE lzc.runner_pool_slots SET status='consumed',job_id=$2 WHERE id=$1", + [runner, randomUUID()], + ); + expect(await disposition()).toBe("protected"); + expect( + ( + await database.query( + "SELECT lzc_auth.runner_cleanup_disposition($1,$2,'plan') AS result", + [runner, randomUUID()], + ) + ).rows[0].result, + ).toBe("protected"); + await database.query("SELECT lzc_auth.record_runner_cleanup($1,$2,'warm')", [ + runner, + app, + ]); + await database.query("SELECT lzc_auth.record_runner_cleanup($1,$2,'warm')", [ + runner, + app, + ]); + await expect( + database.query("SELECT * FROM lzc.runner_cleanup_receipts"), + ).rejects.toMatchObject({ code: "42501" }); + expect( + ( + await migration.query( + "SELECT count(*) FROM lzc.runner_cleanup_receipts WHERE app_id=$1", + [app], + ) + ).rows[0].count, + ).toBe("1"); +}); diff --git a/landing-zone-configurator/app/scripts/assistant-scenarios.ts b/landing-zone-configurator/app/scripts/assistant-scenarios.ts index 2fa337e..14dd78e 100644 --- a/landing-zone-configurator/app/scripts/assistant-scenarios.ts +++ b/landing-zone-configurator/app/scripts/assistant-scenarios.ts @@ -159,6 +159,7 @@ export type AssistantScenario = { | "kubernetes-add" | "kubernetes-requester" | "public-kubernetes-requester" + | "public-kubernetes-incomplete-owner" | "public-kubernetes-clarify" | "public-kubernetes-followup"; corporateIpv4?: "advice" | "automatic" | "explicit"; @@ -306,14 +307,16 @@ export const assistantScenarios: readonly AssistantScenario[] = [ message: "Ergänze bei der vorhandenen Vorlage central nur für den Besteller die Rechte zum Management von Kubernetes Clustern. Die Rechte des Application-Service-Accounts und der zentrale Owner bleiben unverändert, ebenso alle anderen Einstellungen. Nur einen überprüfbaren Vorschlag erstellen.", }, - ...(["requester", "clarify"] as const).map((mode) => ({ + ...(["requester", "clarify", "incomplete-owner"] as const).map((mode) => ({ id: `application-access-public-kubernetes-${mode}`, topic: "project-templates", fixture: "corporate-ipv4" as const, applicationAccess: - mode === "requester" - ? ("public-kubernetes-requester" as const) - : ("public-kubernetes-clarify" as const), + mode === "incomplete-owner" + ? ("public-kubernetes-incomplete-owner" as const) + : mode === "requester" + ? ("public-kubernetes-requester" as const) + : ("public-kubernetes-clarify" as const), message: "Ich will das man bei der Bestellung einer Public Landing Zone auch Rechte bekommt einen Kubernetes Cluster bestellen und nutzen zu können", })), @@ -963,7 +966,11 @@ export function assistantScenarioFixture(scenario: AssistantScenario) { secretsmanager_enabled: true, }; publicTemplate.accessProfile = { - ...defaultApplicationAccessProfile("platform-owner@example.test"), + ...defaultApplicationAccessProfile( + scenario.applicationAccess === "public-kubernetes-incomplete-owner" + ? "" + : "platform-owner@example.test", + ), permissions: ["secrets-manager.secret.get", "iaas.server.get"], automationPermissions: ["iaas.server.get"], }; @@ -1138,6 +1145,7 @@ export function validateAssistantScenario( if ( scenario.applicationAccess === "kubernetes-add" || scenario.applicationAccess === "kubernetes-requester" || + scenario.applicationAccess === "public-kubernetes-incomplete-owner" || scenario.applicationAccess === "public-kubernetes-requester" || scenario.applicationAccess === "public-kubernetes-followup" ) { @@ -1152,12 +1160,14 @@ export function validateAssistantScenario( central.accessProfile = { ...before, permissions: + scenario.applicationAccess === "public-kubernetes-incomplete-owner" || scenario.applicationAccess === "public-kubernetes-requester" || scenario.applicationAccess === "public-kubernetes-followup" ? [...new Set([...before.permissions, ...additions])] : [...new Set([...before.permissions, ...additions])].sort(), automationPermissions: scenario.applicationAccess === "kubernetes-requester" || + scenario.applicationAccess === "public-kubernetes-incomplete-owner" || scenario.applicationAccess === "public-kubernetes-requester" || scenario.applicationAccess === "public-kubernetes-followup" ? before.automationPermissions diff --git a/landing-zone-configurator/app/tests/assistant-editor.test.ts b/landing-zone-configurator/app/tests/assistant-editor.test.ts index 5c4ddb8..29c1920 100644 --- a/landing-zone-configurator/app/tests/assistant-editor.test.ts +++ b/landing-zone-configurator/app/tests/assistant-editor.test.ts @@ -2028,6 +2028,60 @@ it("rejects an unrelated service or invented evidence in the catalogue-derived a ).rejects.toBeInstanceOf(AssistantInferenceError); }); +it.each(["", "central@example.test"])( + "adds requested service rights to an incomplete draft without replacing its owner (%s)", + async (owner) => { + const original = createEditorConfiguration( + "hub-and-spoke", + crypto.randomUUID(), + ); + const template = original.projectTemplates!.find( + (t) => t.kind === "public", + )!; + template.accessProfile = defaultApplicationAccessProfile(owner); + const previous = structuredClone(template.accessProfile); + const message = + "Ich will das man bei der Bestellung einer Public Landing Zone auch Rechte bekommt einen Kubernetes Cluster bestellen und nutzen zu können"; + const result = await collectionModel( + { + topic: "project-templates", + mode: "draft", + requirements: [], + applicationAccess: { + key: "__select_by_kind__", + templateKind: "public", + templateEvidence: "Public Landing Zone", + choice: "add-service-permissions", + services: ["ske"], + permissionTarget: "requester", + serviceEvidence: "Kubernetes Cluster bestellen und nutzen", + }, + }, + null, + ).converse( + knowledgeSelection(), + assistantPublicValues(original), + [], + message, + ); + expect(result.questions).toEqual([]); + expect(result.changes).toHaveLength(1); + const after = applyAssistantChanges( + original, + result.changes, + ).projectTemplates!.find((t) => t.id === template.id)!.accessProfile!; + expect(after.mode).toBe("granular"); + if (after.mode !== "granular" || previous.mode !== "granular") + throw new Error("expected granular fixture"); + expect(after.initialOwnerEmail).toBe(owner); + expect(after.automationPermissions).toEqual(previous.automationPermissions); + expect(after.permissions).toEqual([ + ...previous.permissions, + ...applicationProjectPermissionNames.filter((p) => p.startsWith("ske.")), + ]); + }, +); + it("does not offer an unchanged policy when all Kubernetes rights are already present", async () => { const original = createEditorConfiguration( "hub-and-spoke", diff --git a/landing-zone-configurator/app/tests/runner-cleanup.test.ts b/landing-zone-configurator/app/tests/runner-cleanup.test.ts new file mode 100644 index 0000000..28bc2d0 --- /dev/null +++ b/landing-zone-configurator/app/tests/runner-cleanup.test.ts @@ -0,0 +1,225 @@ +import { randomUUID } from "node:crypto"; +import type pg from "pg"; +import { afterEach, expect, it, vi } from "vitest"; +import { CloudFoundryPlanRunner } from "../apps/api/src/plans/cloud-foundry.js"; +import { + type CleanupApp, + RunnerCleanup, + runnerOwner, +} from "../apps/api/src/plans/runner-cleanup.js"; + +afterEach(() => vi.unstubAllGlobals()); +const origin = "https://configurator.example"; +function fixture() { + const app: CleanupApp = { + id: randomUUID(), + name: `lzc-plan-${randomUUID()}`, + createdAt: new Date(Date.now() - 2 * 86400000).toISOString(), + state: "STOPPED", + owner: null, + }; + const database = { + query: vi.fn(async (_sql: string, _values?: unknown[]) => ({ + rows: [{ disposition: "completed" }], + })), + }; + const driver = { + cleanupPage: vi.fn(async (_page: number) => ({ + apps: [app], + nextPage: null as number | null, + })), + deleteStoppedRunner: vi.fn( + async (_app: CleanupApp, eligible: () => Promise) => eligible(), + ), + }; + return { + app, + database, + driver, + cleanup: new RunnerCleanup(database as unknown as pg.Pool, driver, origin), + }; +} +it("rechecks database eligibility and records only deleted apps without modifying job or State rows", async () => { + const f = fixture(); + await f.cleanup.maintain(); + expect(f.driver.deleteStoppedRunner).toHaveBeenCalledOnce(); + expect(f.database.query.mock.calls).toHaveLength(3); + expect(f.database.query.mock.calls.at(-1)?.[0]).toContain( + "record_runner_cleanup", + ); +}); +it.each([ + "STARTED", + "fresh", + "foreign-owner", + "template", + "unknown", + "unlabelled-orphan", +])("preserves %s apps", async (reason) => { + const f = fixture(); + if (reason === "STARTED") f.app.state = "STARTED"; + if (reason === "fresh") f.app.createdAt = new Date().toISOString(); + if (reason === "foreign-owner") + f.app.owner = runnerOwner("https://other.example"); + if (reason === "template") f.app.name = "lzc-plan-template"; + if (reason === "unknown") + f.database.query.mockResolvedValue({ + rows: [{ disposition: "protected" }], + }); + if (reason === "unlabelled-orphan") + f.database.query.mockResolvedValue({ rows: [{ disposition: "orphan" }] }); + await f.cleanup.maintain(); + expect(f.driver.deleteStoppedRunner).not.toHaveBeenCalled(); +}); +it("removes an old stopped origin-labelled orphan and advances bounded pages", async () => { + const f = fixture(); + f.app.owner = runnerOwner(origin); + f.database.query.mockResolvedValue({ rows: [{ disposition: "orphan" }] }); + f.driver.cleanupPage + .mockResolvedValueOnce({ apps: [f.app], nextPage: 2 }) + .mockResolvedValueOnce({ apps: [], nextPage: null }); + await f.cleanup.maintain(); + await f.cleanup.maintain(); + expect(f.driver.cleanupPage.mock.calls).toEqual([[1], [2]]); + expect(f.driver.deleteStoppedRunner).toHaveBeenCalledOnce(); +}); +it("does not record a deletion when final authorization or CF deletion fails", async () => { + const f = fixture(); + f.driver.deleteStoppedRunner.mockResolvedValue(false); + await f.cleanup.maintain(); + expect(f.database.query.mock.calls).toHaveLength(1); + f.driver.deleteStoppedRunner.mockRejectedValueOnce(new Error("CF failed")); + await expect(f.cleanup.maintain()).rejects.toThrow("CF failed"); + expect(f.database.query.mock.calls).toHaveLength(2); +}); +function cfFixture() { + const app: CleanupApp = { + id: randomUUID(), + name: `lzc-plan-${randomUUID()}`, + createdAt: new Date(Date.now() - 2 * 86400000).toISOString(), + state: "STOPPED", + owner: null, + }; + const space = randomUUID(), + process = randomUUID(); + const data: Record = { + [`apps/${app.id}`]: { + guid: app.id, + name: app.name, + created_at: app.createdAt, + state: "STOPPED", + relationships: { space: { data: { guid: space } } }, + }, + [`apps/${app.id}/routes`]: { resources: [], pagination: { next: null } }, + [`service_credential_bindings?app_guids=${app.id}`]: { + resources: [], + pagination: { next: null }, + }, + [`apps/${app.id}/tasks?per_page=100`]: { + resources: [], + pagination: { next: null }, + }, + [`apps/${app.id}/processes`]: { + resources: [{ guid: process }], + pagination: { next: null }, + }, + [`processes/${process}/stats`]: { resources: [{ state: "DOWN" }] }, + }; + const deletes: string[] = []; + const deletion = { remainsPresent: false }; + vi.stubGlobal( + "fetch", + vi.fn(async (url: unknown, init: RequestInit) => { + if (String(url).endsWith("/oauth/token")) + return new Response(JSON.stringify({ access_token: "test-only" })); + const path = String(url).split("/v3/")[1]!; + if (init.method === "DELETE") { + deletes.push(path); + return new Response(null, { status: 202 }); + } + if (deletes.includes(path) && !deletion.remainsPresent) + return new Response(null, { status: 404 }); + if (!(path in data)) throw new Error("Unexpected CF path"); + return new Response(JSON.stringify(data[path])); + }), + ); + const runner = new CloudFoundryPlanRunner({ + username: "test", + password: "test", + spaceId: space, + templateId: randomUUID(), + }); + return { app, data, process, deletes, runner, deletion }; +} +it("deletes only the exact stopped route-free unbound app with terminal processes and tasks", async () => { + const f = cfFixture(); + expect(await f.runner.deleteStoppedRunner(f.app, async () => true)).toBe( + true, + ); + expect(f.deletes).toEqual([`apps/${f.app.id}`]); +}); +it.each([ + "task", + "process", + "binding", + "route", + "pagination", + "authorization", + "changed-name", +])("refuses deletion for %s uncertainty", async (reason) => { + const f = cfFixture(); + if (reason === "task") + f.data[`apps/${f.app.id}/tasks?per_page=100`] = { + resources: [{ state: "RUNNING" }], + pagination: { next: null }, + }; + if (reason === "process") + f.data[`processes/${f.process}/stats`] = { + resources: [{ state: "RUNNING" }], + }; + if (reason === "binding") + f.data[`service_credential_bindings?app_guids=${f.app.id}`] = { + resources: [{}], + pagination: { next: null }, + }; + if (reason === "route") + f.data[`apps/${f.app.id}/routes`] = { + resources: [{}], + pagination: { next: null }, + }; + if (reason === "pagination") + f.data[`apps/${f.app.id}/tasks?per_page=100`] = { + resources: [], + pagination: { next: { href: "untrusted" } }, + }; + if (reason === "changed-name") + (f.data[`apps/${f.app.id}`] as { name: string }).name = "foreign-app"; + const action = f.runner.deleteStoppedRunner( + f.app, + async () => reason !== "authorization", + ); + if (reason === "changed-name") await expect(action).rejects.toThrow(); + else expect(await action).toBe(false); + expect(f.deletes).toEqual([]); +}); + +it("does not confirm deletion when CF accepts DELETE but the app remains present", async () => { + const f = cfFixture(); + f.deletion.remainsPresent = true; + expect(await f.runner.deleteStoppedRunner(f.app, async () => true)).toBe( + false, + ); + expect(f.deletes).toEqual([`apps/${f.app.id}`]); +}); + +it("fails closed when CF omits the task pagination completeness marker", async () => { + const f = cfFixture(); + f.data[`apps/${f.app.id}/tasks?per_page=100`] = { + resources: [], + pagination: {}, + }; + await expect( + f.runner.deleteStoppedRunner(f.app, async () => true), + ).rejects.toThrow(); + expect(f.deletes).toEqual([]); +}); diff --git a/landing-zone-configurator/docs/assistant-knowledge/project-templates.de.md b/landing-zone-configurator/docs/assistant-knowledge/project-templates.de.md index 2894e40..9536857 100644 --- a/landing-zone-configurator/docs/assistant-knowledge/project-templates.de.md +++ b/landing-zone-configurator/docs/assistant-knowledge/project-templates.de.md @@ -168,3 +168,8 @@ Das ist keine Unterstützung für Projekte ohne initialen Owner (#69). ## Erweiterbare Projektberechtigungen Die 144 Rechte des Workload-Standards sind die Vorauswahl, keine Obergrenze. Alle offiziellen Projektberechtigungen aus dem vollständigen statischen Katalog und verfügbaren Live-Daten können ergänzt werden, einschließlich bewusst gewählter IAM-/Projektverwaltung. Beim Ergänzen von Dienstrechten bleiben zentraler Owner und bestehende Rechte erhalten; die Katalogrechte des angefragten Diensts werden nur den gewünschten Listen hinzugefügt (ohne Unterscheidung beiden). Kubernetes/SKE verwendet den Dienstpräfix `ske`; keine IAM-Rechte oder andere Dienste ungefragt ergänzen. Volle Owner-Rechte enthalten die Dienstverwaltung bereits. Projekt-IAM für SKE ist von Kubernetes-RBAC innerhalb des Clusters zu unterscheiden. IAM-Rollen- und Mitgliederverwaltung kann Rechteerweiterungen ermöglichen und gehört nicht zum Standard. Keine Kennungen erfinden; Veröffentlichung und Ausführung bleiben separat. + +Das Ergänzen von Dienstrechten in einem bestehenden granularen Entwurf bleibt auch +bei noch leerem initialen Owner möglich. Der Assistant erhält dieses Feld unverändert +und erstellt den Rechtevorschlag; erst die Veröffentlichung verlangt einen gültigen +separaten Owner. Beim Einrichten eines neuen Rechteprofils fragt er fehlende Angaben ab. diff --git a/landing-zone-configurator/docs/assistant-knowledge/project-templates.md b/landing-zone-configurator/docs/assistant-knowledge/project-templates.md index a85b297..7acbc73 100644 --- a/landing-zone-configurator/docs/assistant-knowledge/project-templates.md +++ b/landing-zone-configurator/docs/assistant-knowledge/project-templates.md @@ -21,7 +21,7 @@ Public!=Corporate spoke. Namespace services require Platform Kubernetes. ## Dialogue -New: name/kind/region/settings; reuse keys, delegate names only. +New: name/kind/region/settings; reuse keys; delegate names only. Central=Corporate; Internet-only=Public; local network=true. Merge, retain Audit/Git, Omit unrequested services; ask missing choices. "100 projects"=capacity, not examples/quota; Reserve platform/firewall space; no free-capacity promise. /26: network_prefix_length=26; @@ -29,16 +29,17 @@ Automatic=null; no mandatory /24/numeric choice/disabled DNS. Preserve other set New: granular recommended; owner customer choice. IDP, Configurator roles and deployed project IAM are separate. Immutable accessProfile, never order input. Granular schemaVersion=1,mode=granular: -initialOwnerEmail=user/SA, not requester; permissions/automationPermissions use official IDs, -e.g. iaas.server.get, never compute.*. Default144: VM,Object Storage,Observability,Secrets Manager; +initialOwnerEmail=user/SA, not requester; permissions/automationPermissions: official IDs, +e.g. iaas.server.get; no compute.*. Default144: VM,Object Storage,Observability,Secrets Manager; VM-only narrower. All project rights selectable, including IAM/admin. -Trusted catalogue IDs only. ADD Kubernetes/SKE=ske rights by union into requested lists +Catalogue IDs only. ADD Kubernetes/SKE=ske union into requested lists (default both), preserving owner/rights; no unrelated IAM. SKE IAM!=Kubernetes RBAC. IAM grants can escalate; excluded from defaults. Owner={schemaVersion:1,mode:owner}: requester/automation have all project rights; IAM/project powers; no immutable central audit/routing claim. -Complete atomic profile; preserve other fields. No legacy custom_roles/role_assignments/ -automation_role or role-context combination. Ask missing central owner. Absent profile preserves +Atomic profile; preserve other fields. No legacy custom_roles/role_assignments/ +automation_role/role-context combination. ADD preserves even empty owner; no owner question. +Publication needs valid owner; new profile asks missing owner. Absent profile preserves legacy owner; inherited grants stay additive. ## Examples @@ -47,8 +48,8 @@ SNA /28: Automatic /28+DNS. ## Limits -Old versions/State bound; changes need reviewed source/publication/migration. Never delete State. -Region/area/DNS match; empty roles/ACLs prove no security. Effective rights need live acceptance, not mock Plans. Not owner-less. +Versions/State bound; review source/publication/migration. Never delete State. +Match region/area/DNS. Empty roles/ACLs prove no security. Rights need live acceptance, not mock Plans. Owner required. ## Credentials diff --git a/landing-zone-configurator/docs/assistant-test-catalogue.json b/landing-zone-configurator/docs/assistant-test-catalogue.json index 00dce99..b43e410 100644 --- a/landing-zone-configurator/docs/assistant-test-catalogue.json +++ b/landing-zone-configurator/docs/assistant-test-catalogue.json @@ -2,12 +2,12 @@ "schemaVersion": 1, "status": "field-inventory-and-smoke-corpus-not-ai-qualification", "engineRevision": "a256f6896d11134fdc351786f1be5eba4e56b2e2", - "knowledgeSha256": "07f55aadc052c9f71c6193b0ff3a7c4d4eb8e4318d826b1ad2afcec0c6ba2ba1", + "knowledgeSha256": "af1405ba326c49210f87d1f11c1c44538f88d3f6a83c935028aba2f9eb72f62f", "sourceDigests": { - "scripts/assistant-scenarios.ts": "c3ea7ac63ab6213134058af25cdf1227ba727135c9ee03336bf5d65ea872e859", + "scripts/assistant-scenarios.ts": "5c9797f284be6801385bfef6b9e1062eed69c76ff4a0058f367564d9ae014757", "scripts/assistant-tests.ts": "5e19b1c74da1ffa996f2d76876cc8dc639da698d0b6f9b7e1fc6637863833a8d" }, - "suiteSha256": "24385cceda47a61f9b6e02579eccb1aa4917e8810c7c288e6411357f8065ddec", + "suiteSha256": "dd9d18bf0fb1dd9ab4423f2501acfc214a52d085e9d9b1390eda23c1c5ee20e6", "topics": [ { "id": "credential-foundations", @@ -5446,6 +5446,20 @@ "no_premature_saved_claim" ] }, + { + "id": "application-access-public-kubernetes-incomplete-owner", + "topic": "project-templates", + "fixture": "corporate-ipv4", + "applicationAccess": "public-kubernetes-incomplete-owner", + "message": "Ich will das man bei der Bestellung einer Public Landing Zone auch Rechte bekommt einen Kubernetes Cluster bestellen und nutzen zu können", + "assertions": [ + "valid_dialogue", + "reviewed_topic_reference", + "compiler_accepts_proposal", + "explicit_unsaved_notice", + "no_premature_saved_claim" + ] + }, { "id": "application-access-public-kubernetes-followup", "topic": "project-templates", diff --git a/landing-zone-configurator/docs/plan-execution.de.md b/landing-zone-configurator/docs/plan-execution.de.md index df17460..09ab0d0 100644 --- a/landing-zone-configurator/docs/plan-execution.de.md +++ b/landing-zone-configurator/docs/plan-execution.de.md @@ -964,3 +964,30 @@ verfolgt diese Abnahme. Lokale Zwischen-State-Persistenz bleibt [Issue #140](https://github.com/stackitcloud/stackit-landing-zone/issues/140). Lokale Recovery-Import-Serials werden vor Freigabe auf genau einen entfernten Nachfolger (`Original + 1`) normalisiert. Der Server weist überhöhte Kandidaten zurück. Ein nativer OpenTofu-Test schiebt beim Sperrerwerb einen konkurrierenden State mit gleicher/höherer Serial ein und weist nach, dass er nicht überschrieben wird. + +## Aufbewahrung der CF-Ausführungs-Apps + +Abgeschlossene Plattform-Runner werden frühestens 30 Sekunden nach dem gespeicherten +Abschluss beim nächsten 30-Sekunden-Wartungslauf gelöscht. Der Warm-Pool wird alle +fünf Sekunden gepflegt; eindeutig abgeschlossene Application-Runner werden dort +verworfen und bei Bedarf ersetzt. Das betrifft CF-Apps, nicht Jobhistorie oder State. + +Zusätzlich prüft die API jede Minute höchstens 50 Apps im konfigurierten Runner-Space +und setzt den nächsten Lauf auf der folgenden Seite fort. Gestoppte `lzc-plan-` +und `lzc-warm-` können ab einem App-Alter von 24 Stunden bereinigt werden: +Die Datenbank muss einen abgeschlossenen, abgelaufenen Job oder einen ausgemusterten +Slot bestätigen. Laufende/ungeklärte Jobs, aktive Recovery und gültige Tickets schützen +die App. Ein abgeglichener Application-Abbruch benötigt einen gespeicherten +Recovery-Neuplan-Nachweis. Unbekannte Apps werden nur mit der bei Neuanlage gesetzten +Herkunftsmarkierung dieser Configurator-Origin gelöscht. Unmarkierte, nicht zuordenbare +Altbestände brauchen einen Operator-Abgleich; Namen allein belegen keine Zugehörigkeit. + +Unmittelbar vor DELETE werden Identität/Space, gestoppter Zustand, fehlende Routes und +Service-Bindings, ausschließlich beendete Tasks/Prozesse und die Datenbankfreigabe +erneut geprüft. Unvollständige oder nicht erreichbare Prüfungen verhindern die Löschung. +Erfolg erzeugt einen separaten Bereinigungsnachweis. Vorlage und Recovery-Apps werden +von diesem Lauf nicht gelöscht. Fehler erscheinen ohne Zugangsdaten als +`runner_retention_failed` im API-Log; der nächste Lauf versucht die Bereinigung erneut. + +Die Log-Zusammenfassung `runner_retention` enthält nur Anzahlen geprüfter, gelöschter, +geschützter und nicht zuordenbarer Apps. So werden ungeklärte Altbestände sichtbar. diff --git a/landing-zone-configurator/docs/plan-execution.md b/landing-zone-configurator/docs/plan-execution.md index 1b796aa..3a3d051 100644 --- a/landing-zone-configurator/docs/plan-execution.md +++ b/landing-zone-configurator/docs/plan-execution.md @@ -450,3 +450,28 @@ closed. The original approving Platform Owner retains access. [Issue #157](https://github.com/stackitcloud/stackit-landing-zone/issues/157) tracks that acceptance. Local-backend intermediate-State persistence remains [Issue #140](https://github.com/stackitcloud/stackit-landing-zone/issues/140). + +## CF execution app retention + +Completed platform runners are deleted no earlier than 30 seconds after durable +completion by the next 30-second maintenance pass. The warm pool is maintained every +five seconds; exactly bound completed Application runners are retired and replenished +as needed. These deletions concern CF apps, not job history or infrastructure State. + +A supplementary API sweep checks at most 50 apps in the configured Runner space each +minute and continues from the next page on the following pass. Stopped +`lzc-plan-` and `lzc-warm-` apps become eligible at an app age of 24 hours. +The database must confirm a finished, expired job or retired slot. Active/uncertain +jobs, active recovery and valid tickets protect the app. A reconciled Application +interruption needs a recorded recovery-replan clearance. Unassociated apps are deleted +only with the ownership label set at creation for this Configurator origin. Unlabelled, +unassociated legacy apps require operator reconciliation; a name alone is not ownership. + +Immediately before DELETE, the sweeper rechecks exact identity/space, stopped state, +absence of routes/service bindings, terminal tasks/processes and database eligibility. +Incomplete or unavailable checks prevent deletion. Success creates a separate cleanup +receipt. The template and recovery apps are excluded. Failures log the non-secret +`runner_retention_failed` event; subsequent passes retry cleanup. + +The `runner_retention` log summary contains only scanned, deleted, protected and +unassociated counts, allowing operators to detect unresolved legacy accumulation.