From 0728bff17fd68fd7bf216e7ce9f348d4fdc49a1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Weberru=C3=9F?= Date: Wed, 28 Oct 2026 15:37:01 +0000 Subject: [PATCH 1/2] feat(applications): preserve pinned execution and guide source upgrades --- .../079_application_execution_lifecycle.sql | 289 ++++++ .../src/applications/execution-lifecycle.ts | 77 ++ .../app/apps/api/src/applications/routes.ts | 86 +- .../app/apps/api/src/applications/service.ts | 167 ++- .../api/src/applications/state-recovery.ts | 18 +- .../assistant/knowledge-topics.generated.ts | 22 +- .../apps/api/src/assistant/model-serving.ts | 24 +- .../app/apps/api/src/plans/cloud-foundry.ts | 2 + .../app/apps/api/src/plans/local.ts | 44 +- .../ApplicationExecutionLifecycle.tsx | 398 ++++++++ .../apps/web/src/components/Applications.tsx | 29 +- .../app/apps/web/src/locales/en.ts | 58 +- .../app/apps/worker/src/runner.ts | 23 +- .../apps/worker/src/state-recovery-native.ts | 15 +- .../application-execution-lifecycle.spec.ts | 488 +++++++++ landing-zone-configurator/app/e2e/fixtures.ts | 10 + .../application-execution-lifecycle.test.ts | 952 ++++++++++++++++++ .../app/integration/identity.test.ts | 8 +- .../contracts/src/application-execution.ts | 135 +++ .../app/packages/contracts/src/index.ts | 1 + .../app/packages/contracts/src/plan.ts | 14 +- .../src/recovery/application-execution.ts | 78 ++ .../packages/contracts/src/recovery/index.ts | 1 + .../contracts/src/recovery/inventory.ts | 33 +- .../app/scripts/assistant-scenarios.ts | 120 +++ .../scripts/package-application-execution.mjs | 38 + .../prepare-application-project-contract.ts | 62 +- .../test-application-execution-sources.sh | 38 + .../application-execution-lifecycle.test.ts | 299 ++++++ .../app/tests/application-execution.test.ts | 174 ++++ .../tests/application-recovery-native.test.ts | 42 + .../application-state-recovery-api.test.ts | 19 + ...plication-state-recovery-inventory.test.ts | 175 ++++ .../app/tests/assistant-scenarios.test.ts | 5 +- .../app/tests/assistant.test.ts | 42 + .../app/tests/local-runner.test.ts | 56 ++ .../app/tests/platform-worker.test.ts | 79 ++ .../app/tests/runner-packaging.test.ts | 66 ++ .../app/tests/runner-pool.test.ts | 26 + .../deploy/runner/package.sh | 21 +- .../application-execution-lifecycle.de.md | 109 ++ .../docs/application-execution-lifecycle.md | 97 ++ .../configuration-lifecycle.de.md | 26 + .../configuration-lifecycle.md | 85 +- .../docs/assistant-knowledge/manifest.json | 8 +- .../docs/assistant-test-catalogue.json | 19 +- .../docs/development.de.md | 2 +- landing-zone-configurator/docs/development.md | 2 +- 48 files changed, 4428 insertions(+), 154 deletions(-) create mode 100644 landing-zone-configurator/app/apps/api/db/079_application_execution_lifecycle.sql create mode 100644 landing-zone-configurator/app/apps/api/src/applications/execution-lifecycle.ts create mode 100644 landing-zone-configurator/app/apps/web/src/components/ApplicationExecutionLifecycle.tsx create mode 100644 landing-zone-configurator/app/e2e/application-execution-lifecycle.spec.ts create mode 100644 landing-zone-configurator/app/integration/application-execution-lifecycle.test.ts create mode 100644 landing-zone-configurator/app/packages/contracts/src/application-execution.ts create mode 100644 landing-zone-configurator/app/packages/contracts/src/recovery/application-execution.ts create mode 100644 landing-zone-configurator/app/scripts/package-application-execution.mjs create mode 100644 landing-zone-configurator/app/scripts/test-application-execution-sources.sh create mode 100644 landing-zone-configurator/app/tests/application-execution-lifecycle.test.ts create mode 100644 landing-zone-configurator/app/tests/application-execution.test.ts create mode 100644 landing-zone-configurator/docs/application-execution-lifecycle.de.md create mode 100644 landing-zone-configurator/docs/application-execution-lifecycle.md diff --git a/landing-zone-configurator/app/apps/api/db/079_application_execution_lifecycle.sql b/landing-zone-configurator/app/apps/api/db/079_application_execution_lifecycle.sql new file mode 100644 index 00000000..84436ccf --- /dev/null +++ b/landing-zone-configurator/app/apps/api/db/079_application_execution_lifecycle.sql @@ -0,0 +1,289 @@ +-- Reviewed IaC sources are independent of the patched Worker/application package. +-- Existing publications, orders, jobs, grants and State identities are not rewritten. +CREATE TABLE lzc.application_execution_sources ( + revision text PRIMARY KEY CHECK(revision ~ '^[0-9a-f]{40}$'), + provider_lock_sha256 text NOT NULL CHECK(provider_lock_sha256 ~ '^[0-9a-f]{64}$'), + tofu_version text NOT NULL, + contract_version integer NOT NULL CHECK(contract_version=1), + status text NOT NULL CHECK(status IN ('supported','withdrawn')), + replacement_revision text REFERENCES lzc.application_execution_sources(revision), + CHECK(replacement_revision IS DISTINCT FROM revision) +); +ALTER TABLE lzc.application_execution_sources ENABLE ROW LEVEL SECURITY; +ALTER TABLE lzc.application_execution_sources FORCE ROW LEVEL SECURITY; +CREATE POLICY execution_sources_migration ON lzc.application_execution_sources TO configurator_migration USING(true) WITH CHECK(true); +INSERT INTO lzc.application_execution_sources(revision,provider_lock_sha256,tofu_version,contract_version,status) + SELECT source,'d40debbff204aee590c2a76d09f6ad3234643329b438fd5c6497de60687f6fa5','1.12.6',1, + CASE WHEN source='4d15d7870afa323badd93559d8b37c5a8d138dcf' THEN 'withdrawn' ELSE 'supported' END + FROM unnest(ARRAY['4d15d7870afa323badd93559d8b37c5a8d138dcf','c4b43c36af198985980b17626c48d357795e3fbd','88149782bf8e91dcdbb43a203b54337886023f7f','57ad1f6a651c1787694b74ff8aa8b241a3dcd16f','9e7fb46c329aca32e878ce1f0ea3b944ea240354','1ed855609d4dcbfded6759c7c8b4fabf7bd6725d','08ac257de1548c8ec5e83dae1a79373711c3d644']) source; +UPDATE lzc.application_execution_sources SET replacement_revision='08ac257de1548c8ec5e83dae1a79373711c3d644' WHERE revision<>'08ac257de1548c8ec5e83dae1a79373711c3d644'; + +CREATE TABLE lzc.application_execution_bindings ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id uuid NOT NULL REFERENCES lzc.tenants(id), + instance_id uuid NOT NULL, + sequence integer NOT NULL CHECK(sequence>0), + previous_binding_id uuid REFERENCES lzc.application_execution_bindings(id), + previous_revision text NOT NULL, + revision text NOT NULL REFERENCES lzc.application_execution_sources(revision), + requested_by uuid NOT NULL REFERENCES lzc_auth.users(id), + issuer_session_id uuid NOT NULL, + created_at timestamptz NOT NULL DEFAULT clock_timestamp(), + FOREIGN KEY(tenant_id,instance_id) REFERENCES lzc.application_instances(tenant_id,id), + UNIQUE(instance_id,sequence), UNIQUE(tenant_id,id) +); +ALTER TABLE lzc.application_execution_bindings ENABLE ROW LEVEL SECURITY; +ALTER TABLE lzc.application_execution_bindings FORCE ROW LEVEL SECURITY; +CREATE POLICY execution_bindings_read ON lzc.application_execution_bindings FOR SELECT TO configurator_app + USING(tenant_id=lzc.current_tenant_id() AND EXISTS(SELECT 1 FROM lzc.application_instances i WHERE i.id=instance_id)); +CREATE POLICY execution_bindings_migration ON lzc.application_execution_bindings TO configurator_migration USING(true) WITH CHECK(true); +CREATE TRIGGER immutable_application_execution_binding BEFORE UPDATE OR DELETE ON lzc.application_execution_bindings + FOR EACH ROW EXECUTE FUNCTION lzc.immutable_application_version(); +GRANT SELECT ON lzc.application_execution_bindings TO configurator_app; + +CREATE FUNCTION lzc_auth.effective_application_revision(p_instance uuid) RETURNS text +LANGUAGE sql SECURITY DEFINER SET search_path=pg_catalog AS $$ + SELECT coalesce((SELECT b.revision FROM lzc.application_execution_bindings b WHERE b.instance_id=i.id ORDER BY sequence DESC LIMIT 1),v.accelerator_revision) + FROM lzc.application_instances i JOIN lzc.application_template_versions v ON v.id=i.version_id AND v.tenant_id=i.tenant_id WHERE i.id=p_instance; +$$; +REVOKE ALL ON FUNCTION lzc_auth.effective_application_revision(uuid) FROM PUBLIC; + +CREATE FUNCTION lzc_auth.application_execution_blockers(p_instance uuid) RETURNS jsonb +LANGUAGE sql SECURITY DEFINER SET search_path=pg_catalog AS $$ + SELECT to_jsonb(array_remove(ARRAY[ + CASE WHEN EXISTS(SELECT 1 FROM lzc.application_jobs j JOIN lzc.application_dispatches d ON d.job_id=j.id WHERE j.instance_id=p_instance + AND (d.status NOT IN ('succeeded','failed','cancelled','reconciliation_required') OR (d.status='reconciliation_required' AND NOT lzc_auth.application_recovery_replan_valid(j.id)))) THEN 'execution-or-recovery-active' END, + CASE WHEN EXISTS(SELECT 1 FROM lzc.application_jobs j JOIN lzc.application_runner_tickets t ON t.job_id=j.id WHERE j.instance_id=p_instance AND t.expires_at>now()) THEN 'runner-ticket-active' END, + CASE WHEN EXISTS(SELECT 1 FROM lzc.application_state_recoveries r WHERE r.instance_id=p_instance AND (r.phase IN ('requested','assessing','prepared','committing','reconciliation-required') OR EXISTS(SELECT 1 FROM lzc.application_state_recovery_tasks t WHERE t.recovery_id=r.id AND t.expires_at>now() AND t.finished_at IS NULL))) THEN 'guided-recovery-active' END, + CASE WHEN EXISTS(SELECT 1 FROM lzc.application_jobs j JOIN lzc.application_job_grants g ON g.job_id=j.id WHERE j.instance_id=p_instance AND j.operation='apply' AND g.revoked_at IS NULL AND g.expires_at>now() + AND NOT EXISTS(SELECT 1 FROM lzc.application_dispatches d WHERE d.job_id=j.id AND d.finished_at IS NOT NULL AND d.status IN ('succeeded','failed'))) THEN 'apply-grant-active' END + ,CASE WHEN EXISTS(SELECT 1 FROM lzc.application_jobs j JOIN lzc.application_job_claims c ON c.job_id=j.id WHERE j.instance_id=p_instance AND c.expires_at>now() AND NOT EXISTS(SELECT 1 FROM lzc.application_dispatches d WHERE d.job_id=j.id AND d.finished_at IS NOT NULL AND d.status IN ('succeeded','failed','cancelled'))) THEN 'execution-claim-active' END + ],NULL)); +$$; +REVOKE ALL ON FUNCTION lzc_auth.application_execution_blockers(uuid) FROM PUBLIC; + +CREATE FUNCTION lzc_auth.application_execution_context(p_session uuid,p_instance uuid) RETURNS jsonb +LANGUAGE plpgsql SECURITY DEFINER SET search_path=pg_catalog AS $$ +DECLARE ordered lzc.application_instances; source lzc.application_execution_sources; binding lzc.application_execution_bindings; origin_revision text; blockers jsonb; +BEGIN + PERFORM lzc_auth.require_active_application_order(p_session,p_instance); + SELECT * INTO ordered FROM lzc.application_instances WHERE id=p_instance AND tenant_id=lzc.current_tenant_id(); + IF NOT lzc_auth.application_template_group_access(ordered.tenant_id,ordered.requested_by,ordered.version_id) THEN RAISE EXCEPTION 'application_upgrade_access_denied' USING ERRCODE='42501'; END IF; + SELECT accelerator_revision INTO origin_revision FROM lzc.application_template_versions WHERE id=ordered.version_id AND tenant_id=ordered.tenant_id; + SELECT * INTO binding FROM lzc.application_execution_bindings WHERE instance_id=p_instance ORDER BY sequence DESC LIMIT 1; + SELECT * INTO source FROM lzc.application_execution_sources WHERE revision=coalesce(binding.revision,origin_revision); + blockers:=lzc_auth.application_execution_blockers(p_instance); + RETURN jsonb_build_object('revision',coalesce(binding.revision,origin_revision),'bindingId',binding.id,'originRevision',origin_revision, + 'sourceStatus',coalesce(source.status,'withdrawn'),'replacementRevision',source.replacement_revision,'providerLockSha256',source.provider_lock_sha256, + 'tofuVersion',source.tofu_version,'contractVersion',source.contract_version,'blockers',blockers, + 'canUpgrade',ordered.requested_by=lzc.current_user_id() AND source.replacement_revision IS NOT NULL AND jsonb_array_length(blockers)=0 AND EXISTS(SELECT 1 FROM lzc.application_execution_sources next WHERE next.revision=source.replacement_revision AND next.status='supported'), + 'lastAppliedRevision',(SELECT g.accelerator_revision FROM lzc.application_jobs j JOIN lzc.application_dispatches d ON d.job_id=j.id JOIN lzc.application_job_grants g ON g.job_id=j.id + WHERE j.instance_id=p_instance AND j.operation='apply' AND d.status='succeeded' AND coalesce(j.inputs->>'purpose','standard')<>'destroy' ORDER BY d.finished_at DESC,j.id DESC LIMIT 1)); +END $$; +REVOKE ALL ON FUNCTION lzc_auth.application_execution_context(uuid,uuid) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION lzc_auth.application_execution_context(uuid,uuid) TO configurator_app; + +CREATE FUNCTION lzc_auth.upgrade_application_execution(p_session uuid,p_instance uuid,p_target text,p_expected_revision text,p_confirm boolean) RETURNS jsonb +LANGUAGE plpgsql SECURITY DEFINER SET search_path=pg_catalog AS $$ +DECLARE ordered lzc.application_instances; current_binding lzc.application_execution_bindings; current_source lzc.application_execution_sources; current_revision text; +BEGIN + IF p_confirm IS DISTINCT FROM true THEN RAISE EXCEPTION 'application_upgrade_confirmation_required' USING ERRCODE='42501'; END IF; + PERFORM lzc_auth.authorize_application(p_session,lzc.current_tenant_id(),'order'); + PERFORM lzc_auth.require_active_application_order(p_session,p_instance); + SELECT * INTO ordered FROM lzc.application_instances WHERE id=p_instance AND tenant_id=lzc.current_tenant_id() FOR UPDATE; + IF ordered.requested_by<>lzc.current_user_id() OR NOT lzc_auth.application_template_group_access(ordered.tenant_id,ordered.requested_by,ordered.version_id) THEN + RAISE EXCEPTION 'application_upgrade_access_denied' USING ERRCODE='42501'; END IF; + SELECT * INTO current_binding FROM lzc.application_execution_bindings WHERE instance_id=p_instance ORDER BY sequence DESC LIMIT 1; + current_revision:=lzc_auth.effective_application_revision(p_instance); + IF current_revision=p_target AND current_binding.previous_revision=p_expected_revision THEN RETURN lzc_auth.application_execution_context(p_session,p_instance); END IF; + IF current_revision IS DISTINCT FROM p_expected_revision THEN RAISE EXCEPTION 'application_execution_changed' USING ERRCODE='40001'; END IF; + SELECT * INTO current_source FROM lzc.application_execution_sources WHERE revision=current_revision; + IF current_source.replacement_revision IS DISTINCT FROM p_target OR NOT EXISTS(SELECT 1 FROM lzc.application_execution_sources WHERE revision=p_target AND status='supported') THEN + RAISE EXCEPTION 'application_upgrade_source_unavailable' USING ERRCODE='42501'; END IF; + IF jsonb_array_length(lzc_auth.application_execution_blockers(p_instance))<>0 THEN RAISE EXCEPTION 'application_upgrade_execution_active' USING ERRCODE='40001'; END IF; + -- Revocation is the sole allowed mutation of immutable old grants. It is not + -- an update of prior job inputs, template policy, State or an Apply approval. + UPDATE lzc.application_job_grants g SET revoked_at=coalesce(g.revoked_at,now()) FROM lzc.application_jobs j WHERE j.id=g.job_id AND j.instance_id=p_instance AND j.operation='plan' AND g.revoked_at IS NULL; + INSERT INTO lzc.application_execution_bindings(tenant_id,instance_id,sequence,previous_binding_id,previous_revision,revision,requested_by,issuer_session_id) + VALUES(ordered.tenant_id,p_instance,coalesce(current_binding.sequence,0)+1,current_binding.id,current_revision,p_target,lzc.current_user_id(),p_session); + RETURN lzc_auth.application_execution_context(p_session,p_instance); +END $$; +REVOKE ALL ON FUNCTION lzc_auth.upgrade_application_execution(uuid,uuid,text,text,boolean) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION lzc_auth.upgrade_application_execution(uuid,uuid,text,text,boolean) TO configurator_app; + +-- Historical sources before project-scoped bucket names used the legacy +-- default. An execution upgrade must preserve those existing resource names; +-- new orders on newer sources keep their native project-id default. +CREATE FUNCTION lzc_auth.application_execution_compatibility_valid(p_instance uuid,p_inputs jsonb) RETURNS boolean +LANGUAGE sql SECURITY DEFINER SET search_path=pg_catalog AS $$ + SELECT CASE WHEN v.accelerator_revision IN ('4d15d7870afa323badd93559d8b37c5a8d138dcf','c4b43c36af198985980b17626c48d357795e3fbd','88149782bf8e91dcdbb43a203b54337886023f7f','57ad1f6a651c1787694b74ff8aa8b241a3dcd16f') + AND p_inputs->>'acceleratorRevision' IN ('9e7fb46c329aca32e878ce1f0ea3b944ea240354','1ed855609d4dcbfded6759c7c8b4fabf7bd6725d','08ac257de1548c8ec5e83dae1a79373711c3d644') + THEN coalesce(p_inputs->'variables'->>'bucket_naming'='legacy',false) ELSE true END + FROM lzc.application_instances i JOIN lzc.application_template_versions v ON v.id=i.version_id AND v.tenant_id=i.tenant_id WHERE i.id=p_instance; +$$; +REVOKE ALL ON FUNCTION lzc_auth.application_execution_compatibility_valid(uuid,jsonb) FROM PUBLIC; + +-- The same instance lock serializes source changes, admission and dispatch. +CREATE FUNCTION lzc_auth.assert_application_execution_binding(p_job uuid) RETURNS void +LANGUAGE plpgsql SECURITY DEFINER SET search_path=pg_catalog AS $$ +DECLARE job lzc.application_jobs; binding lzc.application_execution_bindings; +BEGIN + SELECT * INTO job FROM lzc.application_jobs WHERE id=p_job; + IF NOT FOUND THEN RAISE EXCEPTION 'application_execution_binding_unavailable' USING ERRCODE='42501'; END IF; + PERFORM 1 FROM lzc.application_instances WHERE id=job.instance_id FOR UPDATE; + SELECT * INTO binding FROM lzc.application_execution_bindings WHERE instance_id=job.instance_id ORDER BY sequence DESC LIMIT 1; + IF job.inputs->>'acceleratorRevision' IS DISTINCT FROM lzc_auth.effective_application_revision(job.instance_id) + OR job.inputs->>'executionBindingId' IS DISTINCT FROM binding.id::text + OR lzc_auth.application_execution_compatibility_valid(job.instance_id,job.inputs) IS DISTINCT FROM true + OR NOT EXISTS(SELECT 1 FROM lzc.application_execution_sources WHERE revision=job.inputs->>'acceleratorRevision' AND status='supported') THEN + RAISE EXCEPTION 'application_execution_binding_changed' USING ERRCODE='42501'; END IF; +END $$; +REVOKE ALL ON FUNCTION lzc_auth.assert_application_execution_binding(uuid) FROM PUBLIC; + +CREATE FUNCTION lzc.bind_application_execution_source() RETURNS trigger +LANGUAGE plpgsql SECURITY DEFINER SET search_path=pg_catalog AS $$ +DECLARE binding lzc.application_execution_bindings; +BEGIN + PERFORM 1 FROM lzc.application_instances WHERE id=NEW.instance_id FOR UPDATE; + SELECT * INTO binding FROM lzc.application_execution_bindings WHERE instance_id=NEW.instance_id ORDER BY sequence DESC LIMIT 1; + IF NEW.inputs->>'acceleratorRevision' IS DISTINCT FROM lzc_auth.effective_application_revision(NEW.instance_id) + OR NEW.inputs->>'executionBindingId' IS DISTINCT FROM binding.id::text + OR lzc_auth.application_execution_compatibility_valid(NEW.instance_id,NEW.inputs) IS DISTINCT FROM true + OR NOT EXISTS(SELECT 1 FROM lzc.application_execution_sources WHERE revision=NEW.inputs->>'acceleratorRevision' AND status='supported') THEN + RAISE EXCEPTION 'application_execution_binding_changed' USING ERRCODE='42501'; END IF; + RETURN NEW; +END $$; +REVOKE ALL ON FUNCTION lzc.bind_application_execution_source() FROM PUBLIC; +CREATE TRIGGER application_source_execution BEFORE INSERT ON lzc.application_jobs FOR EACH ROW EXECUTE FUNCTION lzc.bind_application_execution_source(); + +-- Preserve the established owner/org/profile/backend/delegation checks. Only the +-- source derivation now resolves the desired immutable per-instance binding. +DO $$ DECLARE definition text; +BEGIN + definition:=pg_get_functiondef('lzc.issue_application_job_grant()'::regprocedure); + IF strpos(definition,'v.accelerator_revision')=0 THEN RAISE EXCEPTION 'application_source_guard_missing'; END IF; + EXECUTE replace(definition,'v.accelerator_revision','lzc_auth.effective_application_revision(i.id)'); +END $$; + +CREATE FUNCTION lzc.guard_application_execution_material() RETURNS trigger +LANGUAGE plpgsql SECURITY DEFINER SET search_path=pg_catalog AS $$ +BEGIN + IF TG_OP='INSERT' THEN + PERFORM lzc_auth.assert_application_execution_binding(NEW.job_id); + ELSIF TG_TABLE_NAME='application_runner_tickets' THEN + IF OLD.consumed_at IS NULL AND NEW.consumed_at IS NOT NULL THEN PERFORM lzc_auth.assert_application_execution_binding(NEW.job_id); END IF; + END IF; + RETURN NEW; +END $$; +REVOKE ALL ON FUNCTION lzc.guard_application_execution_material() FROM PUBLIC; +CREATE TRIGGER execution_source_material BEFORE INSERT ON lzc.application_runner_tickets FOR EACH ROW EXECUTE FUNCTION lzc.guard_application_execution_material(); +CREATE TRIGGER execution_source_consume BEFORE UPDATE OF consumed_at ON lzc.application_runner_tickets FOR EACH ROW EXECUTE FUNCTION lzc.guard_application_execution_material(); +CREATE TRIGGER execution_source_material BEFORE INSERT ON lzc.application_dispatches FOR EACH ROW EXECUTE FUNCTION lzc.guard_application_execution_material(); +CREATE TRIGGER execution_source_material BEFORE INSERT ON lzc.application_job_claims FOR EACH ROW EXECUTE FUNCTION lzc.guard_application_execution_material(); +CREATE TRIGGER execution_source_material BEFORE INSERT ON lzc.application_job_backends FOR EACH ROW EXECUTE FUNCTION lzc.guard_application_execution_material(); + +-- Existing direct and delegated credential/input functions must fail closed on +-- stale/withdrawn bindings too. Reports retain the original capability rules. +DO $$ DECLARE signature text; definition text; +BEGIN + FOREACH signature IN ARRAY ARRAY['lzc_auth.application_job_credential_context(uuid,uuid)','lzc_auth.application_runner_input_context(uuid,uuid)','lzc_auth.claim_application_job_grant(uuid,uuid)','lzc_auth.reserve_application_dispatch(uuid,uuid)','lzc_auth.approve_application_job_backend(uuid,uuid,uuid)','lzc_auth.issue_application_runner_ticket(uuid,uuid,text,uuid,text,text)','lzc_auth.consume_application_runner_ticket(uuid,uuid,text,uuid,text,text)','lzc_auth.bind_application_dispatch(uuid,uuid,uuid,uuid)','lzc_auth.bind_application_job_execution(uuid,uuid)'] LOOP + definition:=pg_get_functiondef(signature::regprocedure); + IF strpos(definition,E'BEGIN\n')=0 THEN RAISE EXCEPTION 'application_execution_guard_missing'; END IF; + EXECUTE replace(definition,E'BEGIN\n',E'BEGIN\n PERFORM lzc_auth.assert_application_execution_binding(p_job);\n'); + END LOOP; + -- Reports use the original source, but their context still takes the instance + -- lock before a grant row lock, matching upgrade/material lock ordering. + FOREACH signature IN ARRAY ARRAY['lzc_auth.delegated_application_context(uuid,uuid)','lzc_auth.delegated_application_plan_context(uuid,uuid)'] LOOP + definition:=pg_get_functiondef(signature::regprocedure); + IF strpos(definition,E'BEGIN\n')=0 THEN RAISE EXCEPTION 'application_execution_lock_guard_missing'; END IF; + EXECUTE replace(definition,E'BEGIN\n',E'BEGIN\n PERFORM 1 FROM lzc.application_instances i JOIN lzc.application_jobs j ON j.instance_id=i.id WHERE j.id=p_job FOR UPDATE OF i;\n'); + END LOOP; + definition:=pg_get_functiondef('lzc_auth.delegated_application_plan_operation(uuid,uuid,text,jsonb)'::regprocedure); + IF strpos(definition,'context:=lzc_auth.delegated_application_context(p_session,p_job);')=0 THEN RAISE EXCEPTION 'application_execution_guard_missing'; END IF; + EXECUTE replace(definition,'context:=lzc_auth.delegated_application_context(p_session,p_job);',E'IF p_operation NOT IN (''stage'',''record'',''artifact'',''output'',''result'',''recovery'',''fail'') THEN PERFORM lzc_auth.assert_application_execution_binding(p_job); END IF;\n context:=lzc_auth.delegated_application_context(p_session,p_job);'); +END $$; + +-- Resolve source from an already bound capability, never a client-selected hash. +CREATE FUNCTION lzc_auth.application_runner_source(p_hash text,p_package uuid,p_lock text) RETURNS text +LANGUAGE plpgsql SECURITY DEFINER SET search_path=pg_catalog AS $$ +DECLARE source text; +BEGIN + SELECT t.accelerator_revision INTO source FROM lzc.application_runner_tickets t + JOIN lzc.application_execution_sources s ON s.revision=t.accelerator_revision AND s.provider_lock_sha256=t.provider_lock_sha256 + WHERE t.ticket_hash=p_hash AND t.runner_package_id=p_package AND t.provider_lock_sha256=p_lock AND ( + EXISTS(SELECT 1 FROM lzc_auth.resolve_delegated_application_runner(p_hash,p_package,t.accelerator_revision,p_lock,false)) OR + EXISTS(SELECT 1 FROM lzc_auth.resolve_delegated_application_runner(p_hash,p_package,t.accelerator_revision,p_lock,true)) OR + EXISTS(SELECT 1 FROM lzc_auth.resolve_application_runner_ticket(p_hash,p_package,t.accelerator_revision,p_lock)) OR + EXISTS(SELECT 1 FROM lzc_auth.resolve_application_runner_report(p_hash,p_package,t.accelerator_revision,p_lock))); + IF source IS NOT NULL THEN RETURN source; END IF; + SELECT g.accelerator_revision INTO source FROM lzc.application_state_recovery_tasks t + JOIN lzc.application_state_recoveries r ON r.id=t.recovery_id JOIN lzc.application_job_grants g ON g.job_id=r.job_id + JOIN lzc.application_execution_sources s ON s.revision=g.accelerator_revision AND s.provider_lock_sha256=p_lock + WHERE t.ticket_hash=p_hash AND t.runner_package_id=p_package AND EXISTS(SELECT 1 FROM lzc_auth.resolve_application_state_recovery_task(p_hash,p_package)); + IF source IS NULL THEN RAISE EXCEPTION 'invalid_application_runner_ticket' USING ERRCODE='42501'; END IF; + RETURN source; +END $$; +REVOKE ALL ON FUNCTION lzc_auth.application_runner_source(text,uuid,text) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION lzc_auth.application_runner_source(text,uuid,text) TO configurator_app; + +-- Product releases may change the configured package while an older immutable +-- ticket is reporting. Only the opaque capability chooses its original package. +CREATE FUNCTION lzc_auth.application_runner_binding(p_hash text,p_lock text) RETURNS jsonb +LANGUAGE plpgsql SECURITY DEFINER SET search_path=pg_catalog AS $$ +DECLARE package uuid; source text; +BEGIN + SELECT runner_package_id INTO package FROM lzc.application_runner_tickets WHERE ticket_hash=p_hash AND provider_lock_sha256=p_lock; + IF package IS NULL THEN + SELECT runner_package_id INTO package FROM lzc.application_state_recovery_tasks WHERE ticket_hash=p_hash; + END IF; + IF package IS NULL THEN RAISE EXCEPTION 'invalid_application_runner_ticket' USING ERRCODE='42501'; END IF; + source:=lzc_auth.application_runner_source(p_hash,package,p_lock); + RETURN jsonb_build_object('runnerPackageId',package,'acceleratorRevision',source,'providerLockSha256',p_lock); +END $$; +REVOKE ALL ON FUNCTION lzc_auth.application_runner_binding(text,text) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION lzc_auth.application_runner_binding(text,text) TO configurator_app; + +-- Recovery deliberately uses the failed job's original source. Withdrawal also +-- fences this separate material channel; it never authorizes a newer source to +-- rewrite an unresolved checkpoint. Existing task reports remain resolvable. +CREATE FUNCTION lzc_auth.assert_application_recovery_source(p_recovery uuid) RETURNS void +LANGUAGE plpgsql SECURITY DEFINER SET search_path=pg_catalog AS $$ +DECLARE source text; status text; +BEGIN + PERFORM 1 FROM lzc.application_instances i JOIN lzc.application_state_recoveries r ON r.instance_id=i.id WHERE r.id=p_recovery FOR UPDATE OF i; + SELECT g.accelerator_revision INTO source FROM lzc.application_state_recoveries r JOIN lzc.application_job_grants g ON g.job_id=r.job_id WHERE r.id=p_recovery; + SELECT s.status INTO status FROM lzc.application_execution_sources s WHERE s.revision=source FOR SHARE; + IF status IS DISTINCT FROM 'supported' THEN RAISE EXCEPTION 'application_recovery_source_unavailable' USING ERRCODE='42501'; END IF; +END $$; +REVOKE ALL ON FUNCTION lzc_auth.assert_application_recovery_source(uuid) FROM PUBLIC; + +CREATE FUNCTION lzc.guard_application_recovery_source() RETURNS trigger +LANGUAGE plpgsql SECURITY DEFINER SET search_path=pg_catalog AS $$ +BEGIN + IF TG_OP='INSERT' THEN + PERFORM lzc_auth.assert_application_recovery_source(NEW.recovery_id); + ELSIF (OLD.input_released_at IS NULL AND NEW.input_released_at IS NOT NULL) OR + (OLD.runner_app_id IS NULL AND NEW.runner_app_id IS NOT NULL) THEN + PERFORM lzc_auth.assert_application_recovery_source(NEW.recovery_id); + END IF; + RETURN NEW; +END $$; +REVOKE ALL ON FUNCTION lzc.guard_application_recovery_source() FROM PUBLIC; +CREATE TRIGGER execution_source_recovery_admission BEFORE INSERT ON lzc.application_state_recovery_tasks FOR EACH ROW EXECUTE FUNCTION lzc.guard_application_recovery_source(); +CREATE TRIGGER execution_source_recovery_material BEFORE UPDATE OF input_released_at,runner_app_id ON lzc.application_state_recovery_tasks FOR EACH ROW EXECUTE FUNCTION lzc.guard_application_recovery_source(); + +DO $$ DECLARE signature text; definition text; guard text; +BEGIN + FOREACH signature IN ARRAY ARRAY[ + 'lzc_auth.begin_application_state_recovery_task(uuid,uuid,uuid,text,text,bytea,jsonb)', + 'lzc_auth.bind_application_state_recovery_task(uuid,uuid,uuid,uuid)', + 'lzc_auth.release_application_state_recovery_input(uuid,uuid)', + 'lzc_auth.fence_application_state_recovery(uuid,uuid,text)' + ] LOOP + definition:=pg_get_functiondef(signature::regprocedure); + IF strpos(definition,E'BEGIN\n')=0 THEN RAISE EXCEPTION 'application_recovery_source_guard_missing'; END IF; + guard:=CASE WHEN signature LIKE '%begin_application%' THEN 'PERFORM lzc_auth.assert_application_recovery_source(p_id);' + ELSE 'PERFORM lzc_auth.assert_application_recovery_source((SELECT recovery_id FROM lzc.application_state_recovery_tasks WHERE id=p_task));' END; + EXECUTE replace(definition,E'BEGIN\n',E'BEGIN\n '||guard||E'\n'); + END LOOP; +END $$; diff --git a/landing-zone-configurator/app/apps/api/src/applications/execution-lifecycle.ts b/landing-zone-configurator/app/apps/api/src/applications/execution-lifecycle.ts new file mode 100644 index 00000000..fe493720 --- /dev/null +++ b/landing-zone-configurator/app/apps/api/src/applications/execution-lifecycle.ts @@ -0,0 +1,77 @@ +import { applicationExecutionProfiles } from "@lzc/contracts"; +import type pg from "pg"; +import { z } from "zod"; +import type { Session } from "../auth/store.js"; +import type { PlanRunner } from "../plans/cloud-foundry.js"; + +const revision = z.string().regex(/^[a-f0-9]{40}$/); +export const applicationExecutionContextSchema = z.object({ + revision, + bindingId: z.uuid().nullable(), + lastAppliedRevision: revision.nullable(), + originRevision: revision, + sourceStatus: z.enum(["supported", "withdrawn"]), + replacementRevision: revision.nullable(), + canUpgrade: z.boolean(), + blockers: z.array(z.string().min(1).max(128)).max(30), +}); +export const applicationExecutionUpgradeSchema = z.strictObject({ + targetRevision: revision, + expectedRevision: revision, + confirmUpgrade: z.literal(true), +}); + +export async function readApplicationExecutionContext( + client: pg.PoolClient, + session: Session, + instanceId: string, +) { + const result = await client.query<{ context: unknown }>( + "SELECT lzc_auth.application_execution_context($1,$2) AS context", + [session.id, instanceId], + ); + return applicationExecutionContextSchema.parse(result.rows[0]?.context); +} + +export function supportsApplicationExecution( + runner: PlanRunner | undefined, + revision: string, +) { + return runner?.supportsAccelerator + ? runner.supportsAccelerator(revision) + : runner?.acceleratorCommit === revision; +} + +export function availableApplicationExecutionContext( + input: unknown, + runner?: PlanRunner, +) { + const context = applicationExecutionContextSchema.parse(input); + const profiles = applicationExecutionProfiles.filter((profile) => + supportsApplicationExecution(runner, profile.revision), + ); + const available = profiles.some( + (profile) => + profile.revision === context.revision && profile.status === "supported", + ); + const replacementAvailable = profiles.some( + (profile) => + profile.revision === context.replacementRevision && + profile.status === "supported", + ); + return { + ...context, + sourceStatus: + context.sourceStatus === "supported" && !available + ? ("unavailable" as const) + : context.sourceStatus, + canUpgrade: context.canUpgrade && replacementAvailable, + blockers: [ + ...context.blockers, + ...(context.sourceStatus === "supported" && !available + ? ["application_execution_package_unavailable"] + : []), + ], + profiles, + }; +} diff --git a/landing-zone-configurator/app/apps/api/src/applications/routes.ts b/landing-zone-configurator/app/apps/api/src/applications/routes.ts index e483a405..512289da 100644 --- a/landing-zone-configurator/app/apps/api/src/applications/routes.ts +++ b/landing-zone-configurator/app/apps/api/src/applications/routes.ts @@ -33,6 +33,7 @@ export type ApplicationRunnerServices = Pick< Partial< Pick< Applications, + | "resolveRunnerBinding" | "runnerRecovery" | "stateRecoveryInput" | "stateRecoveryFence" @@ -46,6 +47,10 @@ export function registerApplicationRunner( input: unknown, ) { const binding = applicationRunnerPackageSchema.parse(input); + const resolveBinding = async (key: string) => + applications.resolveRunnerBinding + ? applications.resolveRunnerBinding(key, binding) + : binding; const ticket = (authorization: string | undefined) => { if (!authorization || !/^Bearer [A-Za-z0-9_-]{43}$/.test(authorization)) throw new ApplicationError(401, "invalid_application_runner_ticket"); @@ -88,7 +93,7 @@ export function registerApplicationRunner( async (request) => { const key = ticket(request.headers.authorization); z.strictObject({}).parse(request.body); - return applications.runnerInput(key, binding); + return applications.runnerInput(key, await resolveBinding(key)); }, ); if ( @@ -105,7 +110,8 @@ export function registerApplicationRunner( { bodyLimit: 1024 }, async (request) => { z.strictObject({}).parse(request.body); - return recoveryInput(ticket(request.headers.authorization), binding); + const key = ticket(request.headers.authorization); + return recoveryInput(key, await resolveBinding(key)); }, ); for (const path of ["fence", "result"] as const) @@ -115,8 +121,8 @@ export function registerApplicationRunner( async (request) => { const key = ticket(request.headers.authorization); return path === "fence" - ? recoveryFence(key, binding, request.body) - : recoveryResult(key, binding, request.body); + ? recoveryFence(key, await resolveBinding(key), request.body) + : recoveryResult(key, await resolveBinding(key), request.body); }, ); } @@ -138,9 +144,10 @@ export function registerApplicationRunner( `/api/application-runner/${path}`, { bodyLimit: limit }, async (request, reply) => { + const key = ticket(request.headers.authorization); const receipt = await applications[method]( - ticket(request.headers.authorization), - binding, + key, + await resolveBinding(key), request.body, ); return method === "runnerArtifact" ? receipt : reply.code(204).send(); @@ -152,12 +159,10 @@ export function registerApplicationRunner( routes.post( "/api/application-runner/recovery", { bodyLimit: 22 * 1024 * 1024 + 65536 }, - async (request) => - recovery( - ticket(request.headers.authorization), - binding, - request.body, - ), + async (request) => { + const key = ticket(request.headers.authorization); + return recovery(key, await resolveBinding(key), request.body); + }, ); }); } @@ -188,6 +193,8 @@ export function registerApplications( | "configureExecution" | "listExecutionBindings" | "executionCapabilities" + | "executionLifecycle" + | "upgradeExecution" | "listJobs" | "revokeJobGrant" | "approveJobBackend" @@ -246,6 +253,12 @@ export function registerApplications( "template_version_in_use", "template_version_not_retired", "application_execution_binding_conflict", + "application_execution_changed", + "application_upgrade_execution_active", + "application_execution_upgrade_changed", + "application_execution_upgrade_unavailable", + "application_execution_source_withdrawn", + "application_execution_package_unavailable", "application_apply_plan_unavailable", "application_apply_package_changed", "application_recovery_fresh_plan_required", @@ -268,6 +281,25 @@ export function registerApplications( : "application_backend_binding_conflict"; return reply.code(409).send({ error: code }); } + if ( + error instanceof Error && + "code" in error && + error.code === "42501" && + [ + "application_upgrade_source_unavailable", + "application_recovery_source_unavailable", + "application_execution_binding_changed", + "application_execution_source_withdrawn", + "application_upgrade_confirmation_required", + ].includes(error.message) + ) + return reply + .code( + error.message === "application_upgrade_confirmation_required" + ? 400 + : 409, + ) + .send({ error: error.message }); if ( error instanceof Error && "code" in error && @@ -294,6 +326,33 @@ export function registerApplications( .send({ error: "invalid_request_origin_or_csrf" }); sessions.set(request, session); }); + routes.get( + "/api/v1/applications/instances/:id/execution-lifecycle", + async (request, reply) => { + const session = sessions.get(request); + if (!session) return reply.code(401).send(); + if (!applications.executionLifecycle) + return reply + .code(503) + .send({ error: "application_execution_lifecycle_unavailable" }); + const { id } = z.strictObject({ id: z.uuid() }).parse(request.params); + return applications.executionLifecycle(session, id); + }, + ); + routes.post( + "/api/v1/applications/instances/:id/execution-lifecycle", + { bodyLimit: 1024 }, + async (request, reply) => { + const session = sessions.get(request); + if (!session) return reply.code(401).send(); + if (!applications.upgradeExecution) + return reply + .code(503) + .send({ error: "application_execution_lifecycle_unavailable" }); + const { id } = z.strictObject({ id: z.uuid() }).parse(request.params); + return applications.upgradeExecution(session, id, request.body); + }, + ); routes.get( "/api/v1/applications/execution-bindings", async (request, reply) => { @@ -491,6 +550,9 @@ export function registerApplications( instances: await applications.listInstances(session), orderDecisionEnabled: Boolean(applications.decideOrder), executionDelegationEnabled: Boolean(applications.configureExecution), + executionLifecycleEnabled: Boolean( + applications.executionLifecycle && applications.upgradeExecution, + ), orderDeletionEnabled: Boolean(applications.deleteOrder), execution: applications.executionCapabilities?.() ?? { planEnabled: false, 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 3728c5b2..eb111bd6 100644 --- a/landing-zone-configurator/app/apps/api/src/applications/service.ts +++ b/landing-zone-configurator/app/apps/api/src/applications/service.ts @@ -2,6 +2,9 @@ import { createHash, randomBytes, randomUUID } from "node:crypto"; import { type ApplicationInstance, type AppliedPlatformSource, + applicationExecutionNativeOverrides, + applicationExecutionProfiles, + applicationExecutionRevisionSchema, applicationInstanceSchema, applicationOrderDecisionSchema, applicationOrderQuotaSchema, @@ -47,6 +50,12 @@ import { type ArtifactCrypto, canonicalBase64 } from "../plans/crypto.js"; import { canonicalJson, sha256 } from "../plans/execution.js"; import type { Plans } from "../plans/service.js"; import { withTenant } from "../storage/database.js"; +import { + applicationExecutionUpgradeSchema, + availableApplicationExecutionContext, + readApplicationExecutionContext, + supportsApplicationExecution, +} from "./execution-lifecycle.js"; import { inspectInterruptedApply } from "./interrupted-apply.js"; import { completeInterruptedPlan } from "./interrupted-plan.js"; import { @@ -83,14 +92,7 @@ function requireSeparateInitialOwner( export const applicationRunnerPackageSchema = z.strictObject({ runnerPackageId: z.uuid(), - acceleratorRevision: z.enum([ - "c4b43c36af198985980b17626c48d357795e3fbd", - "88149782bf8e91dcdbb43a203b54337886023f7f", - "57ad1f6a651c1787694b74ff8aa8b241a3dcd16f", - "9e7fb46c329aca32e878ce1f0ea3b944ea240354", - "1ed855609d4dcbfded6759c7c8b4fabf7bd6725d", - "08ac257de1548c8ec5e83dae1a79373711c3d644", - ]), + acceleratorRevision: applicationExecutionRevisionSchema, providerLockSha256: z.literal( "d40debbff204aee590c2a76d09f6ad3234643329b438fd5c6497de60687f6fa5", ), @@ -1065,6 +1067,52 @@ export class Applications { }); } + executionLifecycle(session: Session, instanceId: string) { + const id = z.uuid().parse(instanceId); + return this.work(session, "read", async (client) => + availableApplicationExecutionContext( + await readApplicationExecutionContext(client, session, id), + this.dispatch?.runner, + ), + ); + } + + async upgradeExecution(session: Session, instanceId: string, input: unknown) { + const id = z.uuid().parse(instanceId); + const request = applicationExecutionUpgradeSchema.parse(input); + if ( + !applicationExecutionProfiles.some( + (profile) => + profile.revision === request.targetRevision && + profile.status === "supported", + ) || + !supportsApplicationExecution( + this.dispatch?.runner, + request.targetRevision, + ) + ) + throw new ApplicationError( + 409, + "application_execution_package_unavailable", + ); + return this.work(session, "order", async (client) => { + const result = await client.query<{ context: unknown }>( + "SELECT lzc_auth.upgrade_application_execution($1,$2,$3,$4,$5) AS context", + [ + session.id, + id, + request.targetRevision, + request.expectedRevision, + request.confirmUpgrade, + ], + ); + return availableApplicationExecutionContext( + result.rows[0]?.context, + this.dispatch?.runner, + ); + }); + } + listInstances(session: Session) { return this.work(session, "read", async (client) => { const result = await client.query( @@ -1879,23 +1927,17 @@ export class Applications { ? prepared.plan : { ...prepared.plan, purpose: request.purpose }; if ( - ![ - "4d15d7870afa323badd93559d8b37c5a8d138dcf", - "c4b43c36af198985980b17626c48d357795e3fbd", - "88149782bf8e91dcdbb43a203b54337886023f7f", - "57ad1f6a651c1787694b74ff8aa8b241a3dcd16f", - "9e7fb46c329aca32e878ce1f0ea3b944ea240354", - "1ed855609d4dcbfded6759c7c8b4fabf7bd6725d", - "08ac257de1548c8ec5e83dae1a79373711c3d644", - ].includes(prepared.plan.acceleratorRevision) - ) - throw new ApplicationError(409, "application_runner_revision_required"); - if ( + !applicationExecutionRevisionSchema.safeParse( + prepared.plan.acceleratorRevision, + ).success || this.dispatch?.runner.supportsAccelerator?.( prepared.plan.acceleratorRevision, ) === false ) - throw new ApplicationError(409, "application_runner_revision_required"); + throw new ApplicationError( + 409, + "application_execution_package_unavailable", + ); const bindingHash = createHash("sha256") .update(JSON.stringify(plan)) .digest("hex"); @@ -2116,6 +2158,22 @@ export class Applications { z.strictObject({ confirmPlan: z.literal(true) }).parse(input); if (!this.dispatch || !this.backends || !this.secrets || !this.profiles) throw new ApplicationError(503, "application_dispatch_disabled"); + const jobSource = await this.work(session, "order", async (client) => { + const result = await client.query<{ accelerator_revision: string }>( + "SELECT accelerator_revision FROM lzc.application_job_grants WHERE job_id=$1 AND tenant_id=$2", + [jobId, session.tenantId], + ); + const revision = + applicationRunnerPackageSchema.shape.acceleratorRevision.parse( + result.rows[0]?.accelerator_revision, + ); + if (this.dispatch?.runner.supportsAccelerator?.(revision) === false) + throw new ApplicationError( + 409, + "application_execution_package_unavailable", + ); + return revision; + }); const created = await this.work(session, "order", async (client) => { const row = ( await this.jobQuery<{ created: boolean }>( @@ -2144,9 +2202,7 @@ export class Applications { jobId, { runnerPackageId, - acceleratorRevision: - this.dispatch?.runner.acceleratorCommit ?? - "c4b43c36af198985980b17626c48d357795e3fbd", + acceleratorRevision: jobSource, providerLockSha256: "d40debbff204aee590c2a76d09f6ad3234643329b438fd5c6497de60687f6fa5", }, @@ -2184,6 +2240,22 @@ export class Applications { } } + async resolveRunnerBinding(ticket: string, input: unknown) { + const binding = applicationRunnerPackageSchema.parse(input); + if (!/^[A-Za-z0-9_-]{43}$/.test(ticket)) + throw new ApplicationError(401, "invalid_application_runner_ticket"); + const result = await this.pool.query<{ binding: unknown }>( + "SELECT lzc_auth.application_runner_binding($1,$2) AS binding", + [tokenHash(ticket), binding.providerLockSha256], + ); + const resolved = applicationRunnerPackageSchema.safeParse( + result.rows[0]?.binding, + ); + if (!resolved.success) + throw new ApplicationError(401, "invalid_application_runner_ticket"); + return resolved.data; + } + private async consumeRunnerTicket( ticket: string, binding: z.infer, @@ -2673,15 +2745,7 @@ export class Applications { acceleratorRevision: string, ) { z.uuid().parse(jobId); - z.enum([ - "4d15d7870afa323badd93559d8b37c5a8d138dcf", - "c4b43c36af198985980b17626c48d357795e3fbd", - "88149782bf8e91dcdbb43a203b54337886023f7f", - "57ad1f6a651c1787694b74ff8aa8b241a3dcd16f", - "9e7fb46c329aca32e878ce1f0ea3b944ea240354", - "1ed855609d4dcbfded6759c7c8b4fabf7bd6725d", - "08ac257de1548c8ec5e83dae1a79373711c3d644", - ]).parse(acceleratorRevision); + applicationExecutionRevisionSchema.parse(acceleratorRevision); if (!this.profiles || !this.secrets) throw new ApplicationError( 503, @@ -2868,14 +2932,21 @@ export class Applications { } if (resolved.resolution.qualificationBlockers.length) throw new ApplicationError(409, "application_parameters_not_qualified"); + const execution = availableApplicationExecutionContext( + await readApplicationExecutionContext(client, session, row.id), + this.dispatch?.runner, + ); + if (this.dispatch && execution.sourceStatus === "withdrawn") + throw new ApplicationError(409, "application_execution_source_withdrawn"); + if (this.dispatch && execution.sourceStatus === "unavailable") + throw new ApplicationError( + 409, + "application_execution_package_unavailable", + ); const settings = resolved.resolution.settings; const observability = objectValue(settings.observability); if ( - applicationPlanScopeIssues( - template, - settings, - published.accelerator_revision, - ).length + applicationPlanScopeIssues(template, settings, execution.revision).length ) throw new ApplicationError(409, "application_plan_scope_not_supported"); const plan = compileApplicationPlan({ @@ -2886,7 +2957,7 @@ export class Applications { id: `template-${template.id}`, version: published.version, status: "published", - accelerator_revision: published.accelerator_revision, + accelerator_revision: execution.revision, platform_revision: published.platform_revision, target_keys: [published.target_key], apply_policy: published.deployment_policy, @@ -2929,9 +3000,7 @@ export class Applications { verified_stackit_email: identity.email, stackit_organization_id: identity.organization_id, allowed_accelerator_revision: - applicationAcceleratorRevisionSchema.parse( - published.accelerator_revision, - ), + applicationAcceleratorRevisionSchema.parse(execution.revision), }, request: { name: row.name, @@ -2943,7 +3012,19 @@ export class Applications { kind: "application-plan-input" as const, cloudPlanExecuted: false as const, requiresExplicitApplyApproval: true as const, - plan, + plan: { + ...plan, + variables: { + ...plan.variables, + ...applicationExecutionNativeOverrides( + execution.originRevision, + execution.revision, + ), + }, + ...(execution.bindingId + ? { executionBindingId: execution.bindingId } + : {}), + }, blockers: this.executionCapabilities().planEnabled ? [] : [ diff --git a/landing-zone-configurator/app/apps/api/src/applications/state-recovery.ts b/landing-zone-configurator/app/apps/api/src/applications/state-recovery.ts index e11519c7..19bd9504 100644 --- a/landing-zone-configurator/app/apps/api/src/applications/state-recovery.ts +++ b/landing-zone-configurator/app/apps/api/src/applications/state-recovery.ts @@ -1,6 +1,7 @@ import { randomBytes, randomUUID } from "node:crypto"; import { type ApplicationStateRecoveryView, + applicationExecutionRevisionSchema, applicationStateRecoveryAssessRequestSchema, applicationStateRecoveryBlockerSchema, applicationStateRecoveryCommitRequestSchema, @@ -56,10 +57,7 @@ const contextSchema = z.object({ credentialOwnerId: z.uuid().optional(), serviceAccount: z.string().min(1).optional(), organizationId: z.uuid(), - sourceRevision: z.enum([ - "1ed855609d4dcbfded6759c7c8b4fabf7bd6725d", - "08ac257de1548c8ec5e83dae1a79373711c3d644", - ]), + sourceRevision: applicationExecutionRevisionSchema, originalInputs: z.object({ variables: z.record(z.string(), z.json()) }), backendCiphertext: z.string().regex(/^[a-f0-9]+$/), finishedAt: z.iso.datetime({ offset: true }), @@ -537,6 +535,7 @@ export class ApplicationStateRecoveries { decodeRecoveryArtifact(state.original), ); const imports = deriveApplicationRecoveryImports({ + sourceRevision: c.sourceRevision, state: decodeRecoveryArtifact(state.original), variables: c.originalInputs.variables, inventory, @@ -555,6 +554,7 @@ export class ApplicationStateRecoveries { ...this.view(row), phase: "assessing" as const, credentialRevocations: deriveApplicationRecoveryRevocations({ + sourceRevision: c.sourceRevision, state: decodeRecoveryArtifact(state.original), variables: c.originalInputs.variables, inventory, @@ -682,6 +682,7 @@ export class ApplicationStateRecoveries { ); const revocations = allowRevoked ? deriveApplicationRecoveryRevocations({ + sourceRevision: c.sourceRevision, state: decodeRecoveryArtifact(payload.original), variables: c.originalInputs.variables, inventory: payload.inventory, @@ -707,10 +708,7 @@ export class ApplicationStateRecoveries { const b = z .strictObject({ runnerPackageId: z.uuid(), - acceleratorRevision: z.enum([ - "1ed855609d4dcbfded6759c7c8b4fabf7bd6725d", - "08ac257de1548c8ec5e83dae1a79373711c3d644", - ]), + acceleratorRevision: applicationExecutionRevisionSchema, providerLockSha256: z.literal( "d40debbff204aee590c2a76d09f6ad3234643329b438fd5c6497de60687f6fa5", ), @@ -811,6 +809,7 @@ export class ApplicationStateRecoveries { private_ciphertext: task.input_ciphertext, }); const revocations = deriveApplicationRecoveryRevocations({ + sourceRevision: c.sourceRevision, state: decodeRecoveryArtifact(payload.original), variables: c.originalInputs.variables, inventory: payload.inventory, @@ -861,6 +860,7 @@ export class ApplicationStateRecoveries { try { await this.stopped(c); const revocations = deriveApplicationRecoveryRevocations({ + sourceRevision: c.sourceRevision, state: decodeRecoveryArtifact(payload.original), variables: c.originalInputs.variables, inventory: payload.inventory, @@ -1061,6 +1061,7 @@ export class ApplicationStateRecoveries { const original = decodeRecoveryArtifact(payload.original), candidate = decodeRecoveryArtifact(request.candidate); const imports = deriveApplicationRecoveryImports({ + sourceRevision: c.sourceRevision, state: original, variables: c.originalInputs.variables, inventory: payload.inventory, @@ -1073,6 +1074,7 @@ export class ApplicationStateRecoveries { instanceId: c.instanceId, imports, revocations: deriveApplicationRecoveryRevocations({ + sourceRevision: c.sourceRevision, state: original, variables: c.originalInputs.variables, inventory: payload.inventory, 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 83f5e825..f7b90a30 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: - "f34bfcb1b5d8e0f2149d4779223e663e4bedab6326f955d6416cafd77d585401", + "4922fd0a55bbbfe63af6c941f83a8f6db5c85a07578fbc63dad6edd8dba17eb9", }, { path: "landing-zone-configurator/app/apps/api/src/assistant/editor.ts", @@ -1539,8 +1539,8 @@ export const assistantKnowledgeTopics = { { id: "configuration-lifecycle", file: "configuration-lifecycle.md", - version: 9, - checkedOn: "2026-10-12", + version: 10, + checkedOn: "2026-10-28", qualification: "local-draft-not-reviewed-release", groups: [], mandatory: true, @@ -1580,7 +1580,7 @@ export const assistantKnowledgeTopics = { { path: "landing-zone-configurator/app/apps/api/src/assistant/model-serving.ts", sha256: - "f34bfcb1b5d8e0f2149d4779223e663e4bedab6326f955d6416cafd77d585401", + "4922fd0a55bbbfe63af6c941f83a8f6db5c85a07578fbc63dad6edd8dba17eb9", }, { path: "landing-zone-configurator/app/apps/api/src/assistant/knowledge.ts", @@ -1592,6 +1592,16 @@ export const assistantKnowledgeTopics = { sha256: "56209a0eea70927042b8799f5e12b3005b53ed9c1584fc9b77cbc99986b174b2", }, + { + path: "landing-zone-configurator/docs/application-execution-lifecycle.md", + sha256: + "93fb8b2bf37f8688efcb13211ef25c9b1486e9d63f2248c78287871f95bcf82c", + }, + { + path: "landing-zone-configurator/app/apps/api/src/applications/execution-lifecycle.ts", + sha256: + "a6defed96d8d56262d3591c9f8288107195596295f8291ba680e5737e77ad94c", + }, { path: "landing-zone-configurator/app/apps/api/src/assistant/editor.ts", sha256: @@ -1614,9 +1624,9 @@ export const assistantKnowledgeTopics = { fieldPaths: [], source: "landing-zone-configurator/docs/assistant-knowledge/configuration-lifecycle.md", - text: '# Configuration, Conversation and Execution Lifecycle\n\n## Purpose\n\nMandatory for all service/template advice: authorized workspace/configuration;\ndistinguish requirements, defaults, proposals, acceptance and deployment.\n\n## Accelerator Contract\n\nThe shared document/compiler produces native tfvars for the matching Accelerator.\nTemplate selection supplies defaults, not consent to enable optional services.\nSupported patches: company name/code/region, existing folder names and native SNA.\nExplicit naming delegation permits chosen company/folder names, not region,\nIDs, access, ownership or roles. Preserve existing structural keys and metadata.\nOther topics are advice, never extra mutation rights or inferred access values.\nSaved configuration, application-template publication, ordering, plan, apply,\ndestroy and recovery are independent operations. No free Terraform or a second\nYAML schema. A full apply includes mandatory foundations and all enabled inputs.\n\n## Dependencies\n\nService knowledge and credential foundations do not activate services.\nCatalogue facts, current values, user claims and estimates are not execution.\n\n## Dialogue\n\nReuse confirmed prior requirements, name unresolved assumptions, prioritize the\ncurrent step and explain inconsistent inherited settings. "Later" is not\n"enable now". Topic/mode planning uses the saved history, including unnamed\nfollow-up answers; explicit topic switches override prior focus. Planning is\nnot authorization. Distinguish business decisions, derived technical values\nand operator-only secure setup. Ask one missing firewall decision at a time.\noptions are reply suggestions, not patches/consent. multiple=true permits\ncompatible combined answers; free text remains allowed. Subsets request a new\nvalidated proposal; only explicit whole-proposal acceptance saves changes.\nNew configurations can explicitly inherit their own template\nadvice via a revision-bound server-side context; existing configurations are\nnot silently retrofitted. Application groups/parameter bindings constrain\nauthorized versions and choices; never create grants or orders from chat.\n\n## Examples\n\n"SNA first, firewall later": only supported SNA changes; disclose inherited\nfirewall settings. No selective cloud apply; native tfvars need separate\nexternally supplied credentials/backend prerequisites.\n\n## Limits\n\nAcceptance rechecks authorization, base revision and candidate validation.\nUnknown import fields remain preserved outside model context. Conversations\nexpire after three calendar months from creation, not last activity; source\ndeletion removes derived context and late inference cannot resurrect it.\nContinue conversations with current knowledge; old proposals need regeneration.\nNo deletion or expiry extension. Five concurrent sessions is a load target, not\nsaved-chat quota; current process limits are not measured load qualification.\nSchema/model errors preserve the draft. No automatic Git/repository mutation,\npublication, order, recovery unlock or raw state/plan inspection.\n\n## Credentials\n\nRecheck owner/tenant/workspace rights for each operation, including after\ninference and reauthentication. Session state is not permission escalation.\nTokens, keys, raw provider errors and protected data never enter model context.\n\n## Sources\n\nManifest binds document/features/service. Release/promotion, planning,\nretention/restore/load/DE-EN parity and A01-A24 require separate acceptance.', + text: '# Configuration, Conversation and Execution Lifecycle\n\n## Purpose\n\nAuthorize workspace/configuration; separate proposals/acceptance/deployment.\n\n## Accelerator Contract\n\nCompiler exports native tfvars. Template defaults never consent\nto optional services. Patches: company name/code/region, existing folder names,\nnative SNA. Naming delegation grants names only, not region/IDs/access/owners/roles.\nPreserve structural keys/metadata; other advice grants no mutations/access values.\nSaving, publication, ordering, Plan, Apply, Destroy, Recovery are separate.\nNo free Terraform/YAML schema. Apply includes foundations+enabled inputs.\nApplication orders pin IaC/lock/tools across product releases; patched Workers\nverify bundled sources. Order details > Ausführungsstand: origin=immutable template\nGit revision; desired=selected Accelerator revision, NOT an editable platform draft;\nlastApplied=source of last successful Apply, NOT a complete current-State snapshot.\nExplicit Accelerator upgrade retains same order/project/State/backend/policy;\nit changes desired source only, starts NO execution. Then NEW PLAN, review changes/\nreplacements/deletions, NORMAL EXPLICIT APPLY APPROVAL. Failures retain desired\nsource but MAY CHANGE State/resources; Recovery uses interrupted job source.\nPlan compares actual State, NOT lastApplied; lastApplied is NOT Recovery source.\nNever promise unchanged State/no data loss after failed Apply. Withdrawn sources\ncannot manually Apply/bypass checks; use reviewed replacement. Unresolved Recovery\nblocks upgrade; operator must provide reviewed compatible package if withdrawn.\nNever reorder existing infrastructure. Chat cannot upgrade/unlock/execute.\n\n## Dependencies\n\nKnowledge/credentials never enable services; catalogues, values, claims and\nestimates are not execution.\n\n## Dialogue\n\nReuse confirmed requirements; expose assumptions/inherited inconsistencies;\nprioritize step. "Later" never enables. Use saved history/unnamed follow-ups;\nexplicit topic switches win. Planning is not authorization. Separate business,\nderived and secure operator decisions. Ask one missing firewall decision.\noptions suggest replies, never patches/consent; multiple=true permits compatible\nanswers; free text allowed. Subsets need new validated proposals; save only on\nexplicit whole-proposal acceptance. New configs inherit own template advice only\nvia explicit revision-bound server context; never retrofit existing ones.\nApplication groups/parameters restrict versions/choices; chat creates no grants/orders.\n\n## Examples\n\n"SNA first, firewall later": supported SNA only; disclose inherited firewall.\nNo selective cloud Apply; native tfvars need separate credentials/backend setup.\n\n## Limits\n\nAcceptance: reauthorize/recheck base/candidate. Keep unknown imports outside model.\nChats expire three calendar months from creation, not activity; deletion/late\ninference cannot restore derived context. Regenerate with current knowledge.\nNo chat expiry extension. Five sessions=load target, not quota/capacity. Schema/model errors retain drafts. No auto Git/publication/order/\nRecovery unlock/raw State-Plan inspection.\n\n## Credentials\n\nRecheck owner/tenant/workspace per operation and after inference/reauth.\nSessions grant no rights; no tokens/keys/raw errors/protected data in model.\n\n## Sources\n\nManifest binds sources. Separate release/promotion/planning,\nretention/restore/load/DE-EN parity and A01-A24 acceptance.\n', sha256: - "693305e383573ccb49b39979e3a60b50e8bc77a2087850b671aecd117e7041e8", + "713f8a3f715ee3931d5a3914141f1411396050b1e9d650d8982f0b06c1d77051", }, ], } as const; 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 32105959..67e6d886 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 @@ -2700,6 +2700,28 @@ Reviewed knowledge: ${JSON.stringify({ fields: draftFields, ...topicKnowledge }) if (intent.topic === "dns-zones" && intent.dnsTask === "remaining-settings") intent.mode = "draft"; const networkFirst = intent.topic === "stackit-network-components"; + // Execution recovery advice is a product contract, not a model-selected + // approval policy. Do not let a paraphrase omit the required review boundary. + if ( + intent.topic === "configuration-lifecycle" && + intent.mode === "advice" && + /Application|Bestellung|\border\b/i.test(message) && + /Revision|Ausführungsstand|Upgrade|source|Quelle/i.test(message) + ) + return validateDialogue( + { + explanation: + "Bestehende Application-Bestellungen und ihre State-Zuordnung bleiben erhalten; keine Neubestellung. Produkt-Updates ändern ihre gebundene Accelerator-Quelle nicht automatisch. Geprüfte historische Quellen laufen mit dem aktuellen gepatchten Worker. Die konkrete Verfügbarkeit und offene Jobs prüfst du in den Bestelldetails unter Ausführungsstand der Bestellung; ich habe diese Bestellung nicht ausgelesen.\n\n" + + "Ursprüngliche Template-Quelle (origin) ist die unveränderliche Git-Revision der Veröffentlichung. Gewünschter Ausführungsstand (desired) ist die ausgewählte Accelerator-Revision. Zuletzt erfolgreich angewendete Revision (lastApplied) bezeichnet die Quelle des letzten erfolgreichen Apply, weder den vollständigen aktuellen State noch die Recovery-Quelle.\n\n" + + "Für ein Upgrade derselben Bestellung wählst du dort den angebotenen geprüften Ersatz und bestätigst die Aktualisierung. Das ändert nur die gewünschte Ausführungsbindung; Projekt, State, Backend und Template-Regeln bleiben erhalten. Es startet keinen Plan oder Apply. Danach brauchst du einen neuen Plan gegen den tatsächlichen State: Änderungen, Ersetzungen und Löschungen prüfen, anschließend die normale separate ausdrückliche Apply-Freigabe erteilen. Ein alter Plan wird nicht wiederverwendet.\n\n" + + "Ein fehlgeschlagener Apply kann State und Ressourcen bereits verändert haben; Verlustfreiheit oder unveränderter State sind nicht garantiert. Der gewünschte Stand bleibt erhalten. Recovery verwendet die Quelle des unterbrochenen Jobs. Laufende oder ungeklärte Jobs, aktive Freigaben und offene Recovery sperren das Upgrade bis zum Abgleich.\n\n" + + "Zurückgezogene Quellen dürfen nicht mehr ausführen: Nutze den geprüften Upgrade-Pfad derselben Bestellung. Fehlt ein Paket, muss ein Operator es geprüft bereitstellen oder den angebotenen Ersatzpfad ermöglichen. Ist zugleich Recovery ungeklärt und deren Quelle zurückgezogen, bleiben State und Sperre erhalten; dafür braucht ein Operator ein geprüftes kompatibles Recovery-Paket, bevor ein Upgrade möglich ist. Keine Umgehung der Prüfungen. Der Chat ändert nichts und führt kein Upgrade, Entsperren, Plan oder Apply aus.", + questions: [], + changes: [], + references: ["configuration-lifecycle"], + }, + context.rules.map((rule) => rule.id), + ); const dnsAdvice = intent.topic === "dns-zones"; const dnsSettingsDraft = dnsAdvice && @@ -3359,7 +3381,7 @@ Reviewed knowledge: ${JSON.stringify({ fields: draftFields, ...topicKnowledge }) ? "DNS CONTRACT: Suitable DNS settings delegate reviewable name, description and TTL proposals and a known contact default, not invented contacts, ACLs, new topology or execution. Existing DNS keys stay stable; no identity/folder naming delegation needed. Current configuration values are the authoritative saved baseline, even if older conversation messages describe a previous value or a name-only limitation. Reuse the saved domain unless the latest user explicitly requests a domain change; distinguish dns_name (domain) from name (display name). Preserve an existing DNS contact; for an unset contact propose saved owner_email, otherwise verified assistant.user_email, stating its source. Do not claim a saved value was just changed. Explain settings and missing business facts. Never ask whether to accept a proposal: dedicated field-selection controls handle that decision." : foundationDraft ? `NAMING CONTRACT: ${JSON.stringify(delegation)} is bounded naming delegation extracted from user requirements. Use explicitly supplied company names as written. For delegated company_code choose a concise resource naming abbreviation; for delegated rm_folders propose suitable names (1-40 chars) for EXISTING keys in currentPublicScalarValues.rm_folders. Without delegation use names explicitly supplied by the user or ask. rm_folders is the only supported nested governance patch: partial existing keys to {name:string}, never descriptions, owner_emails, reader_emails or new keys. Preserve all other metadata and internal/resource IDs. The existing organization_id is the sole UUID draft exception: reuse only the exact user-supplied UUID, never infer access or change tenant binding. owner_email may use actual user requirements, the saved owner address or verified assistant.user_email as a reviewable default. Naming delegation never permits guessing region, emails, grants, parent IDs or credentials. Prepare supported proposals now, not repeated naming questions. Say proposed, not saved or deployed. Missing foundation requirements remain questions, not invented values.` - : `ADVICE CONTRACT: The current topic is ${intent.topic}. Answer the actual question fully from that topic's reviewed Accelerator Contract and Limits, with its native field names and supported values. Cite that topic, not only a dependency. Distinguish native Accelerator/editor fields from the narrower chat patch whitelist: unsupported chat patches do NOT mean those fields are immutable or absent in the editor. A fields overview lists concrete fields and limits in explanation; never merely announce a list, replace the answer with follow-up questions, or invent missing features. Questions only resolve actual missing requirements for the current topic, never unrelated components. No changes or saved/deployed claim in advice mode. For governance, distinguish rm_folder_parent_id and organization roles from the names-only chat patch: names 1-40 characters; meaningful folder descriptions are rejected, not silently ignored. For sandbox projects the only native fields are project_name, project_owner_email and optional owner_emails; no native expiry or budget controls. For firewall policy list firewall_config aliases, routes, rules, outbound_nat and port_forwards, endpoint/TLS and pass/block/reject with in/out; explain external bootstrap and explicit HA policy synchronization separately from CARP/pfsync, without starting the infrastructure capacity questionnaire.`, + : `${intent.topic === "configuration-lifecycle" ? "EXECUTION LIFECYCLE ADVICE: When explaining an Application source upgrade, explicitly include BOTH a fresh Plan review (changes/replacements/deletions) AND the normal separate explicit Apply approval. The upgrade action itself only changes desired source; it never starts either operation. Preserve the same order/project/State identity. Distinguish immutable template origin, desired source, and last successful Apply source. lastApplied is neither the actual current State nor the source to use for interrupted-job Recovery. Failed Apply can change State/resources; Recovery uses the interrupted job source and blocks upgrade until reconciled. Withdrawn sources cannot execute; unresolved Recovery needs an operator-provided reviewed compatible package. Do not recommend reordering or promise unchanged failed State/no data loss. Chat performs no upgrade or execution. " : ""}ADVICE CONTRACT: The current topic is ${intent.topic}. Answer the actual question fully from that topic's reviewed Accelerator Contract and Limits, with its native field names and supported values. Cite that topic, not only a dependency. Distinguish native Accelerator/editor fields from the narrower chat patch whitelist: unsupported chat patches do NOT mean those fields are immutable or absent in the editor. A fields overview lists concrete fields and limits in explanation; never merely announce a list, replace the answer with follow-up questions, or invent missing features. Questions only resolve actual missing requirements for the current topic, never unrelated components. No changes or saved/deployed claim in advice mode. For governance, distinguish rm_folder_parent_id and organization roles from the names-only chat patch: names 1-40 characters; meaningful folder descriptions are rejected, not silently ignored. For sandbox projects the only native fields are project_name, project_owner_email and optional owner_emails; no native expiry or budget controls. For firewall policy list firewall_config aliases, routes, rules, outbound_nat and port_forwards, endpoint/TLS and pass/block/reject with in/out; explain external bootstrap and explicit HA policy synchronization separately from CARP/pfsync, without starting the infrastructure capacity questionnaire.`, }, ...history .slice(-12) 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 a6663677..0baa12f5 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 @@ -122,6 +122,8 @@ export class CloudFoundryPlanRunner implements PlanRunner { } supportsAccelerator(commit: string) { + if (this.config.inspection?.supportsAccelerator) + return this.config.inspection.supportsAccelerator(commit); return ( commit === (this.acceleratorCommit ?? "a256f6896d11134fdc351786f1be5eba4e56b2e2") diff --git a/landing-zone-configurator/app/apps/api/src/plans/local.ts b/landing-zone-configurator/app/apps/api/src/plans/local.ts index ffe8f1af..470fcfd7 100644 --- a/landing-zone-configurator/app/apps/api/src/plans/local.ts +++ b/landing-zone-configurator/app/apps/api/src/plans/local.ts @@ -17,11 +17,15 @@ import { import { join } from "node:path"; import { promisify } from "node:util"; import { + type ApplicationExecutionManifest, + applicationExecutionCurrentRevision, + applicationExecutionProfile, applicationRunnerSourceSchema, openTofuProgress, platformFirewallImage, platformRunnerSourceSchema, } from "@lzc/contracts"; +import { readApplicationExecutionManifest } from "@lzc/contracts/recovery"; import { z } from "zod"; import type { PlanRunner } from "./cloud-foundry.js"; @@ -73,6 +77,13 @@ async function fingerprint( hash.update("application-source\0"); await add("application-source.json"); } + const execution = await readApplicationExecutionManifest(root); + if (execution) { + await add("application-execution.json"); + for (const profile of execution.profiles) + if (profile.sourcePath !== "application-src") + await add(profile.sourcePath); + } } if (broker === "platform") { const metadata = await lstat(join(root, "platform-source.json")).catch( @@ -118,6 +129,7 @@ export class LocalPlanRunner implements PlanRunner { readonly acceleratorCommit: string, readonly applicationMaintenanceEnabled: boolean, readonly firewallPlansEnabled: boolean, + private readonly applicationExecution: ApplicationExecutionManifest | null, ) {} static async open( @@ -143,8 +155,18 @@ export class LocalPlanRunner implements PlanRunner { throw new Error("Local runner requires OpenTofu 1.12.6"); let maintenanceEnabled = false; let firewallPlansEnabled = false; - const source = - broker === "platform" + const execution = + broker === "application" + ? await readApplicationExecutionManifest(packageRoot) + : null; + if (execution) + applicationExecutionProfile( + execution, + applicationExecutionCurrentRevision, + ); + const source = execution + ? applicationExecutionCurrentRevision + : broker === "platform" ? await readFile( join(packageRoot, "platform-source.json"), "utf8", @@ -197,8 +219,14 @@ export class LocalPlanRunner implements PlanRunner { identity, broker, source, - maintenanceEnabled, + execution + ? execution.profiles.some( + (profile) => + profile.status === "supported" && profile.maintenanceEnabled, + ) + : maintenanceEnabled, firewallPlansEnabled, + execution, ); } @@ -207,7 +235,15 @@ export class LocalPlanRunner implements PlanRunner { } supportsAccelerator(commit: string) { - return commit === this.acceleratorCommit; + if (!this.applicationExecution) return commit === this.acceleratorCommit; + try { + return ( + applicationExecutionProfile(this.applicationExecution, commit) + .status === "supported" + ); + } catch { + return false; + } } supportsArtifact(identity: string) { diff --git a/landing-zone-configurator/app/apps/web/src/components/ApplicationExecutionLifecycle.tsx b/landing-zone-configurator/app/apps/web/src/components/ApplicationExecutionLifecycle.tsx new file mode 100644 index 00000000..793e3b4b --- /dev/null +++ b/landing-zone-configurator/app/apps/web/src/components/ApplicationExecutionLifecycle.tsx @@ -0,0 +1,398 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { z } from "zod"; +import { t } from "../i18n"; +import type { Session } from "./Account"; + +const revisionSchema = z.string().regex(/^[a-f0-9]{40}$/); +const profileSchema = z.object({ + revision: revisionSchema, + providerLockSha256: z.string().regex(/^[a-f0-9]{64}$/), + openTofuVersion: z.string().min(1), + contractVersion: z.literal(1), + status: z.enum(["supported", "withdrawn"]), + replacementRevision: revisionSchema.nullable(), + maintenanceEnabled: z.boolean(), +}); +const contextSchema = z.object({ + revision: revisionSchema, + bindingId: z.uuid().nullable(), + lastAppliedRevision: revisionSchema.nullable(), + originRevision: revisionSchema, + sourceStatus: z.enum(["supported", "withdrawn", "unavailable"]), + replacementRevision: revisionSchema.nullable(), + canUpgrade: z.boolean(), + blockers: z.array(z.string()), + profiles: z.array(profileSchema), +}); +type Context = z.infer; +const errors: Record = { + application_execution_source_withdrawn: + "Dieser Ausführungsstand wurde zurückgezogen. Prüfe den angebotenen Upgrade-Pfad für dieselbe Bestellung und denselben State.", + application_execution_package_unavailable: + "Das gebundene Runner-Paket ist derzeit nicht verfügbar. Die Bestellung und ihr State bleiben erhalten. Prüfe die verfügbaren Ausführungsstände oder aktualisiere den Status.", + application_execution_upgrade_changed: + "Der Ausführungsstand hat sich zwischenzeitlich geändert. Lade den Status erneut und prüfe die Auswahl.", + application_execution_upgrade_unavailable: + "Ein Upgrade ist derzeit gesperrt. Prüfe die angezeigten Hindernisse; die bestehende Bestellung bleibt erhalten.", + application_execution_upgrade_confirmation_required: + "Bestätige die Prüfung des neuen Plans und die Beibehaltung von Bestellung und State.", + application_execution_binding_conflict: + "Die Ausführungsbindung passt nicht mehr zum Prüfstand. Lade den Status erneut; Bestellung und State bleiben erhalten.", + application_instance_running: + "Eine Ausführung ist noch aktiv. Warte auf ihren Abschluss, bevor du den Ausführungsstand änderst.", + application_execution_lifecycle_unavailable: + "Der Ausführungsstatus ist derzeit nicht verfügbar. Die bestehende Bestellung und ihr State bleiben erhalten. Aktualisiere den Status später erneut.", + application_access_denied: + "Du hast derzeit keine Berechtigung, diese Ausführungsbindung zu ändern.", + "execution-or-recovery-active": + "Eine Ausführung oder die Klärung eines fehlgeschlagenen Apply ist noch offen. Schließe sie vor einem Upgrade ab.", + "runner-ticket-active": + "Eine Runner-Berechtigung ist noch aktiv. Warte auf ihren Ablauf, bevor du den Ausführungsstand änderst.", + "guided-recovery-active": + "Eine geführte State-Wiederherstellung ist noch aktiv. Schließe sie vor einem Upgrade ab.", + "apply-grant-active": + "Eine Apply-Freigabe ist noch aktiv. Kläre die bestehende Ausführung vor einem Upgrade.", + "execution-claim-active": + "Ein Runner hält noch eine Ausführungsberechtigung. Kläre die bestehende Ausführung vor einem Upgrade.", + application_execution_changed: + "Der Ausführungsstand hat sich zwischenzeitlich geändert. Lade den Status erneut und prüfe die Auswahl.", + application_execution_binding_changed: + "Die Ausführungsbindung passt nicht mehr zum Prüfstand. Lade den Status erneut; Bestellung und State bleiben erhalten.", + application_upgrade_execution_active: + "Ein Upgrade ist derzeit gesperrt. Prüfe die angezeigten Hindernisse; die bestehende Bestellung bleibt erhalten.", + application_upgrade_source_unavailable: + "Das gewünschte Runner-Paket ist nicht als Upgrade freigegeben oder derzeit nicht verfügbar. Prüfe den angebotenen Upgrade-Pfad für dieselbe Bestellung.", + application_upgrade_access_denied: + "Du hast derzeit keine Berechtigung, diese Ausführungsbindung zu ändern.", + application_upgrade_confirmation_required: + "Bestätige die Prüfung des neuen Plans und die Beibehaltung von Bestellung und State.", +}; +function message(code: unknown): string { + return ( + (typeof code === "string" ? errors[code] : undefined) ?? + "Der Ausführungsstatus ist derzeit nicht verfügbar. Die bestehende Bestellung und ihr State bleiben erhalten. Aktualisiere den Status später erneut." + ); +} +function Source({ value }: { value: string | null }) { + return value ? ( + {value.slice(0, 12)} + ) : ( + {t("Noch kein erfolgreicher Apply")} + ); +} + +export function ApplicationExecutionLifecycle({ + session, + instanceId, + requestedBy, + onUpgraded, +}: { + session: Session; + instanceId: string; + requestedBy: string; + onUpgraded: (signal: AbortSignal) => Promise; +}) { + const [context, setContext] = useState(null); + const [target, setTarget] = useState(""); + const [confirmed, setConfirmed] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const [notice, setNotice] = useState(""); + const generation = useRef(0); + const controller = useRef(null); + const identity = `${session.tenant?.id}:${session.user.id}:${instanceId}`; + const currentIdentity = useRef(identity); + currentIdentity.current = identity; + const endpoint = `/api/v1/applications/instances/${instanceId}/execution-lifecycle`; + + const install = useCallback((value: Context) => { + setContext(value); + const candidates = value.profiles.filter( + (p) => + p.status === "supported" && + p.revision !== value.revision && + p.revision === value.replacementRevision, + ); + setTarget( + candidates.find((p) => p.revision === value.replacementRevision) + ?.revision ?? + candidates[0]?.revision ?? + "", + ); + setConfirmed(false); + }, []); + const read = useCallback( + async function read(signal: AbortSignal, body?: unknown) { + const response = await fetch(endpoint, { + method: body === undefined ? "GET" : "POST", + signal, + redirect: "error", + headers: { + "x-lzc-tenant": session.tenant?.id ?? "", + ...(body === undefined + ? {} + : { + "content-type": "application/json", + "x-lzc-csrf": session.csrfToken, + }), + }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); + const value: unknown = await response.json().catch(() => null); + if (!response.ok) + throw new Error( + message( + value && typeof value === "object" && "error" in value + ? value.error + : null, + ), + ); + return contextSchema.parse(value); + }, + [endpoint, session.tenant?.id, session.csrfToken], + ); + const load = useCallback( + async function load() { + controller.current?.abort(); + const active = new AbortController(); + controller.current = active; + const token = ++generation.current; + setBusy(true); + setError(""); + setNotice(""); + try { + const value = await read(active.signal); + if ( + !active.signal.aborted && + generation.current === token && + currentIdentity.current === identity + ) + install(value); + } catch (cause) { + if ( + !active.signal.aborted && + generation.current === token && + currentIdentity.current === identity + ) { + setContext(null); + setError( + cause instanceof Error && !(cause instanceof z.ZodError) + ? cause.message + : message(null), + ); + } + } finally { + if ( + !active.signal.aborted && + generation.current === token && + currentIdentity.current === identity + ) + setBusy(false); + } + }, + [read, identity, install], + ); + useEffect(() => { + setContext(null); + setConfirmed(false); + setTarget(""); + void load(); + return () => { + ++generation.current; + controller.current?.abort(); + }; + // The operation lifetime follows the authenticated order, not parent rerenders. + }, [load]); + + async function upgrade() { + if ( + !context || + !confirmed || + !target || + !context.canUpgrade || + requestedBy !== session.user.id || + busy + ) + return; + controller.current?.abort(); + const active = new AbortController(); + controller.current = active; + const token = ++generation.current; + setBusy(true); + setError(""); + setNotice(""); + try { + const value = await read(active.signal, { + targetRevision: target, + expectedRevision: context.revision, + confirmUpgrade: true, + }); + if ( + active.signal.aborted || + generation.current !== token || + currentIdentity.current !== identity + ) + return; + if ( + value.revision !== target || + value.originRevision !== context.originRevision + ) { + throw new Error(message("application_execution_upgrade_changed")); + } + install(value); + setNotice( + "Ausführungsstand aktualisiert. Bestellung und State bleiben erhalten. Starte und prüfe als Nächstes einen neuen Plan; es wurde kein Plan oder Apply ausgeführt.", + ); + await onUpgraded(active.signal); + } catch (cause) { + if ( + !active.signal.aborted && + generation.current === token && + currentIdentity.current === identity + ) { + setConfirmed(false); + setError( + cause instanceof Error && !(cause instanceof z.ZodError) + ? cause.message + : message(null), + ); + } + } finally { + if ( + !active.signal.aborted && + generation.current === token && + currentIdentity.current === identity + ) + setBusy(false); + } + } + const candidates = + context?.profiles.filter( + (p) => + p.status === "supported" && + p.revision !== context.revision && + p.revision === context.replacementRevision, + ) ?? []; + const selected = candidates.find((p) => p.revision === target); + return ( +
+

{t("Ausführungsstand der Bestellung")}

+ + {error &&

{t(error)}

} + {notice &&

{t(notice)}

} + {context && ( + <> +
+
{t("Ursprüngliche Templatequelle")}
+
+ +
+
{t("Gewünschter Ausführungsstand")}
+
+ +
+
{t("Zuletzt angewendete Quelle")}
+
+ +
+
{t("Verfügbarkeit des Ausführungsstands")}
+
+ {t( + { + supported: "Unterstützt", + withdrawn: "Zurückgezogen", + unavailable: "Runner-Paket nicht verfügbar", + }[context.sourceStatus], + )} +
+ {context.replacementRevision && ( + <> +
{t("Vorgesehener Upgrade-Pfad")}
+
+ +
+ + )} +
+ {context.sourceStatus !== "supported" && ( +

+ {t( + errors[ + context.sourceStatus === "withdrawn" + ? "application_execution_source_withdrawn" + : "application_execution_package_unavailable" + ] ?? "", + )} +

+ )} + {context.blockers.length > 0 && ( +
    + {[...new Set(context.blockers)].map((blocker) => ( +
  • {t(errors[blocker] ?? blocker)}
  • + ))} +
+ )} + {requestedBy === session.user.id && candidates.length > 0 && ( +
+ {t("Ausführungsstand explizit aktualisieren")} + + {selected && ( +
+
{t("Provider-Lock SHA256")}
+
+ + {selected.providerLockSha256.slice(0, 12)} + +
+
{t("Vertragsversion")}
+
{selected.contractVersion}
+
+ )} + + +
+ )} + + )} +
+ ); +} 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 68a2c17f..00494e29 100644 --- a/landing-zone-configurator/app/apps/web/src/components/Applications.tsx +++ b/landing-zone-configurator/app/apps/web/src/components/Applications.tsx @@ -50,6 +50,7 @@ import { t, } from "../i18n"; import type { Session } from "./Account"; +import { ApplicationExecutionLifecycle } from "./ApplicationExecutionLifecycle"; import { applicationGroupsSchema } from "./ApplicationGroups"; import { ApplicationOrderQuota } from "./ApplicationOrderQuota"; import { ApplicationStateRecovery } from "./ApplicationStateRecovery"; @@ -57,6 +58,14 @@ import { OrganizationBinding } from "./OrganizationBinding"; import { templateObservabilityConfigurable } from "./TemplateParameters"; const errors: Record = { + application_recovery_source_unavailable: + "Die ursprüngliche Recovery-Quelle wurde zurückgezogen oder ihr geprüftes Paket fehlt. Ein Operator muss ein geprüftes kompatibles Recovery-Paket bereitstellen. Bestellung, State und Sperre bleiben erhalten; Upgrade und Apply sind bis zum Abgleich gesperrt.", + application_execution_source_withdrawn: + "Dieser Ausführungsstand wurde zurückgezogen. Prüfe den angebotenen Upgrade-Pfad für dieselbe Bestellung und denselben State.", + application_execution_package_unavailable: + "Das gebundene Runner-Paket ist derzeit nicht verfügbar. Die Bestellung und ihr State bleiben erhalten. Prüfe die verfügbaren Ausführungsstände oder aktualisiere den Status.", + application_execution_binding_changed: + "Die Ausführungsbindung passt nicht mehr zum Prüfstand. Lade den Status erneut; Bestellung und State bleiben erhalten.", application_order_quota_exceeded: "Das Bestellkontingent dieses Arbeitsbereichs ist ausgeschöpft. Bestehende Bestellungen bleiben nutzbar. Bitte den Platform Owner kontaktieren.", application_order_quota_changed: @@ -152,7 +161,7 @@ const errors: Record = { application_deletions_unavailable: "Bestellungen können auf diesem Server noch nicht gelöscht werden.", application_runner_revision_required: - "Diese Bestellung verwendet eine Accelerator-Revision, die der Application-Runner nicht unterstützt. Bitte eine neue Template-Version mit der freigegebenen Runner-Revision veröffentlichen und neu bestellen.", + "Das gebundene Runner-Paket dieser Bestellung ist derzeit nicht verfügbar. Prüfe den Ausführungsstand und den angebotenen Upgrade-Pfad für dieselbe Bestellung; ihr State bleibt erhalten.", application_job_identity_unavailable: "Der STACKIT-Nachweis für diesen Plan ist abgelaufen. Bitte erneut mit STACKIT anmelden.", application_dispatch_disabled: @@ -2121,6 +2130,8 @@ export function Applications({ const [orderDeletionEnabled, setOrderDeletionEnabled] = useState(false); const [runnerRevision, setRunnerRevision] = useState(null); const [planJobsEnabled, setPlanJobsEnabled] = useState(false); + const [executionLifecycleEnabled, setExecutionLifecycleEnabled] = + useState(false); const [maintenanceEnabled, setMaintenanceEnabled] = useState(false); const [planEnabled, setPlanEnabled] = useState(false); const [applyEnabled, setApplyEnabled] = useState(false); @@ -2254,6 +2265,7 @@ export function Applications({ orderDeletionEnabled: z.boolean().default(false), executionDelegationEnabled: z.boolean().default(false), planJobsEnabled: z.boolean().default(false), + executionLifecycleEnabled: z.boolean().default(false), execution: z .object({ planEnabled: z.boolean(), @@ -2284,6 +2296,7 @@ export function Applications({ setOrderDeletionEnabled(storedOrders.orderDeletionEnabled); setRunnerRevision(storedOrders.execution.acceleratorRevision); setPlanJobsEnabled(storedOrders.planJobsEnabled); + setExecutionLifecycleEnabled(storedOrders.executionLifecycleEnabled); setPlanEnabled(storedOrders.execution.planEnabled); setApplyEnabled(storedOrders.execution.applyEnabled); setRecoveryReviewsEnabled(storedOrders.execution.recoveryReviewsEnabled); @@ -4099,6 +4112,20 @@ export function Applications({ hidden={selectedTab !== "orders"} >

{selectedInstance.name}

+ {executionLifecycleEnabled && ( + { + setPlanInput(null); + setPlanRefresh((value) => value + 1); + setOrderView("overview"); + await load(signal); + }} + /> + )} {orderDeletionEnabled && (selectedInstance.canDelete || selectedInstance.canArchive) && (