From a0029f29e41fde76b995f6bbcde6661bc1502bb5 Mon Sep 17 00:00:00 2001 From: Itamar Zand Date: Fri, 24 Jul 2026 19:42:53 +0300 Subject: [PATCH 01/10] feat: gate compute, email, and opensearch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gateability policy drops its compute arm — declining a live workload rides the same removal path as deleting it from a release, and its provisioning baseline persists so acceptance can return — and empties the not-yet-generic list now that email and opensearch render gated through the post-pass (proven by their new matrix fixtures; the SES inbound grant demonstrably follows the Email gate, and a Live gate is proven invisible to setup while its input still reaches the deployer). Worker, Daemon, Container, Email, and the experimental AwsOpenSearch builders gain .enabled(); the regenerated manifest keeps the SDK surface test honest about which lifecycles each type gates in. --- .../tests/generator/gating_matrix_tests.rs | 198 ++++++++++++++++-- crates/alien-core/src/gateability.rs | 43 ++-- .../compile_time/resource_enabled_valid.rs | 11 +- .../tests/generator/gating_matrix_tests.rs | 58 ++++- .../core/src/__tests__/gateability.test.ts | 1 + packages/core/src/__tests__/stack.test.ts | 25 ++- packages/core/src/container.ts | 25 ++- packages/core/src/daemon.ts | 25 ++- packages/core/src/email.ts | 24 ++- .../core/src/experimental/aws-opensearch.ts | 24 ++- packages/core/src/generated/gateability.json | 10 +- packages/core/src/worker.ts | 25 ++- 12 files changed, 385 insertions(+), 84 deletions(-) diff --git a/crates/alien-cloudformation/tests/generator/gating_matrix_tests.rs b/crates/alien-cloudformation/tests/generator/gating_matrix_tests.rs index 1576aeb81..ded733bf0 100644 --- a/crates/alien-cloudformation/tests/generator/gating_matrix_tests.rs +++ b/crates/alien-cloudformation/tests/generator/gating_matrix_tests.rs @@ -10,7 +10,10 @@ use super::helpers::{ resolve, try_render_built_ins, Declined, }; use alien_cloudformation::{CfRegistry, CloudFormationTarget}; -use alien_core::{Kv, Platform, Queue, ResourceLifecycle, Stack, StackSettings, Storage, Vault}; +use alien_core::{ + ownership_policy_for_resource_type, AwsOpenSearch, Email, EmailInbound, Kv, Platform, Queue, + ResourceLifecycle, ResourceRef, Stack, StackSettings, Storage, Vault, Worker, WorkerCode, +}; use std::collections::HashMap; fn gated_fixture(resource_type: &str) -> Option { @@ -42,23 +45,105 @@ fn gated_fixture(resource_type: &str) -> Option { ResourceLifecycle::Frozen, "fixtureEnabled", ), + "email" => base().add_enabled_when( + Email::new("fixture".to_string()).build(), + ResourceLifecycle::Frozen, + "fixtureEnabled", + ), + "experimental/aws-opensearch" => base().add_enabled_when( + AwsOpenSearch::new("fixture".to_string()).build(), + ResourceLifecycle::Frozen, + "fixtureEnabled", + ), _ => return None, }; Some(stack.build()) } -/// Rendered, linted, and resolved with the gate declined: the fixture must -/// leave no registration entry, and every resource the fixture contributed -/// must carry the gate's condition. -fn assert_gated_render(resource_type: &str, stack: &Stack) { +/// A gated Live resource never reaches a setup template: the generator skips +/// Live lifecycles before gate handling, so setup must render as if the +/// resource were absent while still asking the deployer for the input the +/// runtime strip resolves. +fn assert_live_gate_ignored_by_setup(resource_type: &str) { + let stack = Stack::new("matrix-stack".to_string()) + .inputs(vec![gate_input( + "fixtureEnabled", + "Enable the fixture resource", + "Whether to create the gated matrix fixture.", + )]) + .add_enabled_when( + Worker::new("fixture".to_string()) + .permissions("fixture".to_string()) + .code(WorkerCode::Image { + image: "example.com/fixture:latest".to_string(), + }) + .build(), + ResourceLifecycle::Live, + "fixtureEnabled", + ) + .build(); + let (template, _yaml) = render_built_ins_template( - stack, + &stack, StackSettings::default(), custom_resource_registration(), CloudFormationTarget::Aws, "aws", - &format!("gating matrix {resource_type}"), + &format!("gating matrix live {resource_type}"), + ); + + assert!( + !template.conditions.contains_key("InputFixtureEnabledIsTrue"), + "{resource_type}: setup never declares a condition for a Live gate" + ); + assert!( + !template + .resources + .keys() + .any(|logical_id| logical_id.to_ascii_lowercase().contains("fixture")), + "{resource_type}: a Live resource contributes nothing to setup" + ); + let payload = registration_payload(&template); + let text = + serde_json::to_string(&payload).expect("registration payload should serialize"); + assert!( + !text.contains("\"fixture\""), + "{resource_type}: a Live resource has no setup registration entry:\n{text}" + ); + assert!( + template.parameters.contains_key("InputFixtureEnabled"), + "{resource_type}: the deployer is still asked for the input the runtime strip resolves" ); +} + +/// Rendered, linted, and resolved with the gate declined: the fixture must +/// leave no registration entry, and every resource the fixture contributed +/// must carry the gate's condition. +fn assert_gated_render(resource_type: &str, stack: &Stack) { + // The local cfn-lint spec predates the OpenSearch `Generation` property + // and fails the type's ungated renders too, so its matrix cell asserts + // structure without the lint until the spec catches up. + let template = if resource_type == "experimental/aws-opensearch" { + try_render_built_ins( + stack, + StackSettings::default(), + custom_resource_registration(), + CloudFormationTarget::Aws, + "aws", + &format!("gating matrix {resource_type}"), + ) + .expect("gated render should succeed") + } else { + let (template, _yaml) = render_built_ins_template( + stack, + StackSettings::default(), + custom_resource_registration(), + CloudFormationTarget::Aws, + "aws", + &format!("gating matrix {resource_type}"), + ); + template + }; let condition_name = "InputFixtureEnabledIsTrue"; assert!( @@ -107,6 +192,10 @@ fn every_registered_emitter_is_policy_refused_or_renders_gated() { if alien_core::gate_refusal(resource_type, "matrix-fixture").is_some() { continue; } + if !ownership_policy_for_resource_type(resource_type).allows_frozen() { + assert_live_gate_ignored_by_setup(resource_type); + continue; + } match gated_fixture(resource_type) { Some(stack) => assert_gated_render(resource_type, &stack), None => allowed_without_fixture.push(resource_type.to_string()), @@ -159,31 +248,112 @@ fn a_gated_vault_renders_conditionally() { /// preflights ever running, naming type and resource. #[test] fn a_gate_on_a_policy_refused_type_fails_at_render() { + let stack = Stack::new("matrix-stack".to_string()) + .inputs(vec![gate_input( + "robotEnabled", + "Enable the robot", + "Whether to create the service account.", + )]) + .add_enabled_when( + alien_core::ServiceAccount::new("robot".to_string()).build(), + ResourceLifecycle::Frozen, + "robotEnabled", + ) + .build(); + + let error = try_render_built_ins( + &stack, + StackSettings::default(), + custom_resource_registration(), + CloudFormationTarget::Aws, + "aws", + "gated service account stack", + ) + .expect_err("the policy should refuse a gated service account at render"); + assert_eq!(error.code, "OPERATION_NOT_SUPPORTED"); + assert!(error.message.contains("service-account"), "{}", error.message); + assert!(error.message.contains("robot"), "{}", error.message); +} + +/// The first live use of the gated-contribution mechanism: Email's SES write +/// grant sits inside Storage's bucket policy, so it must follow Email's gate +/// while the bucket itself stays ungated. +#[test] +fn the_ses_inbound_grant_follows_the_email_gate() { let stack = Stack::new("matrix-stack".to_string()) .inputs(vec![gate_input( "emailEnabled", "Enable email", "Whether to create the email resource.", )]) + .add( + Storage::new("mail".to_string()).build(), + ResourceLifecycle::Frozen, + ) .add_enabled_when( - alien_core::Email::new("mailer".to_string()).build(), + Email::new("mailer".to_string()) + .inbound(EmailInbound { + storage: ResourceRef { + resource_type: Storage::RESOURCE_TYPE.clone(), + id: "mail".to_string(), + }, + }) + .build(), ResourceLifecycle::Frozen, "emailEnabled", ) .build(); - let error = try_render_built_ins( + let (template, _yaml) = render_built_ins_template( &stack, StackSettings::default(), custom_resource_registration(), CloudFormationTarget::Aws, "aws", - "gated email stack", + "gated email with inbound storage", + ); + + let (policy_id, policy) = template + .resources + .iter() + .find(|(_id, resource)| resource.resource_type == "AWS::S3::BucketPolicy") + .expect("the ungated bucket should keep its policy"); + assert!( + policy.condition.is_none(), + "{policy_id}: the bucket policy belongs to the ungated bucket" + ); + + let document = policy + .properties + .get("PolicyDocument") + .expect("bucket policy document"); + let declined = resolve( + document, + &HashMap::from([("InputEmailEnabledIsTrue", false)]), + Declined::Removed, ) - .expect_err("the policy should refuse a gated email at render"); - assert_eq!(error.code, "OPERATION_NOT_SUPPORTED"); - assert!(error.message.contains("email"), "{}", error.message); - assert!(error.message.contains("mailer"), "{}", error.message); + .expect("document resolves"); + let declined_text = serde_json::to_string(&declined).expect("serializes"); + assert!( + !declined_text.contains("ses.amazonaws.com"), + "a declined Email must take its SES grant with it:\n{declined_text}" + ); + assert!( + declined_text.contains("DenyInsecureTransport"), + "the bucket's own statements survive the decline:\n{declined_text}" + ); + + let accepted = resolve( + document, + &HashMap::from([("InputEmailEnabledIsTrue", true)]), + Declined::Removed, + ) + .expect("document resolves"); + let accepted_text = serde_json::to_string(&accepted).expect("serializes"); + assert!( + accepted_text.contains("ses.amazonaws.com"), + "an accepted Email keeps SES delivery working:\n{accepted_text}" + ); } /// Distinct ids can sanitize to the same CloudFormation parameter logical id; diff --git a/crates/alien-core/src/gateability.rs b/crates/alien-core/src/gateability.rs index e538b26be..149e49b12 100644 --- a/crates/alien-core/src/gateability.rs +++ b/crates/alien-core/src/gateability.rs @@ -52,7 +52,7 @@ const STACK_DERIVED_TYPES: &[&str] = &[ /// post-pass yet. Emptied as each type's gated render is validated; the list /// exists so switching the mechanism cannot silently open gating for a type /// nobody has rendered gated before. -const NOT_YET_GENERIC_TYPES: &[&str] = &["email", "experimental/aws-opensearch"]; +const NOT_YET_GENERIC_TYPES: &[&str] = &[]; /// Why a resource cannot carry an `.enabled()` gate. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -61,9 +61,6 @@ pub enum GateRefusal { ReservedSecretsVault, /// Framework infrastructure derived from the stack itself. DerivedFromStack, - /// Code-carrying compute ships with every release; whether it runs is a - /// rollout question, not a data-plane opt-out. - CodeCarryingCompute, /// The type's gated setup render has not been validated yet. NotYetGeneric, } @@ -83,9 +80,6 @@ impl GateRefusal { GateRefusal::DerivedFromStack => { "Alien derives this resource from the stack itself, so it cannot be optional" } - GateRefusal::CodeCarryingCompute => { - "code-carrying compute ships with every release, so it cannot be optional" - } GateRefusal::NotYetGeneric => { "this resource type's conditional setup render has not been validated yet, so \ the resource would be created regardless of the deployer's answer" @@ -104,12 +98,11 @@ pub fn gate_refusal(resource_type: &str, resource_id: &str) -> Option assert_gated_render(resource_type, platform, &stack), None => allowed_without_fixture.push(format!("{resource_type} ({platform:?})")), @@ -133,6 +138,55 @@ fn every_registered_emitter_is_policy_refused_or_renders_gated() { ); } +/// A gated Live resource never reaches a setup module: the generator skips +/// Live lifecycles before gate handling, so setup renders as if the resource +/// were absent while still asking the deployer for the input the runtime +/// strip resolves. +fn assert_live_gate_ignored_by_setup(resource_type: &str, platform: Platform) { + let Some(target) = target_for(platform) else { + return; + }; + let stack = Stack::new("matrix-stack".to_string()) + .inputs(vec![gate_input( + "fixtureEnabled", + "Enable the fixture resource", + "Whether to create the gated matrix fixture.", + )]) + .add_enabled_when( + Worker::new("fixture".to_string()) + .permissions("fixture".to_string()) + .code(WorkerCode::Image { + image: "example.com/fixture:latest".to_string(), + }) + .build(), + ResourceLifecycle::Live, + "fixtureEnabled", + ) + .build(); + + let module = render(&stack, target, StackSettings::default()); + let locals = module + .files + .get("locals.tf") + .expect("locals.tf should exist"); + assert!( + !locals.contains("var.input_fixture_enabled ?"), + "{resource_type}/{platform:?}: setup has no gated registration entry for a Live resource:\n{locals}" + ); + assert!( + !locals.contains("\"fixture\""), + "{resource_type}/{platform:?}: a Live resource has no setup registration entry:\n{locals}" + ); + let variables = module + .files + .get("variables.tf") + .expect("variables.tf should exist"); + assert!( + variables.contains("input_fixture_enabled"), + "{resource_type}/{platform:?}: the deployer is still asked for the input the runtime strip resolves" + ); +} + /// Vault is the first type whose gated render exists purely through the /// post-pass — no vault emitter ever carried gating code. Snapshots lock the /// render on each cloud. diff --git a/packages/core/src/__tests__/gateability.test.ts b/packages/core/src/__tests__/gateability.test.ts index 9b9215628..138996655 100644 --- a/packages/core/src/__tests__/gateability.test.ts +++ b/packages/core/src/__tests__/gateability.test.ts @@ -24,6 +24,7 @@ const builders: Record object> = { daemon: () => new alien.Daemon("fixture"), container: () => new alien.Container("fixture"), email: () => new alien.Email("fixture"), + "experimental/aws-opensearch": () => new alien.experimental.AwsOpenSearch("fixture"), } describe("gateability manifest", () => { diff --git a/packages/core/src/__tests__/stack.test.ts b/packages/core/src/__tests__/stack.test.ts index 6eae851e2..e55044e65 100644 --- a/packages/core/src/__tests__/stack.test.ts +++ b/packages/core/src/__tests__/stack.test.ts @@ -716,32 +716,35 @@ describe("Experimental AwsOpenSearch resource configuration", () => { }) describe("which builders offer .enabled()", () => { - // Only customer-facing data resources are gateable. Framework infra (build, - // registry, service accounts, clusters) and live-only compute must not offer - // it: a gate there is either always wrong or can only fail later, and - // ServiceAccountMutation would silently overwrite a gated "{profile}-sa" entry, - // erasing the gate before any guard. - it("customer-facing data resources have it", () => { + // Every user resource is gateable: data resources, compute (a live gate — + // declining deletes the workload, accepting recreates it), and setup-owned + // types like email. Framework infra (build, registry, service accounts, + // clusters) must not offer it: a gate there is never a customer choice, and + // ServiceAccountMutation would silently overwrite a gated "{profile}-sa" + // entry, erasing the gate before any guard. The exhaustive + // policy-vs-surface check lives in gateability.test.ts. + it("user resources have it", () => { for (const b of [ new alien.Kv("a"), new alien.Storage("a"), new alien.Queue("a"), new alien.Vault("a"), new alien.Postgres("a"), + new alien.Worker("a"), + new alien.Daemon("a"), + new alien.Container("a"), + new alien.Email("a"), + new alien.experimental.AwsOpenSearch("a"), ]) { expect(typeof (b as { enabled?: unknown }).enabled).toBe("function") } }) - it("framework-derived, live-only compute, and unconverted types do not", () => { + it("framework-derived types do not", () => { for (const b of [ new alien.Build("a"), new alien.ServiceAccount("a"), new alien.ComputeCluster("a"), - new alien.Worker("a"), - new alien.Container("a"), - new alien.Email("a"), - new alien.experimental.AwsOpenSearch("a"), ]) { expect((b as { enabled?: unknown }).enabled).toBeUndefined() } diff --git a/packages/core/src/container.ts b/packages/core/src/container.ts index fbb905b9c..08bd007f1 100644 --- a/packages/core/src/container.ts +++ b/packages/core/src/container.ts @@ -10,6 +10,7 @@ import { type ResourceSpec, type ResourceType, } from "./generated/index.js" +import type { StackInputRef } from "./input.js" import { Resource } from "./resource.js" export type PublicEndpointOptions = @@ -61,6 +62,7 @@ export interface PersistentStorageOptions { * like web services, APIs, databases, and background workers. */ export class Container { + private _enabledWhen?: string private _config: Partial = { links: [], ports: [], @@ -424,12 +426,27 @@ export class Container { * @returns An immutable Resource representing the configured container. * @throws Error if the container configuration is invalid. */ + /** + * Creates this container only when the given boolean stack input is true. + * A live gate is re-resolved on every reconcile: declining deletes the + * container (data included), accepting recreates it. + * @param input A boolean stack input declared with alien.inputs({...}). + * @returns The builder instance. + */ + public enabled(input: StackInputRef): this { + this._enabledWhen = input.id + return this + } + public build(): Resource { const config = ContainerSchema.parse(this._config) - return new Resource({ - type: "container", - ...config, - }) + return new Resource( + { + type: "container", + ...config, + }, + this._enabledWhen, + ) } } diff --git a/packages/core/src/daemon.ts b/packages/core/src/daemon.ts index 93ba9874d..c597d97b5 100644 --- a/packages/core/src/daemon.ts +++ b/packages/core/src/daemon.ts @@ -9,6 +9,7 @@ import { type ResourceSpec, type ResourceType, } from "./generated/index.js" +import type { StackInputRef } from "./input.js" import { Resource } from "./resource.js" export type { @@ -43,6 +44,7 @@ export type DaemonPublicEndpointOptions = * agents and local side services. */ export class Daemon { + private _enabledWhen?: string private _config: Partial = { links: [], publicEndpoints: [], @@ -248,12 +250,27 @@ export class Daemon { * @returns An immutable Resource representing the configured daemon. * @throws Error if the daemon configuration is invalid. */ + /** + * Creates this daemon only when the given boolean stack input is true. + * A live gate is re-resolved on every reconcile: declining deletes the + * daemon (data included), accepting recreates it. + * @param input A boolean stack input declared with alien.inputs({...}). + * @returns The builder instance. + */ + public enabled(input: StackInputRef): this { + this._enabledWhen = input.id + return this + } + public build(): Resource { const config = DaemonSchema.parse(this._config) - return new Resource({ - type: "daemon", - ...config, - }) + return new Resource( + { + type: "daemon", + ...config, + }, + this._enabledWhen, + ) } } diff --git a/packages/core/src/email.ts b/packages/core/src/email.ts index 3bc5803a3..88f096a01 100644 --- a/packages/core/src/email.ts +++ b/packages/core/src/email.ts @@ -1,4 +1,5 @@ import { type Email as EmailConfig, EmailSchema, type ResourceType } from "./generated/index.js" +import type { StackInputRef } from "./input.js" import { Resource } from "./resource.js" export type { @@ -34,6 +35,7 @@ export { EmailSchema as EmailConfigSchema } from "./generated/index.js" * `aws ses set-active-receipt-rule-set --rule-set-name `. */ export class Email { + private _enabledWhen?: string private _config: Partial = { domains: [], } @@ -104,11 +106,25 @@ export class Email { * @returns An immutable Resource representing the configured email infrastructure. * @throws Error if the email configuration is invalid. */ + /** + * Creates this email resource only when the given boolean stack input is true. + * A frozen gate's answer is fixed when the deployment is created. + * @param input A boolean stack input declared with alien.inputs({...}). + * @returns The builder instance. + */ + public enabled(input: StackInputRef): this { + this._enabledWhen = input.id + return this + } + public build(): Resource { const config = EmailSchema.parse(this._config) - return new Resource({ - type: "email", - ...config, - }) + return new Resource( + { + type: "email", + ...config, + }, + this._enabledWhen, + ) } } diff --git a/packages/core/src/experimental/aws-opensearch.ts b/packages/core/src/experimental/aws-opensearch.ts index fe5474e6c..37e122e3e 100644 --- a/packages/core/src/experimental/aws-opensearch.ts +++ b/packages/core/src/experimental/aws-opensearch.ts @@ -4,6 +4,7 @@ import { AwsOpenSearchSchema, type ResourceType, } from "../generated/index.js" +import type { StackInputRef } from "../input.js" import { Resource } from "../resource.js" export type { @@ -33,6 +34,7 @@ export { AwsOpenSearchSchema as AwsOpenSearchConfigSchema } from "../generated/i * be at most 23 characters. */ export class AwsOpenSearch { + private _enabledWhen?: string private _config: Partial = { collectionType: "search", } @@ -65,6 +67,17 @@ export class AwsOpenSearch { return this } + /** + * Creates this collection only when the given boolean stack input is true. + * A frozen gate's answer is fixed when the deployment is created. + * @param input A boolean stack input declared with alien.inputs({...}). + * @returns The AwsOpenSearch builder instance. + */ + public enabled(input: StackInputRef): this { + this._enabledWhen = input.id + return this + } + /** * Builds and validates the collection configuration. * @returns An immutable Resource representing the configured collection. @@ -73,9 +86,12 @@ export class AwsOpenSearch { public build(): Resource { const config = AwsOpenSearchSchema.parse(this._config) - return new Resource({ - type: "experimental/aws-opensearch", - ...config, - }) + return new Resource( + { + type: "experimental/aws-opensearch", + ...config, + }, + this._enabledWhen, + ) } } diff --git a/packages/core/src/generated/gateability.json b/packages/core/src/generated/gateability.json index 102713795..91f9370ea 100644 --- a/packages/core/src/generated/gateability.json +++ b/packages/core/src/generated/gateability.json @@ -1,18 +1,18 @@ { "container": { "frozen": false, - "live": false + "live": true }, "daemon": { "frozen": false, - "live": false + "live": true }, "email": { - "frozen": false, + "frozen": true, "live": false }, "experimental/aws-opensearch": { - "frozen": false, + "frozen": true, "live": false }, "kv": { @@ -37,6 +37,6 @@ }, "worker": { "frozen": false, - "live": false + "live": true } } \ No newline at end of file diff --git a/packages/core/src/worker.ts b/packages/core/src/worker.ts index dc0c912d4..c2d513683 100644 --- a/packages/core/src/worker.ts +++ b/packages/core/src/worker.ts @@ -7,6 +7,7 @@ import { WorkerSchema, type WorkerTrigger, } from "./generated/index.js" +import type { StackInputRef } from "./input.js" import { Resource } from "./resource.js" export type { @@ -36,6 +37,7 @@ export interface WorkerPublicEndpointOptions { * Workers are the primary compute resource in serverless applications, designed to be stateless and ephemeral. */ export class Worker { + private _enabledWhen?: string private _config: Partial = { links: [], triggers: [], @@ -240,12 +242,27 @@ export class Worker { * @returns An immutable Resource representing the configured worker. * @throws Error if the worker configuration is invalid (e.g., missing code). */ + /** + * Creates this worker only when the given boolean stack input is true. + * A live gate is re-resolved on every reconcile: declining deletes the + * worker, accepting recreates it. + * @param input A boolean stack input declared with alien.inputs({...}). + * @returns The builder instance. + */ + public enabled(input: StackInputRef): this { + this._enabledWhen = input.id + return this + } + public build(): Resource { const config = WorkerSchema.parse(this._config) - return new Resource({ - type: "worker", - ...config, - }) + return new Resource( + { + type: "worker", + ...config, + }, + this._enabledWhen, + ) } } From 77a0c40abadbebfefe49aa5819d4e222f700a699 Mon Sep 17 00:00:00 2001 From: Itamar Zand Date: Fri, 24 Jul 2026 19:57:54 +0300 Subject: [PATCH 02/10] fix(deployment): split the decline strip around the mutations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The initial path stripped declined resources after the mutations while the update path stripped before them, so declining a live resource on an update rederived service accounts, profiles, and capacity from a stack missing it — tripping the frozen-compatibility check that the initial path's ordering never trips. Both paths now agree: frozen declines apply before the mutations (a declined setup resource never existed, so nothing may be derived from it), and live declines apply after them, at the boundary where the executor's desired set is built, so a declined workload's provisioning baseline stays identical to the accepted render and acceptance can return. Proven by the worker decline/reaccept state-machine test. A frozen gate on a direct deployment now resolves the initial input values (provided value, else declared default, never a guess); the empty state previously carried no answer and the resource was created regardless of the deployer's choice. The manager's import route composes the two strips back to back — no mutations run between them there. --- crates/alien-deployment/src/lib.rs | 2 +- crates/alien-deployment/src/pending.rs | 197 ++++++++++++------ crates/alien-deployment/src/updating.rs | 29 ++- .../alien-deployment/tests/test_platform.rs | 92 ++++++++ crates/alien-manager/src/routes/stack.rs | 10 +- 5 files changed, 250 insertions(+), 80 deletions(-) diff --git a/crates/alien-deployment/src/lib.rs b/crates/alien-deployment/src/lib.rs index d9dd53971..abdf1da54 100644 --- a/crates/alien-deployment/src/lib.rs +++ b/crates/alien-deployment/src/lib.rs @@ -12,7 +12,7 @@ pub mod loop_contract; pub mod manager_api_transport; mod observe; mod pending; -pub use pending::strip_declined_resources; +pub use pending::{strip_declined_frozen_resources, strip_declined_live_resources}; mod provisioning; pub mod runner; mod running; diff --git a/crates/alien-deployment/src/pending.rs b/crates/alien-deployment/src/pending.rs index c663923a2..cfb945a5f 100644 --- a/crates/alien-deployment/src/pending.rs +++ b/crates/alien-deployment/src/pending.rs @@ -39,6 +39,14 @@ pub async fn handle_pending( collect_deployment_environment_info(current.platform, config.base_platform, &client_config) .await?; + // Step 2.5: Drop gated setup resources the deployer declined, BEFORE the + // mutations: a declined frozen resource never existed, and leaving it in + // would derive grants and profile entries for it — or make InitialSetup + // read it as missing-and-pending and create the very resource the + // deployer declined. + let target_stack = + strip_declined_frozen_resources(target_stack, &stack_state, &config.input_values)?; + // Step 3: Run deployment-time preflights (compile-time + mutations + runtime checks) // Store the mutated stack for use in subsequent phases (InitialSetup, Provisioning) let runner = alien_preflights::runner::PreflightRunner::new(); @@ -56,18 +64,11 @@ pub async fn handle_pending( info!("Deployment-time preflight checks completed successfully"); - // Step 3.5: Drop gated setup resources the import did not deliver. - // - // A gated resource renders behind its input in the setup template, so for - // a deployment whose frozen resources arrived through a setup import, its - // absence from the imported state IS the deployer's answer. Leaving the - // entry in the prepared stack would make InitialSetup read it as - // missing-and-pending and create the very resource the deployer declined. - // A non-empty state at Pending can only come from a setup import: Pending - // runs once, before this runner has created anything, and a direct deploy - // enters it with an empty state. - let mutated_stack = - strip_declined_resources(mutated_stack, &stack_state, &config.input_values)?; + // Step 3.5: Drop gated live resources whose input says no. Frozen + // declines were stripped before the mutations above; live declines apply + // here, after them, so a declined workload's provisioning baseline stays + // derived and acceptance can return later. + let mutated_stack = strip_declined_live_resources(mutated_stack, &config.input_values)?; // Step 4: Store prepared stack and inject environment variables let mut runtime_metadata = alien_core::RuntimeMetadata::default(); @@ -109,22 +110,21 @@ pub async fn handle_pending( }) } -/// Remove gated resources the deployer declined. +/// Remove gated setup-created resources the deployer declined. Runs BEFORE +/// the mutations on both deployment paths: a declined frozen resource never +/// existed, so nothing may be derived from it — no service-account grants, no +/// profile entries, no capacity contribution. /// -/// Two rules, one per lifecycle family: -/// - a gated setup-created resource is declined when a setup import seeded -/// the state and the resource is absent from it — the template rendered it -/// behind the input, so absence IS the answer; -/// - a gated live resource is declined when its input resolves false: the -/// provided value when present, else the input's declared boolean default. -/// Dropping it from the desired stack is what deprovisions it — the -/// executor deletes state resources absent from the desired stack, so a -/// toggle-off removes the resource AND its data by design. +/// The answer's source is the import when one seeded the state — the template +/// rendered the resource behind its input, so absence IS the answer — and the +/// initial input values on a direct deployment, where no template ever asked. +/// A non-empty state at Pending can only come from a setup import: Pending +/// runs once, before this runner has created anything, and a direct deploy +/// enters it with an empty state. /// /// An ungated resource missing from an import stays, so real drift still -/// surfaces as a failure; an unresolvable live gate is an error, never a -/// silent keep-or-drop. -pub fn strip_declined_resources( +/// surfaces as a failure. +pub fn strip_declined_frozen_resources( mut stack: Stack, stack_state: &StackState, input_values: &std::collections::HashMap, @@ -138,27 +138,68 @@ pub fn strip_declined_resources( entry.config.resource_type().as_ref(), ) .should_emit_in_setup(entry.lifecycle); + if !setup_created { + continue; + } - let is_declined = if setup_created { - !stack_state.resources.is_empty() - && !stack_state.resources.contains_key(resource_id.as_str()) - } else { + let is_declined = if stack_state.resources.is_empty() { !gate_resolves_true(&stack.inputs, input_id, input_values, resource_id)? + } else { + !stack_state.resources.contains_key(resource_id.as_str()) }; if is_declined { declined.push(resource_id.clone()); } } + remove_declined(&mut stack, &declined); + Ok(stack) +} + +/// Remove gated live resources whose input resolves false. Runs AFTER the +/// mutations on both deployment paths, at the boundary where the executor's +/// desired set is built: the mutations must keep seeing a declined live +/// resource so its provisioning baseline — service account, profile grants, +/// capacity contribution — stays stable and acceptance can return without a +/// frozen-compatibility violation. +/// +/// The answer is the provided value when present, else the input's declared +/// boolean default; anything else is an error, never a silent keep-or-drop. +/// Dropping the resource from the desired stack is what deprovisions it — the +/// executor deletes state resources absent from the desired stack, so a +/// decline removes the resource AND its data by design. +pub fn strip_declined_live_resources( + mut stack: Stack, + input_values: &std::collections::HashMap, +) -> Result { + let mut declined: Vec = Vec::new(); + for (resource_id, entry) in stack.resources() { + let Some(input_id) = entry.enabled_when.as_deref() else { + continue; + }; + let setup_created = alien_core::ownership_policy_for_resource_type( + entry.config.resource_type().as_ref(), + ) + .should_emit_in_setup(entry.lifecycle); + if setup_created { + continue; + } + + if !gate_resolves_true(&stack.inputs, input_id, input_values, resource_id)? { + declined.push(resource_id.clone()); + } + } + remove_declined(&mut stack, &declined); + Ok(stack) +} - for resource_id in &declined { +fn remove_declined(stack: &mut Stack, declined: &[String]) { + for resource_id in declined { info!( resource_id = %resource_id, "The deployer declined this gated resource; it leaves the desired stack" ); stack.resources.shift_remove(resource_id); } - - Ok(stack) } /// The deployer's answer for a live gate: the provided value, else the @@ -267,7 +308,18 @@ mod tests { } fn gated_stack() -> Stack { + gated_stack_with_default(Some(true)) + } + + fn gated_stack_with_default(default: Option) -> Stack { + let input = StackInputDefinition::deployer_boolean( + "analyticsEnabled", + "Enable analytics", + "Whether to create the analytics store.", + default, + ); Stack::new("gated-stack".to_string()) + .inputs(vec![input]) .add( ServiceAccount::new("execution-sa".to_string()).build(), ResourceLifecycle::Frozen, @@ -306,25 +358,46 @@ mod tests { Resource::new(ServiceAccount::new("execution-sa".to_string()).build()), ); - let stripped = strip_declined_resources(gated_stack(), &state, &Default::default()) - .expect("frozen rules never error"); + let stripped = + strip_declined_frozen_resources(gated_stack(), &state, &Default::default()) + .expect("an imported answer resolves without error"); assert!(!stripped.resources.contains_key("analytics")); assert!(stripped.resources.contains_key("execution-sa")); } /// An empty state means this runner creates the frozen resources itself - /// (a direct deploy), so absence carries no answer and nothing is dropped. + /// (a direct deploy), so no template ever asked the deployer: the initial + /// input values answer instead — provided value, else the declared + /// default, and never a guess. #[test] - fn nothing_is_stripped_before_anything_was_imported() { - let stripped = strip_declined_resources( - gated_stack(), + fn a_direct_deploy_frozen_gate_follows_the_input() { + let kept = strip_declined_frozen_resources( + gated_stack_with_default(Some(true)), &StackState::new(Platform::Aws), &Default::default(), ) - .expect("frozen rules never error"); + .expect("default resolves"); + assert!(kept.resources.contains_key("analytics")); - assert!(stripped.resources.contains_key("analytics")); + let dropped = strip_declined_frozen_resources( + gated_stack_with_default(Some(true)), + &StackState::new(Platform::Aws), + &std::collections::HashMap::from([( + "analyticsEnabled".to_string(), + serde_json::json!(false), + )]), + ) + .expect("provided answer resolves"); + assert!(!dropped.resources.contains_key("analytics")); + + let error = strip_declined_frozen_resources( + gated_stack_with_default(None), + &StackState::new(Platform::Aws), + &Default::default(), + ) + .expect_err("no value and no default cannot resolve"); + assert!(error.message.contains("analyticsEnabled"), "{}", error.message); } /// A gated resource the import delivered was accepted; it stays. @@ -335,8 +408,9 @@ mod tests { Resource::new(Kv::new("analytics".to_string()).build()), ); - let stripped = strip_declined_resources(gated_stack(), &state, &Default::default()) - .expect("frozen rules never error"); + let stripped = + strip_declined_frozen_resources(gated_stack(), &state, &Default::default()) + .expect("an imported answer resolves without error"); assert!(stripped.resources.contains_key("analytics")); } @@ -350,8 +424,9 @@ mod tests { Resource::new(Kv::new("analytics".to_string()).build()), ); - let stripped = strip_declined_resources(gated_stack(), &state, &Default::default()) - .expect("frozen rules never error"); + let stripped = + strip_declined_frozen_resources(gated_stack(), &state, &Default::default()) + .expect("an imported answer resolves without error"); assert!(stripped.resources.contains_key("execution-sa")); } @@ -361,9 +436,8 @@ mod tests { /// whether or not the resource already exists. #[test] fn a_live_gate_answered_false_drops_the_resource() { - let stripped = strip_declined_resources( + let stripped = strip_declined_live_resources( live_gated_stack(Some(true)), - &StackState::new(Platform::Aws), &std::collections::HashMap::from([("cacheEnabled".to_string(), serde_json::json!(false))]), ) .expect("resolvable gate"); @@ -372,9 +446,8 @@ mod tests { #[test] fn a_live_gate_answered_true_keeps_the_resource() { - let stripped = strip_declined_resources( + let stripped = strip_declined_live_resources( live_gated_stack(Some(false)), - &StackState::new(Platform::Aws), &std::collections::HashMap::from([("cacheEnabled".to_string(), serde_json::json!(true))]), ) .expect("resolvable gate"); @@ -384,32 +457,21 @@ mod tests { /// No answer given (a direct deploy): the declared default decides. #[test] fn an_unanswered_live_gate_follows_its_default() { - let kept = strip_declined_resources( - live_gated_stack(Some(true)), - &StackState::new(Platform::Aws), - &Default::default(), - ) - .expect("default resolves"); + let kept = strip_declined_live_resources(live_gated_stack(Some(true)), &Default::default()) + .expect("default resolves"); assert!(kept.resources.contains_key("cache")); - let dropped = strip_declined_resources( - live_gated_stack(Some(false)), - &StackState::new(Platform::Aws), - &Default::default(), - ) - .expect("default resolves"); + let dropped = + strip_declined_live_resources(live_gated_stack(Some(false)), &Default::default()) + .expect("default resolves"); assert!(!dropped.resources.contains_key("cache")); } /// An unresolvable gate is a fault, never a silent keep-or-drop. #[test] fn an_unresolvable_live_gate_fails_fast() { - let error = strip_declined_resources( - live_gated_stack(None), - &StackState::new(Platform::Aws), - &Default::default(), - ) - .expect_err("no value and no default cannot resolve"); + let error = strip_declined_live_resources(live_gated_stack(None), &Default::default()) + .expect_err("no value and no default cannot resolve"); assert!(error.message.contains("cacheEnabled"), "{}", error.message); } @@ -417,9 +479,8 @@ mod tests { /// this layer; a non-boolean here is corrupt input and must fail loudly. #[test] fn a_non_boolean_gate_value_fails_fast() { - let error = strip_declined_resources( + let error = strip_declined_live_resources( live_gated_stack(Some(true)), - &StackState::new(Platform::Aws), &std::collections::HashMap::from([("cacheEnabled".to_string(), serde_json::json!("false"))]), ) .expect_err("string values are not answers"); diff --git a/crates/alien-deployment/src/updating.rs b/crates/alien-deployment/src/updating.rs index 44351b8b0..bdc46ba19 100644 --- a/crates/alien-deployment/src/updating.rs +++ b/crates/alien-deployment/src/updating.rs @@ -68,16 +68,16 @@ pub async fn handle_update_pending( }) })?; - // Drop gated resources the deployer declined, BEFORE the preflights: the - // frozen-compatibility check compares against the previous prepared - // stack, which was stripped the same way, and an unstripped new stack - // would read as "frozen resource added" and refuse the update — or - // worse, resurrect the resource the deployer declined. For a live gate - // this strip is also what applies an input edit: the resource enters or - // leaves the desired stack here, and the executor's create/delete - // planning provisions or deprovisions it. Dependents share their - // dependency's gate, so the strip stays closed. - let target_stack = crate::pending::strip_declined_resources( + // Drop gated setup resources the deployer declined, BEFORE the + // preflights: the frozen-compatibility check compares against the + // previous prepared stack, which was stripped the same way, and an + // unstripped new stack would read as "frozen resource added" and refuse + // the update — or worse, resurrect the resource the deployer declined. + // Live declines apply AFTER the mutations instead, so a declined + // workload's derived baseline (service account, profile grants, capacity + // contribution) stays identical to the accepted render and the + // compatibility checks never see a difference. + let target_stack = crate::pending::strip_declined_frozen_resources( target_stack, &stack_state, &config.input_values, @@ -124,6 +124,15 @@ pub async fn handle_update_pending( info!("Deployment-time preflight checks completed successfully"); + // Drop gated live resources whose input says no — after the mutations, + // at the boundary where the executor's desired set is built. For a live + // gate this strip is what applies an input edit: the resource enters or + // leaves the desired stack here, and the executor's create/delete + // planning provisions or deprovisions it. Dependents share their + // dependency's gate, so the strip stays closed. + let mutated_stack = + crate::pending::strip_declined_live_resources(mutated_stack, &config.input_values)?; + // Store the mutated stack in runtime_metadata for future compatibility checks let mut runtime_metadata = current.runtime_metadata.unwrap_or_default(); runtime_metadata.pending_prepared_stack = Some(mutated_stack); diff --git a/crates/alien-deployment/tests/test_platform.rs b/crates/alien-deployment/tests/test_platform.rs index 99b67a2e1..c5a374fb9 100644 --- a/crates/alien-deployment/tests/test_platform.rs +++ b/crates/alien-deployment/tests/test_platform.rs @@ -1545,6 +1545,98 @@ async fn live_gate_flip_deprovisions_and_reprovisions_across_updates() { assert_eq!(store.status, alien_core::ResourceStatus::Running); } +/// The split-strip ordering guarantee on compute: declining a live worker on +/// an update removes the workload but keeps its derived baseline — the +/// profile-derived service account stays in the prepared stack, so the +/// frozen-compatibility check never fires and a later acceptance recreates +/// the worker. +#[tokio::test] +async fn a_declined_live_worker_keeps_its_derived_baseline_across_updates() { + let _temp_dir = TempDir::new().expect("Failed to create temp dir"); + + let mut stack = create_test_stack("gated-stack", "proxy"); + stack + .resources + .get_mut("proxy") + .expect("worker entry") + .enabled_when = Some("proxyEnabled".to_string()); + stack.inputs = vec![boolean_gate_input( + "proxyEnabled", + "Enable the proxy", + "Whether to run the proxy worker.", + )]; + + fn config_with_proxy_enabled(enabled: bool) -> DeploymentConfig { + let mut config = create_test_config("hash_v1", false); + config.input_values = + HashMap::from([("proxyEnabled".to_string(), serde_json::json!(enabled))]); + config + } + + fn prepared_stack_of(state: &DeploymentState) -> &Stack { + state + .runtime_metadata + .as_ref() + .and_then(|metadata| metadata.prepared_stack.as_ref()) + .expect("a completed deployment stores its prepared stack") + } + + let mut state = create_initial_state(stack.clone()); + + // Accepted (default true): the worker runs and the mutations derived its + // service account into the prepared stack. + state = run_to_completion(state, config_with_proxy_enabled(true)).await; + assert_eq!(state.status, DeploymentStatus::Running); + assert!(state + .stack_state + .as_ref() + .unwrap() + .resources + .contains_key("proxy")); + assert!( + prepared_stack_of(&state).resources.contains_key("default-sa"), + "the profile-derived service account belongs to the prepared stack: {:?}", + prepared_stack_of(&state).resources.keys().collect::>() + ); + + // Declined on an update: the worker is deprovisioned, the deployment + // converges, and the derived service account is still in the prepared + // stack — the mutations saw the declined worker, only the executor's + // desired set lost it. + start_update(&mut state, release_of("rel_v2", stack.clone())); + state = run_to_completion(state, config_with_proxy_enabled(false)).await; + assert_eq!(state.status, DeploymentStatus::Running); + assert!(!state + .stack_state + .as_ref() + .unwrap() + .resources + .contains_key("proxy")); + assert!( + prepared_stack_of(&state).resources.contains_key("default-sa"), + "declining the worker must not strip its derived baseline: {:?}", + prepared_stack_of(&state).resources.keys().collect::>() + ); + assert!( + !prepared_stack_of(&state).resources.contains_key("proxy"), + "the declined worker itself leaves the prepared stack" + ); + + // Accepted again: the worker comes back without any compatibility + // refusal, because nothing derived ever changed. + start_update(&mut state, release_of("rel_v3", stack)); + state = run_to_completion(state, config_with_proxy_enabled(true)).await; + assert_eq!(state.status, DeploymentStatus::Running); + let proxy = state + .stack_state + .as_ref() + .unwrap() + .resources + .get("proxy") + .expect("an accepted gate must recreate the worker"); + assert_eq!(proxy.status, alien_core::ResourceStatus::Running); +} + /// A setup import that omitted a gated frozen resource: the runner must not /// create the resource the deployer declined, while the delivered sibling /// and the live function still deploy. diff --git a/crates/alien-manager/src/routes/stack.rs b/crates/alien-manager/src/routes/stack.rs index 4fba5c43a..e791d0b63 100644 --- a/crates/alien-manager/src/routes/stack.rs +++ b/crates/alien-manager/src/routes/stack.rs @@ -190,7 +190,10 @@ pub async fn stack_import( // declined entry in would make the runner create the very resource the // deployer said no to. A live gated resource follows the request's input // values here for the same reason. - let prepared_stack = match alien_deployment::strip_declined_resources( + // No mutations run between the two strips on the import path: the + // registered stack is the already-mutated release render, so both + // families resolve here, back to back. + let prepared_stack = match alien_deployment::strip_declined_frozen_resources( prepared_stack, &stack_state, &req.input_values, @@ -198,6 +201,11 @@ pub async fn stack_import( Ok(stack) => stack, Err(e) => return e.into_response(), }; + let prepared_stack = + match alien_deployment::strip_declined_live_resources(prepared_stack, &req.input_values) { + Ok(stack) => stack, + Err(e) => return e.into_response(), + }; let environment_info = infer_import_environment_info(&req); match state .deployment_store From 720068a0b24455bf6d12301929d480f7f05b0547 Mon Sep 17 00:00:00 2001 From: Itamar Zand Date: Fri, 24 Jul 2026 20:16:50 +0300 Subject: [PATCH 03/10] feat(deployment): a frozen gate is answered once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The canonical resolved answer for every input gating a Frozen resource is recorded on the runtime metadata when the deployment is created — the same record/state/sync surface that already carries the prepared stack, so no store or protocol shape changes. The update path refuses input values that conflict with a recorded answer; states from before this contract derive their answers from the settled stack state (presence IS the original answer once setup completed) and upgrade atomically before the check, refusing rather than guessing when resources sharing one gate disagree. Deployment protocol CURRENT moves to 2 (MIN stays 1): an actor unaware of the fixity contract would step a deployment against the deployer's recorded answer, so older actors refuse newer states instead. Live-gate transitions now emit a structured audit event when detected, with an operation id derived from resource, input, answer, and release — identical across retries of the same step by construction, fresh for a later flip — completed or failed by the executor's ordinary per-resource status transitions. The preflight gains the pause-consumer contract tests: a gated worker consuming an ungated queue passes (the queue's retention policy governs the backlog), while an ungated worker consuming a gated queue stays refused. --- crates/alien-core/src/deployment/state.rs | 22 +- crates/alien-deployment/src/error.rs | 37 +++ crates/alien-deployment/src/pending.rs | 249 +++++++++++++++++- crates/alien-deployment/src/updating.rs | 27 ++ .../alien-deployment/tests/test_platform.rs | 79 ++++++ .../compile_time/resource_enabled_valid.rs | 61 +++++ 6 files changed, 469 insertions(+), 6 deletions(-) diff --git a/crates/alien-core/src/deployment/state.rs b/crates/alien-core/src/deployment/state.rs index e96d4088e..05bef1c64 100644 --- a/crates/alien-core/src/deployment/state.rs +++ b/crates/alien-core/src/deployment/state.rs @@ -3,6 +3,7 @@ use crate::{ObservedInventoryBatch, Platform, ResourceHeartbeat, StackState}; use alien_error::AlienError; use bon::Builder; +use indexmap::IndexMap; use serde::{Deserialize, Serialize}; use super::{DeploymentStatus, EnvironmentInfo, ReleaseInfo}; @@ -52,6 +53,16 @@ pub struct RuntimeMetadata { #[serde(skip_serializing_if = "Option::is_none")] pub prepared_stack: Option, + /// Canonical resolved answers for inputs that gate Frozen resources, + /// keyed by input id, recorded when the deployment is created (or derived + /// from the settled stack state on the first update of an older state). + /// + /// A frozen gate's answer is fixed for the deployment's lifetime: the + /// update path refuses input values that conflict with these, and a Live + /// resource sharing such an input resolves the persisted answer forever. + #[serde(default, skip_serializing_if = "IndexMap::is_empty")] + pub persisted_gate_answers: GateAnswers, + /// Prepared target for an update that has not reached Running yet. Keeping /// it separate preserves the last successful baseline across retries. #[serde(default, skip_serializing_if = "Option::is_none")] @@ -170,12 +181,21 @@ pub(crate) fn is_false(b: &bool) -> bool { !*b } +/// Answers for inputs gating Frozen resources, keyed by input id. +pub type GateAnswers = IndexMap; + /// Oldest deployment protocol version this binary can read. pub const MIN_SUPPORTED_DEPLOYMENT_PROTOCOL_VERSION: u32 = 1; /// Deployment protocol version this binary writes. /// Bump when making incompatible changes to DeploymentState semantics. -pub const CURRENT_DEPLOYMENT_PROTOCOL_VERSION: u32 = 1; +/// +/// Version 2 added the frozen-gate fixity contract (`persisted_gate_answers` +/// on the runtime metadata): an actor unaware of it would skip the fixity +/// check and could resurrect or delete a setup-created resource against the +/// deployer's recorded answer, so older actors must refuse v2 states rather +/// than step them. +pub const CURRENT_DEPLOYMENT_PROTOCOL_VERSION: u32 = 2; /// Backwards-compatible alias for older call sites. pub const DEPLOYMENT_PROTOCOL_VERSION: u32 = CURRENT_DEPLOYMENT_PROTOCOL_VERSION; diff --git a/crates/alien-deployment/src/error.rs b/crates/alien-deployment/src/error.rs index 61accb0e3..5269cd53d 100644 --- a/crates/alien-deployment/src/error.rs +++ b/crates/alien-deployment/src/error.rs @@ -19,6 +19,43 @@ pub enum ErrorData { repair: String, }, + /// An update supplied a value for an input whose frozen-gate answer is + /// already fixed for the deployment's lifetime. + #[error( + code = "FROZEN_GATE_ANSWER_CHANGED", + message = "Input '{input_id}' gates a setup-created resource and its answer was fixed at \ + {persisted} when this deployment was created; the update supplies {requested}. \ + A frozen gate cannot be re-answered — create a new deployment for a different \ + answer", + retryable = "false", + internal = "false", + http_status_code = 400 + )] + FrozenGateAnswerChanged { + /// The input whose answer the update tried to change + input_id: String, + /// The answer recorded when the deployment was created + persisted: bool, + /// The conflicting answer the update supplied + requested: bool, + }, + + /// A frozen gate's answer could not be derived while upgrading an older + /// deployment state to the fixity-aware protocol. + #[error( + code = "FROZEN_GATE_ANSWER_UNDERIVABLE", + message = "Cannot derive the recorded answer for input '{input_id}': {reason}. Refusing \ + the update rather than guessing a frozen resource's existence", + retryable = "false", + internal = "false" + )] + FrozenGateAnswerUnderivable { + /// The input whose answer could not be derived + input_id: String, + /// Why derivation failed + reason: String, + }, + /// Environment information collection failed. #[error( code = "ENVIRONMENT_INFO_COLLECTION_FAILED", diff --git a/crates/alien-deployment/src/pending.rs b/crates/alien-deployment/src/pending.rs index cfb945a5f..d9dd443b4 100644 --- a/crates/alien-deployment/src/pending.rs +++ b/crates/alien-deployment/src/pending.rs @@ -39,11 +39,15 @@ pub async fn handle_pending( collect_deployment_environment_info(current.platform, config.base_platform, &client_config) .await?; - // Step 2.5: Drop gated setup resources the deployer declined, BEFORE the - // mutations: a declined frozen resource never existed, and leaving it in - // would derive grants and profile entries for it — or make InitialSetup - // read it as missing-and-pending and create the very resource the - // deployer declined. + // Step 2.5: Record the frozen-gate answers, then drop gated setup + // resources the deployer declined, BEFORE the mutations: a declined + // frozen resource never existed, and leaving it in would derive grants + // and profile entries for it — or make InitialSetup read it as + // missing-and-pending and create the very resource the deployer declined. + // The recorded answers are what the update path holds every later input + // value against: a frozen gate is answered once. + let persisted_gate_answers = + resolve_frozen_gate_answers(&target_stack, &stack_state, &config.input_values)?; let target_stack = strip_declined_frozen_resources(target_stack, &stack_state, &config.input_values)?; @@ -73,6 +77,7 @@ pub async fn handle_pending( // Step 4: Store prepared stack and inject environment variables let mut runtime_metadata = alien_core::RuntimeMetadata::default(); runtime_metadata.prepared_stack = Some(mutated_stack.clone()); + runtime_metadata.persisted_gate_answers = persisted_gate_answers; // Inject environment variables into the prepared stack for validation let mut mutated_stack_with_env = mutated_stack; @@ -99,6 +104,7 @@ pub async fn handle_pending( next.error = None; next.environment_info = environment_info; next.runtime_metadata = Some(runtime_metadata); + next.protocol_version = alien_core::CURRENT_DEPLOYMENT_PROTOCOL_VERSION; // Error handled in DeploymentStepResult Ok(DeploymentStepResult { @@ -192,6 +198,155 @@ pub fn strip_declined_live_resources( Ok(stack) } +/// The canonical resolved answers for every input that gates a Frozen +/// resource in `stack`, from the same sources the frozen strip reads: the +/// import when one seeded the state (per-resource presence, which must agree +/// across resources sharing one input), else the initial input values. +/// +/// Recorded on the deployment at creation; the update path refuses input +/// values that conflict with them for the deployment's lifetime. +pub fn resolve_frozen_gate_answers( + stack: &Stack, + stack_state: &StackState, + input_values: &std::collections::HashMap, +) -> Result { + let mut answers = alien_core::GateAnswers::new(); + for (resource_id, entry) in stack.resources() { + let Some(input_id) = entry.enabled_when.as_deref() else { + continue; + }; + let setup_created = alien_core::ownership_policy_for_resource_type( + entry.config.resource_type().as_ref(), + ) + .should_emit_in_setup(entry.lifecycle); + if !setup_created { + continue; + } + + let answer = if stack_state.resources.is_empty() { + gate_resolves_true(&stack.inputs, input_id, input_values, resource_id)? + } else { + stack_state.resources.contains_key(resource_id.as_str()) + }; + if let Some(previous) = answers.insert(input_id.to_string(), answer) { + if previous != answer { + return Err(AlienError::new( + crate::error::ErrorData::FrozenGateAnswerUnderivable { + input_id: input_id.to_string(), + reason: format!( + "resources sharing this gate disagree — '{resource_id}' resolves \ + {answer} while a sibling resolved {previous}; the template renders \ + them behind one input, so a consistent import cannot produce this" + ), + }, + ) + .into()); + } + } + } + Ok(answers) +} + +/// Refuse an update whose input values conflict with a persisted frozen-gate +/// answer. Inputs the update does not mention keep their recorded answer. +pub fn enforce_frozen_gate_fixity( + persisted: &alien_core::GateAnswers, + input_values: &std::collections::HashMap, +) -> Result<()> { + for (input_id, persisted_answer) in persisted { + let Some(value) = input_values.get(input_id) else { + continue; + }; + let Some(requested) = value.as_bool() else { + return Err(AlienError::new(ErrorData::MissingConfiguration { + message: format!( + "Input '{input_id}' gates a setup-created resource but its value is not a \ + boolean: {value}" + ), + })); + }; + if requested != *persisted_answer { + return Err(AlienError::new( + crate::error::ErrorData::FrozenGateAnswerChanged { + input_id: input_id.clone(), + persisted: *persisted_answer, + requested, + }, + ) + .into()); + } + } + Ok(()) +} + +/// Audit the gate-driven transitions this update requests: a live decline +/// that will delete an existing resource (data included) and a live +/// acceptance that will recreate a previously declined one. +/// +/// The operation id derives from what makes the transition itself — resource, +/// input, answer, release — so a retried step logs the same id (correlate, +/// don't double-count) while a later flip in another release gets a fresh +/// one. Completion and failure are the executor's per-resource status +/// transitions, correlated by resource id; both flow through the ordinary +/// tracing pipeline and the deployment-state sync. +pub fn audit_live_gate_transitions( + stack: &Stack, + stack_state: &StackState, + input_values: &std::collections::HashMap, + release_id: Option<&str>, +) { + for (resource_id, entry) in stack.resources() { + let Some(input_id) = entry.enabled_when.as_deref() else { + continue; + }; + let setup_created = alien_core::ownership_policy_for_resource_type( + entry.config.resource_type().as_ref(), + ) + .should_emit_in_setup(entry.lifecycle); + if setup_created { + continue; + } + let Ok(accepted) = gate_resolves_true(&stack.inputs, input_id, input_values, resource_id) + else { + // The strip right after this reports the unresolvable gate as the + // step's error; nothing to audit for a step that will not run. + continue; + }; + let exists = stack_state.resources.contains_key(resource_id.as_str()); + let (transition, value_source) = match (accepted, exists) { + (false, true) => ("delete", source_of(input_values, input_id)), + (true, false) => ("create", source_of(input_values, input_id)), + _ => continue, + }; + let release = release_id.unwrap_or("unversioned"); + let operation_id = format!("gate:{resource_id}:{input_id}:{accepted}:{release}"); + info!( + audit = "live-gate", + phase = "requested", + operation_id = %operation_id, + resource_id = %resource_id, + input_id = %input_id, + resolved_value = accepted, + value_source = value_source, + lifecycle = "live", + transition = transition, + "A live gate transition was requested; the executor's status \ + transitions for this resource complete or fail it" + ); + } +} + +fn source_of( + input_values: &std::collections::HashMap, + input_id: &str, +) -> &'static str { + if input_values.contains_key(input_id) { + "provided" + } else { + "default" + } +} + fn remove_declined(stack: &mut Stack, declined: &[String]) { for resource_id in declined { info!( @@ -475,6 +630,90 @@ mod tests { assert!(error.message.contains("cacheEnabled"), "{}", error.message); } + /// Answers derive from import presence when a state exists, from the + /// initial input values on a direct deploy, and refuse to guess when + /// resources sharing one gate disagree. + #[test] + fn frozen_gate_answers_resolve_from_their_provenance() { + let imported = imported_state_with( + "analytics", + Resource::new(Kv::new("analytics".to_string()).build()), + ); + let answers = + resolve_frozen_gate_answers(&gated_stack(), &imported, &Default::default()) + .expect("presence resolves"); + assert_eq!(answers.get("analyticsEnabled"), Some(&true)); + + let declined = imported_state_with( + "execution-sa", + Resource::new(ServiceAccount::new("execution-sa".to_string()).build()), + ); + let answers = + resolve_frozen_gate_answers(&gated_stack(), &declined, &Default::default()) + .expect("absence resolves"); + assert_eq!(answers.get("analyticsEnabled"), Some(&false)); + + let direct = resolve_frozen_gate_answers( + &gated_stack_with_default(Some(false)), + &StackState::new(Platform::Aws), + &Default::default(), + ) + .expect("the declared default resolves"); + assert_eq!(direct.get("analyticsEnabled"), Some(&false)); + } + + #[test] + fn a_shared_gate_with_disagreeing_imports_is_refused() { + let mut stack = gated_stack(); + stack.resources.insert( + "metrics".to_string(), + alien_core::ResourceEntry { + config: Resource::new(Kv::new("metrics".to_string()).build()), + lifecycle: ResourceLifecycle::Frozen, + dependencies: Vec::new(), + remote_access: false, + enabled_when: Some("analyticsEnabled".to_string()), + }, + ); + // The import delivered one of the two resources behind the gate. + let state = imported_state_with( + "analytics", + Resource::new(Kv::new("analytics".to_string()).build()), + ); + + let error = resolve_frozen_gate_answers(&stack, &state, &Default::default()) + .expect_err("a half-delivered shared gate cannot resolve"); + assert_eq!(error.code, "FROZEN_GATE_ANSWER_UNDERIVABLE"); + } + + /// A persisted answer wins over any later input value; inputs the update + /// does not mention keep their recorded answer silently. + #[test] + fn fixity_refuses_conflicting_answers_only() { + let persisted = alien_core::GateAnswers::from_iter([("analyticsEnabled".to_string(), false)]); + + enforce_frozen_gate_fixity(&persisted, &Default::default()) + .expect("an unmentioned input keeps its answer"); + enforce_frozen_gate_fixity( + &persisted, + &std::collections::HashMap::from([( + "analyticsEnabled".to_string(), + serde_json::json!(false), + )]), + ) + .expect("a matching answer passes"); + + let error = enforce_frozen_gate_fixity( + &persisted, + &std::collections::HashMap::from([( + "analyticsEnabled".to_string(), + serde_json::json!(true), + )]), + ) + .expect_err("a flipped answer is refused"); + assert_eq!(error.code, "FROZEN_GATE_ANSWER_CHANGED"); + } + /// Input values are coerced to their declared kinds before they reach /// this layer; a non-boolean here is corrupt input and must fail loudly. #[test] diff --git a/crates/alien-deployment/src/updating.rs b/crates/alien-deployment/src/updating.rs index bdc46ba19..100269ddf 100644 --- a/crates/alien-deployment/src/updating.rs +++ b/crates/alien-deployment/src/updating.rs @@ -68,6 +68,26 @@ pub async fn handle_update_pending( }) })?; + // A frozen gate is answered once. States from before the fixity-aware + // protocol carry no recorded answers, so derive them from the settled + // stack state — presence IS the original answer once setup completed — + // and upgrade atomically before the check, refusing rather than guessing + // when derivation is impossible. + let mut persisted_gate_answers = current + .runtime_metadata + .as_ref() + .map(|metadata| metadata.persisted_gate_answers.clone()) + .unwrap_or_default(); + if persisted_gate_answers.is_empty() { + persisted_gate_answers = crate::pending::resolve_frozen_gate_answers( + &target_stack, + &stack_state, + &Default::default(), + )?; + next.protocol_version = alien_core::CURRENT_DEPLOYMENT_PROTOCOL_VERSION; + } + crate::pending::enforce_frozen_gate_fixity(&persisted_gate_answers, &config.input_values)?; + // Drop gated setup resources the deployer declined, BEFORE the // preflights: the frozen-compatibility check compares against the // previous prepared stack, which was stripped the same way, and an @@ -130,12 +150,19 @@ pub async fn handle_update_pending( // leaves the desired stack here, and the executor's create/delete // planning provisions or deprovisions it. Dependents share their // dependency's gate, so the strip stays closed. + crate::pending::audit_live_gate_transitions( + &mutated_stack, + &stack_state, + &config.input_values, + target_release_id, + ); let mutated_stack = crate::pending::strip_declined_live_resources(mutated_stack, &config.input_values)?; // Store the mutated stack in runtime_metadata for future compatibility checks let mut runtime_metadata = current.runtime_metadata.unwrap_or_default(); runtime_metadata.pending_prepared_stack = Some(mutated_stack); + runtime_metadata.persisted_gate_answers = persisted_gate_answers; // Transition to Updating next.status = DeploymentStatus::Updating; diff --git a/crates/alien-deployment/tests/test_platform.rs b/crates/alien-deployment/tests/test_platform.rs index c5a374fb9..828e17c52 100644 --- a/crates/alien-deployment/tests/test_platform.rs +++ b/crates/alien-deployment/tests/test_platform.rs @@ -1545,6 +1545,85 @@ async fn live_gate_flip_deprovisions_and_reprovisions_across_updates() { assert_eq!(store.status, alien_core::ResourceStatus::Running); } +/// A frozen gate is answered once: the answer recorded at creation refuses +/// every later conflicting input value, including on states from before the +/// fixity-aware protocol, whose answers derive from the settled stack state. +#[tokio::test] +async fn a_frozen_gate_answer_is_fixed_for_the_deployment_lifetime() { + let _temp_dir = TempDir::new().expect("Failed to create temp dir"); + + // A frozen store gated with a declared default of true. + let mut stack = create_test_stack("fixed-stack", "test-function"); + stack.resources.insert( + "archive".to_string(), + ResourceEntry { + config: alien_core::Resource::new(Storage::new("archive".to_string()).build()), + lifecycle: ResourceLifecycle::Frozen, + dependencies: Vec::new(), + remote_access: false, + enabled_when: Some("archiveEnabled".to_string()), + }, + ); + stack.inputs = vec![boolean_gate_input( + "archiveEnabled", + "Enable the archive", + "Whether to create the archive store.", + )]; + + fn config_with_archive_enabled(enabled: bool) -> DeploymentConfig { + let mut config = create_test_config("hash_v1", false); + config.input_values = + HashMap::from([("archiveEnabled".to_string(), serde_json::json!(enabled))]); + config + } + + let mut state = create_initial_state(stack.clone()); + state = run_to_completion(state, config_with_archive_enabled(true)).await; + assert_eq!(state.status, DeploymentStatus::Running); + assert_eq!( + state + .runtime_metadata + .as_ref() + .and_then(|metadata| metadata.persisted_gate_answers.get("archiveEnabled")), + Some(&true), + "the answer is recorded at creation" + ); + + // A conflicting answer on an update is refused, not applied. + start_update(&mut state, release_of("rel_v2", stack.clone())); + let error = alien_deployment::step( + state.clone(), + config_with_archive_enabled(false), + ClientConfig::Test, + None, + ) + .await + .expect_err("a flipped frozen answer must refuse the update"); + assert_eq!(error.code, "FROZEN_GATE_ANSWER_CHANGED"); + + // The same answer passes and the update completes. + let mut state_matching = state.clone(); + state_matching = run_to_completion(state_matching, config_with_archive_enabled(true)).await; + assert_eq!(state_matching.status, DeploymentStatus::Running); + + // A state from before the fixity protocol (no recorded answers): the + // settled stack state derives them, and the conflict is still refused. + let mut legacy = state.clone(); + if let Some(metadata) = legacy.runtime_metadata.as_mut() { + metadata.persisted_gate_answers = Default::default(); + } + legacy.protocol_version = 1; + let error = alien_deployment::step( + legacy, + config_with_archive_enabled(false), + ClientConfig::Test, + None, + ) + .await + .expect_err("derived answers refuse the conflict too"); + assert_eq!(error.code, "FROZEN_GATE_ANSWER_CHANGED"); +} + /// The split-strip ordering guarantee on compute: declining a live worker on /// an update removes the workload but keeps its derived baseline — the /// profile-derived service account stays in the prepared stack, so the diff --git a/crates/alien-preflights/src/compile_time/resource_enabled_valid.rs b/crates/alien-preflights/src/compile_time/resource_enabled_valid.rs index bcaed500b..6308e4259 100644 --- a/crates/alien-preflights/src/compile_time/resource_enabled_valid.rs +++ b/crates/alien-preflights/src/compile_time/resource_enabled_valid.rs @@ -301,6 +301,67 @@ mod tests { assert!(errors_for(stack).await.is_empty()); } + /// Pausing the sole consumer is the point of gating compute: the worker + /// is the dependent of the ungated queue, so nothing dangles — producers + /// keep enqueuing and the queue's retention policy governs the backlog + /// until the deployer accepts the worker again. + #[tokio::test] + async fn accepts_a_gated_worker_consuming_an_ungated_queue() { + let queue = alien_core::Queue::new("jobs".to_string()).build(); + let worker = Worker::new("consumer".to_string()) + .permissions("consumer".to_string()) + .code(WorkerCode::Image { + image: "example.com/consumer:latest".to_string(), + }) + .trigger(alien_core::WorkerTrigger::Queue { + queue: alien_core::ResourceRef { + resource_type: alien_core::Queue::RESOURCE_TYPE.clone(), + id: "jobs".to_string(), + }, + }) + .build(); + + let stack = Stack::new("test-stack".to_string()) + .inputs(vec![boolean_input()]) + .add(queue, ResourceLifecycle::Frozen) + .add_enabled_when(worker, ResourceLifecycle::Live, "storeEnabled") + .build(); + + assert!(errors_for(stack).await.is_empty()); + } + + /// The reverse stays closed: an ungated worker consuming a gated queue + /// would resolve a binding for a queue that may never exist. + #[tokio::test] + async fn rejects_an_ungated_worker_consuming_a_gated_queue() { + let queue = alien_core::Queue::new("jobs".to_string()).build(); + let worker = Worker::new("consumer".to_string()) + .permissions("consumer".to_string()) + .code(WorkerCode::Image { + image: "example.com/consumer:latest".to_string(), + }) + .trigger(alien_core::WorkerTrigger::Queue { + queue: alien_core::ResourceRef { + resource_type: alien_core::Queue::RESOURCE_TYPE.clone(), + id: "jobs".to_string(), + }, + }) + .build(); + + let stack = Stack::new("test-stack".to_string()) + .inputs(vec![boolean_input()]) + .add_enabled_when(queue, ResourceLifecycle::Frozen, "storeEnabled") + .add(worker, ResourceLifecycle::Live) + .build(); + + let errors = errors_for(stack).await; + assert_eq!(errors.len(), 1, "{errors:?}"); + assert!( + errors[0].contains("depends on 'jobs'"), + "{errors:?}" + ); + } + #[tokio::test] async fn rejects_an_undeclared_input() { let mut input = boolean_input(); From d773dedea9d1012359924d1d9b74c4f0bd82a113 Mon Sep 17 00:00:00 2001 From: Itamar Zand Date: Fri, 24 Jul 2026 23:04:21 +0300 Subject: [PATCH 04/10] feat(manager): record and enforce frozen-gate answers at import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The import route now resolves every frozen-gating input's answer before the strip and records it on the created deployment, so imported deployments carry their answers from birth instead of deriving them on their first update. A re-registration whose derived answers conflict with the recorded ones is refused synchronously with FROZEN_GATE_ANSWER_CHANGED — and because the caller is the setup artifact's custom resource mid-stack-update, the failed response is what forces CloudFormation to roll the parameter edit back, restoring whatever its conditionals just created or deleted. The refusal runs before setup-update authorization is minted, so an answer flip cannot ride an otherwise-authorized setup update; answers for inputs a new release introduces are recorded, and recorded answers are never overwritten. --- crates/alien-deployment/src/lib.rs | 5 +- crates/alien-manager/src/routes/stack.rs | 65 +++++++++++- crates/alien-manager/tests/stack_import.rs | 117 +++++++++++++++++++++ 3 files changed, 182 insertions(+), 5 deletions(-) diff --git a/crates/alien-deployment/src/lib.rs b/crates/alien-deployment/src/lib.rs index abdf1da54..45fe17bd7 100644 --- a/crates/alien-deployment/src/lib.rs +++ b/crates/alien-deployment/src/lib.rs @@ -12,7 +12,10 @@ pub mod loop_contract; pub mod manager_api_transport; mod observe; mod pending; -pub use pending::{strip_declined_frozen_resources, strip_declined_live_resources}; +pub use pending::{ + enforce_frozen_gate_fixity, resolve_frozen_gate_answers, strip_declined_frozen_resources, + strip_declined_live_resources, +}; mod provisioning; pub mod runner; mod running; diff --git a/crates/alien-manager/src/routes/stack.rs b/crates/alien-manager/src/routes/stack.rs index e791d0b63..9b8eec6a5 100644 --- a/crates/alien-manager/src/routes/stack.rs +++ b/crates/alien-manager/src/routes/stack.rs @@ -190,6 +190,18 @@ pub async fn stack_import( // declined entry in would make the runner create the very resource the // deployer said no to. A live gated resource follows the request's input // values here for the same reason. + // The answers are resolved before the strip — a declined resource must + // still be in the stack for its input to be enumerated — and recorded on + // the deployment, where the fixity check holds every later value against + // them. + let imported_gate_answers = match alien_deployment::resolve_frozen_gate_answers( + &prepared_stack, + &stack_state, + &req.input_values, + ) { + Ok(answers) => answers, + Err(e) => return e.into_response(), + }; // No mutations run between the two strips on the import path: the // registered stack is the already-mutated release render, so both // families resolve here, back to back. @@ -293,8 +305,38 @@ pub async fn stack_import( }) .into_response(); } - let runtime_metadata = - match reimport_runtime_metadata(&existing, &prepared_stack, &release.id, &req) { + // A frozen gate is answered once. Refusing a changed answer HERE + // — inside the registration the setup artifact calls + // synchronously — is what makes a CloudFormation parameter edit + // roll the whole stack update back: the custom resource fails, + // and CloudFormation restores whatever its conditionals just + // created or deleted. + let persisted_answers = existing + .runtime_metadata + .as_ref() + .map(|metadata| metadata.persisted_gate_answers.clone()) + .unwrap_or_default(); + for (input_id, imported_answer) in &imported_gate_answers { + if let Some(persisted) = persisted_answers.get(input_id) { + if persisted != imported_answer { + return AlienError::new( + alien_deployment::ErrorData::FrozenGateAnswerChanged { + input_id: input_id.clone(), + persisted: *persisted, + requested: *imported_answer, + }, + ) + .into_response(); + } + } + } + let runtime_metadata = match reimport_runtime_metadata( + &existing, + &prepared_stack, + &release.id, + &req, + imported_gate_answers, + ) { Ok(metadata) => metadata, Err(error) => return error.into_response(), }; @@ -353,7 +395,7 @@ pub async fn stack_import( Err(e) => return e.into_response(), } - let runtime_metadata = import_runtime_metadata(&prepared_stack); + let runtime_metadata = import_runtime_metadata(&prepared_stack, imported_gate_answers); let create_ctx = crate::auth::DeploymentCreateCtx { workspace_id: &dg.workspace_id, @@ -808,9 +850,13 @@ fn imported_resources_are_unchanged( }) } -fn import_runtime_metadata(stack: &Stack) -> RuntimeMetadata { +fn import_runtime_metadata( + stack: &Stack, + persisted_gate_answers: alien_core::GateAnswers, +) -> RuntimeMetadata { RuntimeMetadata { prepared_stack: Some(stack.clone()), + persisted_gate_answers, ..RuntimeMetadata::default() } } @@ -820,8 +866,19 @@ fn reimport_runtime_metadata( prepared_stack: &Stack, release_id: &str, req: &StackImportRequest, + imported_gate_answers: alien_core::GateAnswers, ) -> crate::error::Result { let mut metadata = existing.runtime_metadata.clone().unwrap_or_default(); + // The route refused conflicting answers before calling this, so what + // remains is bookkeeping: answers for inputs a new release introduces are + // recorded, recorded answers are never overwritten, and a pre-fixity + // deployment adopts the import's answers wholesale. + for (input_id, imported_answer) in &imported_gate_answers { + metadata + .persisted_gate_answers + .entry(input_id.clone()) + .or_insert(*imported_answer); + } let baseline_stack = metadata.prepared_stack.as_ref().ok_or_else(|| { AlienError::new(ErrorData::ImportedDeploymentConflict { reason: format!( diff --git a/crates/alien-manager/tests/stack_import.rs b/crates/alien-manager/tests/stack_import.rs index a89bf3011..6d4c353de 100644 --- a/crates/alien-manager/tests/stack_import.rs +++ b/crates/alien-manager/tests/stack_import.rs @@ -1173,4 +1173,121 @@ async fn a_declined_gated_resource_is_stripped_from_the_prepared_stack() { carry it into provisioning" ); assert!(prepared_stack.resources.contains_key("assets")); + assert_eq!( + persisted + .runtime_metadata + .as_ref() + .expect("runtime_metadata must be persisted") + .persisted_gate_answers + .get("extrasEnabled"), + Some(&false), + "the declined answer is recorded at import, where the fixity check \ + holds every later value against it" + ); +} + +/// A two-resource variant of `aws_s3_import_request`: the gated store was +/// delivered alongside the ungated one. +fn aws_two_store_import_request( + deployment_name: &str, + region: &str, + ungated_id: &str, + gated_id: &str, +) -> StackImportRequest { + let mut request = aws_s3_import_request(deployment_name, region, ungated_id, "acme-imports"); + request.resources.push(ImportedResource { + id: gated_id.to_string(), + resource_type: alien_core::Storage::RESOURCE_TYPE.into(), + import_data: serde_json::to_value(AwsStorageImportData { + bucket_name: "acme-extras".to_string(), + bucket_arn: "arn:aws:s3:::acme-extras".to_string(), + }) + .unwrap(), + }); + request +} + +/// An accepted answer is recorded too, and a re-registration that flips it is +/// refused synchronously — the setup artifact's custom resource fails, which +/// is what forces CloudFormation to roll the parameter edit back. +#[tokio::test] +async fn a_reimport_flipping_a_frozen_gate_answer_is_refused() { + let fixture = make_fixture(Some(stack_with_gated_storage("assets", "extras"))).await; + + // First install: the deployer accepted the gated store. + let accepted = aws_two_store_import_request("acme-prod", "us-east-1", "assets", "extras"); + let (status, json) = post_import(&fixture, Some(&fixture.dg_token), &accepted).await; + assert_eq!(status, StatusCode::CREATED, "body = {:#}", json); + let parsed: StackImportResponse = serde_json::from_value(json).unwrap(); + + let persisted = fixture + .deployment_store + .get_deployment( + &alien_manager::auth::Subject::system(), + &parsed.deployment_id, + ) + .await + .unwrap() + .expect("deployment must persist"); + assert_eq!( + persisted + .runtime_metadata + .as_ref() + .expect("runtime_metadata must be persisted") + .persisted_gate_answers + .get("extrasEnabled"), + Some(&true), + "the accepted answer is recorded at import" + ); + + // The deployment must be in a re-importable state before the flipped + // answer can even reach the fixity check. + fixture + .deployment_store + .reconcile( + &alien_manager::auth::Subject::system(), + ReconcileData { + deployment_id: persisted.id.clone(), + session: "test-reconcile".to_string(), + state: DeploymentState { + status: DeploymentStatus::Running, + platform: persisted.platform, + current_release: Some(ReleaseInfo { + release_id: fixture.release_id.clone(), + version: None, + description: None, + stack: stack_with_gated_storage("assets", "extras"), + }), + target_release: None, + stack_state: persisted.stack_state.clone(), + error: None, + environment_info: persisted.environment_info.clone(), + runtime_metadata: persisted.runtime_metadata.clone(), + retry_requested: false, + protocol_version: persisted.deployment_protocol_version, + }, + update_heartbeat: false, + suggested_delay_ms: None, + heartbeats: vec![], + observed_inventory_batches: vec![], + capabilities: vec![], + operator_version: None, + }, + ) + .await + .expect("deployment should reach a stable state before re-import"); + + // The re-registration omits the gated store: a flipped answer. + let declined = aws_s3_import_request("acme-prod", "us-east-1", "assets", "acme-imports"); + let (status, json) = post_import(&fixture, Some(&fixture.dg_token), &declined).await; + assert_eq!( + status, + StatusCode::BAD_REQUEST, + "a frozen gate is answered once; body = {json:#}" + ); + assert_eq!( + json.get("code").and_then(|code| code.as_str()), + Some("FROZEN_GATE_ANSWER_CHANGED"), + "body = {json:#}" + ); } From 3966ff4e9bf71c24262185b93dfcfc3692d61ecf Mon Sep 17 00:00:00 2001 From: Itamar Zand Date: Fri, 24 Jul 2026 23:06:14 +0300 Subject: [PATCH 05/10] test(manager): pass the imported answers through the reimport test callers --- crates/alien-manager/src/routes/stack.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/crates/alien-manager/src/routes/stack.rs b/crates/alien-manager/src/routes/stack.rs index 9b8eec6a5..e13c9cedf 100644 --- a/crates/alien-manager/src/routes/stack.rs +++ b/crates/alien-manager/src/routes/stack.rs @@ -1347,6 +1347,7 @@ mod setup_update_authorization_tests { &target, "release", &request(), + Default::default(), ) .expect("stable setup import should succeed"); @@ -1364,7 +1365,13 @@ mod setup_update_authorization_tests { let baseline = stack("live", "frozen-a"); let target = stack("live", "frozen-b"); let metadata = - reimport_runtime_metadata(&record(baseline.clone()), &target, "release", &request()) + reimport_runtime_metadata( + &record(baseline.clone()), + &target, + "release", + &request(), + Default::default(), + ) .expect("setup-owned update should succeed"); let authorization = metadata .setup_update_authorization From 9665c10447406572918fe40d086c9e4158144a91 Mon Sep 17 00:00:00 2001 From: Itamar Zand Date: Fri, 24 Jul 2026 23:26:44 +0300 Subject: [PATCH 06/10] revert(deployment): keep the fixity contract at protocol version 1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The internal protocol rules are explicit: an additive optional field does not bump the version, and the earlier justification stretched the actor-responsibility clause. Re-derived from first principles, the bump bought nothing — an older actor's strip already resolves frozen presence from state, so a flipped input is ignored rather than applied, and answers dropped by an old write-back are rebuilt faithfully by the derive-when-empty fallback — while costing a lot: pull agents upgrade on the customer's schedule, and a version-2 state would hard-refuse every older agent the moment a newer manager writes one. New actors refuse conflicting answers loudly; older actors keep the silent-ignore behavior they always had. Nothing can flip a frozen resource either way. --- crates/alien-core/src/deployment/state.rs | 14 ++++++++------ crates/alien-deployment/src/pending.rs | 1 - crates/alien-deployment/src/updating.rs | 1 - 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/crates/alien-core/src/deployment/state.rs b/crates/alien-core/src/deployment/state.rs index 05bef1c64..db0b010d3 100644 --- a/crates/alien-core/src/deployment/state.rs +++ b/crates/alien-core/src/deployment/state.rs @@ -190,12 +190,14 @@ pub const MIN_SUPPORTED_DEPLOYMENT_PROTOCOL_VERSION: u32 = 1; /// Deployment protocol version this binary writes. /// Bump when making incompatible changes to DeploymentState semantics. /// -/// Version 2 added the frozen-gate fixity contract (`persisted_gate_answers` -/// on the runtime metadata): an actor unaware of it would skip the fixity -/// check and could resurrect or delete a setup-created resource against the -/// deployer's recorded answer, so older actors must refuse v2 states rather -/// than step them. -pub const CURRENT_DEPLOYMENT_PROTOCOL_VERSION: u32 = 2; +/// The frozen-gate fixity contract (`persisted_gate_answers` on the runtime +/// metadata) deliberately did NOT bump this: the field is additive, an actor +/// unaware of it still cannot flip a frozen resource (its strip resolves from +/// state presence, so a changed input is ignored rather than applied), and a +/// write-back that drops the field is rebuilt faithfully by the +/// derive-when-empty fallback. Bumping would instead hard-refuse every +/// customer-scheduled pull agent the moment a newer manager writes state. +pub const CURRENT_DEPLOYMENT_PROTOCOL_VERSION: u32 = 1; /// Backwards-compatible alias for older call sites. pub const DEPLOYMENT_PROTOCOL_VERSION: u32 = CURRENT_DEPLOYMENT_PROTOCOL_VERSION; diff --git a/crates/alien-deployment/src/pending.rs b/crates/alien-deployment/src/pending.rs index d9dd443b4..6e0c72303 100644 --- a/crates/alien-deployment/src/pending.rs +++ b/crates/alien-deployment/src/pending.rs @@ -104,7 +104,6 @@ pub async fn handle_pending( next.error = None; next.environment_info = environment_info; next.runtime_metadata = Some(runtime_metadata); - next.protocol_version = alien_core::CURRENT_DEPLOYMENT_PROTOCOL_VERSION; // Error handled in DeploymentStepResult Ok(DeploymentStepResult { diff --git a/crates/alien-deployment/src/updating.rs b/crates/alien-deployment/src/updating.rs index 100269ddf..b45cc551d 100644 --- a/crates/alien-deployment/src/updating.rs +++ b/crates/alien-deployment/src/updating.rs @@ -84,7 +84,6 @@ pub async fn handle_update_pending( &stack_state, &Default::default(), )?; - next.protocol_version = alien_core::CURRENT_DEPLOYMENT_PROTOCOL_VERSION; } crate::pending::enforce_frozen_gate_fixity(&persisted_gate_answers, &config.input_values)?; From 6e72738e32c1fe7f5fe0aaf9ca9967a4c675a38d Mon Sep 17 00:00:00 2001 From: Itamar Zand Date: Thu, 23 Jul 2026 17:17:47 +0300 Subject: [PATCH 07/10] test(e2e): add enabled-demo real-cloud distribution test for .enabled Adds a distribution test app with a worker plus a matched on/off pair of every gated resource type (KV, storage, queue, vault) behind .enabled(input). The test answers the four *-on inputs true and the four *-off inputs false, provisions to AWS via Terraform, then asserts each enabled resource and its grant reach the imported stack_state and the account while each declined resource is absent. Wires TestApp::EnabledDemo through the harness (input_values -> tfvars) and adds the terraform-aws matrix entry to e2e-cloud.yml behind an enabled-demo app filter. --- .github/workflows/e2e-cloud.yml | 6 + crates/alien-test/src/distribution.rs | 32 +++- crates/alien-test/src/e2e.rs | 10 +- crates/alien-test/tests/distribution.rs | 146 ++++++++++++++++++ tests/e2e/test-apps/enabled-demo/alien.ts | 124 +++++++++++++++ tests/e2e/test-apps/enabled-demo/package.json | 21 +++ tests/e2e/test-apps/enabled-demo/src/index.ts | 20 +++ .../e2e/test-apps/enabled-demo/tsconfig.json | 14 ++ .../test-apps/enabled-demo/tsdown.config.ts | 8 + 9 files changed, 379 insertions(+), 2 deletions(-) create mode 100644 tests/e2e/test-apps/enabled-demo/alien.ts create mode 100644 tests/e2e/test-apps/enabled-demo/package.json create mode 100644 tests/e2e/test-apps/enabled-demo/src/index.ts create mode 100644 tests/e2e/test-apps/enabled-demo/tsconfig.json create mode 100644 tests/e2e/test-apps/enabled-demo/tsdown.config.ts diff --git a/.github/workflows/e2e-cloud.yml b/.github/workflows/e2e-cloud.yml index 939e5716c..2cb5c811d 100644 --- a/.github/workflows/e2e-cloud.yml +++ b/.github/workflows/e2e-cloud.yml @@ -64,6 +64,7 @@ on: - comprehensive-rust - comprehensive-ts - full-stack-microservices + - enabled-demo - command-routing-ts - container-rust - runtime-less-mixed @@ -359,6 +360,11 @@ jobs: TERRAFORM_ENTRIES=$(echo "$TERRAFORM_ENTRIES" | jq -c '. + [{"name":"terraform-aks-helm-pull-comprehensive-rust","test_filter":"terraform_aks_helm_pull_comprehensive_rust","needs_oidc":true,"resource_suffix":"tfakscr"}]') fi fi + if [ "$APP" = "All" ] || [ "$APP" = "enabled-demo" ]; then + if [ "$PUSH_AWS_TERRAFORM" = "true" ]; then + TERRAFORM_ENTRIES=$(echo "$TERRAFORM_ENTRIES" | jq -c '. + [{"name":"terraform-aws-push-enabled-demo","test_filter":"terraform_aws_push_enabled_demo","needs_oidc":false,"resource_suffix":"tfawsed","bindings_arch":"aarch64"}]') + fi + fi if [ "$APP" = "All" ] || [ "$APP" = "full-stack-microservices" ]; then if [ "$KUBERNETES_AWS_TERRAFORM_HELM" = "true" ]; then TERRAFORM_ENTRIES=$(echo "$TERRAFORM_ENTRIES" | jq -c '. + [{"name":"terraform-eks-helm-pull-full-stack-microservices","test_filter":"terraform_eks_helm_pull_full_stack_microservices","needs_oidc":false,"resource_suffix":"tfeksfs","bindings_arch":"aarch64"}]') diff --git a/crates/alien-test/src/distribution.rs b/crates/alien-test/src/distribution.rs index 4ef534daa..8374067d8 100644 --- a/crates/alien-test/src/distribution.rs +++ b/crates/alien-test/src/distribution.rs @@ -86,7 +86,10 @@ impl DistributionArtifactCleanup { } } - fn command_env(&self) -> &[(String, String)] { + /// Target-scoped credentials/region the artifact was applied with. Exposed + /// so distribution tests can make read-only cloud assertions against the + /// same account the setup artifact provisioned into. + pub fn command_env(&self) -> &[(String, String)] { match self { DistributionArtifactCleanup::CloudFormation { env, .. } | DistributionArtifactCleanup::Terraform { env, .. } @@ -3207,6 +3210,26 @@ fn terraform_output_u32(outputs: &Value, key: &str) -> anyhow::Result { anyhow::bail!("terraform output {key} is not a number or string") } +/// The gate answers the enabled-demo e2e applies: the four `*On` inputs true, +/// the four `*Off` inputs false. Tuple keys are the Terraform variable names the +/// generator emits for each input id (`input_` + snake_case), so a change to +/// `stack_input_variable_name` must be mirrored here. Empty for every other app. +fn enabled_demo_gate_answers(app: TestApp) -> &'static [(&'static str, bool)] { + match app { + TestApp::EnabledDemo => &[ + ("input_kv_on", true), + ("input_kv_off", false), + ("input_storage_on", true), + ("input_storage_off", false), + ("input_queue_on", true), + ("input_queue_off", false), + ("input_vault_on", true), + ("input_vault_off", false), + ], + _ => &[], + } +} + fn terraform_tfvars( prepared: &DistributionPrepared, target: alien_terraform::TerraformTarget, @@ -3230,6 +3253,13 @@ fn terraform_tfvars( Value::String(prepared.manager.url.clone()), ); + // Answer deployer gate inputs at apply time. Terraform auto-loads + // terraform.tfvars.json, so a `input_` key here is how the + // harness threads a `.enabled(input)` answer into the applied artifact. + for (tfvar, answer) in enabled_demo_gate_answers(prepared.app) { + vars.insert(tfvar.to_string(), Value::Bool(*answer)); + } + match target.cloud_platform() { Platform::Aws => { let target = prepared diff --git a/crates/alien-test/src/e2e.rs b/crates/alien-test/src/e2e.rs index af7ae7de5..e0b911c0b 100644 --- a/crates/alien-test/src/e2e.rs +++ b/crates/alien-test/src/e2e.rs @@ -148,6 +148,11 @@ pub enum TestApp { /// TypeScript SOURCE Container + Rust SOURCE Daemon sharing a direct KV /// binding and registering the same target-scoped command. RuntimeLessMixed, + /// Worker plus a matched on/off pair of every gated resource type (KV, + /// storage, queue, vault) behind `.enabled(input)`, for verifying that a + /// declined resource and its grant never reach the cloud + /// (`tests/e2e/test-apps/enabled-demo`). + EnabledDemo, } impl std::fmt::Display for TestApp { @@ -159,6 +164,7 @@ impl std::fmt::Display for TestApp { TestApp::CommandRoutingTs => write!(f, "command-routing-ts"), TestApp::ContainerRust => write!(f, "container-rust"), TestApp::RuntimeLessMixed => write!(f, "runtime-less-mixed"), + TestApp::EnabledDemo => write!(f, "enabled-demo"), } } } @@ -363,6 +369,7 @@ pub(crate) fn test_app_path(app: TestApp) -> &'static str { TestApp::CommandRoutingTs => "../../examples/command-routing-ts", TestApp::ContainerRust => "test-apps/container-rust", TestApp::RuntimeLessMixed => "test-apps/runtime-less-mixed", + TestApp::EnabledDemo => "test-apps/enabled-demo", } } @@ -379,7 +386,8 @@ fn deployment_environment_variables( | TestApp::ComprehensiveTs | TestApp::CommandRoutingTs | TestApp::ContainerRust - | TestApp::RuntimeLessMixed => None, + | TestApp::RuntimeLessMixed + | TestApp::EnabledDemo => None, TestApp::FullStackMicroservices => { Some(vec![alien_manager_api::types::EnvironmentVariable { name: "APP_SECRET".to_string(), diff --git a/crates/alien-test/tests/distribution.rs b/crates/alien-test/tests/distribution.rs index 95c085d1b..15964039a 100644 --- a/crates/alien-test/tests/distribution.rs +++ b/crates/alien-test/tests/distribution.rs @@ -60,9 +60,143 @@ async fn check_distribution_deployment(ctx: &mut alien_test::TestContext) { panic!("mixed runtime-less checks failed: {error:#}"); } } + TestApp::EnabledDemo => { + if let Err(error) = check_enabled_demo(ctx).await { + panic!("enabled-demo gate checks failed: {error:#}"); + } + } } } +/// Verifies the `.enabled(input)` gate end to end on a real cloud: after setup +/// applied the Terraform artifact with four `*On` inputs answered true and four +/// `*Off` answered false, every gated-on resource (and the ungated control) +/// must be created and every gated-off resource must be absent — proving the +/// `count = 0` path applies cleanly and a declined resource never reaches the +/// cloud. +async fn check_enabled_demo(ctx: &mut alien_test::TestContext) -> anyhow::Result<()> { + use anyhow::Context as _; + + // Manager-level outcome: the imported stack_state reflects exactly what the + // gated Terraform apply produced. Gated-off resources are absent from the + // registration payload, so they never enter stack_state. + let resp = ctx + .deployment + .manager() + .client() + .get_deployment() + .id(&ctx.deployment.id) + .send() + .await + .map_err(|error| anyhow::anyhow!("get_deployment failed: {error}"))?; + let state_value = resp + .into_inner() + .stack_state + .context("deployment is missing stack_state")?; + let stack_state: alien_core::StackState = + serde_json::from_value(state_value).context("failed to parse stack_state")?; + let present: std::collections::HashSet = + stack_state.resources.keys().cloned().collect(); + + for id in [ + "state", + "optional-kv-on", + "optional-storage-on", + "optional-queue-on", + "optional-vault-on", + ] { + anyhow::ensure!( + present.contains(id), + "expected gated-on/control resource '{id}' present in stack_state, got {present:?}" + ); + } + for id in [ + "optional-kv-off", + "optional-storage-off", + "optional-queue-off", + "optional-vault-off", + ] { + anyhow::ensure!( + !present.contains(id), + "declined resource '{id}' must be absent from stack_state, got {present:?}" + ); + } + + // Cloud-level control: the resource id is embedded in every cloud resource + // name, so a substring scan over the target account is naming-agnostic and + // proves the count=0 apply left nothing behind. Uses the Terraform cleanup's + // target credentials/region. + let env = ctx + .distribution_cleanups + .iter() + .map(|cleanup| cleanup.command_env().to_vec()) + .find(|env| !env.is_empty()) + .context("no distribution cleanup env for cloud assertions")?; + + assert_cloud_gate_pair( + &env, + &["dynamodb", "list-tables", "--output", "json"], + "optional-kv-on", + "optional-kv-off", + ) + .await?; + assert_cloud_gate_pair( + &env, + &["s3api", "list-buckets", "--output", "json"], + "optional-storage-on", + "optional-storage-off", + ) + .await?; + assert_cloud_gate_pair( + &env, + &["sqs", "list-queues", "--output", "json"], + "optional-queue-on", + "optional-queue-off", + ) + .await?; + + Ok(()) +} + +/// Runs one read-only `aws` list call and asserts the enabled sibling's id is +/// present in the output while the declined sibling's id is absent. +async fn assert_cloud_gate_pair( + env: &[(String, String)], + aws_args: &[&str], + on_id: &str, + off_id: &str, +) -> anyhow::Result<()> { + use anyhow::Context as _; + + let mut cmd = tokio::process::Command::new("aws"); + cmd.args(aws_args); + for (key, value) in env { + cmd.env(key, value); + } + let output = cmd + .output() + .await + .with_context(|| format!("failed to run aws {}", aws_args.join(" ")))?; + anyhow::ensure!( + output.status.success(), + "aws {} failed: {}", + aws_args.join(" "), + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + anyhow::ensure!( + stdout.contains(on_id), + "enabled resource '{on_id}' not found in target account (aws {})", + aws_args.join(" ") + ); + anyhow::ensure!( + !stdout.contains(off_id), + "declined resource '{off_id}' must not exist in target account (aws {})", + aws_args.join(" ") + ); + Ok(()) +} + async fn public_url(ctx: &mut alien_test::TestContext) -> anyhow::Result { ctx.deployment .wait_for_public_url(Duration::from_secs(180)) @@ -690,6 +824,18 @@ async fn terraform_aws_push_comprehensive_rust(ctx: &mut TerraformAwsPushRust) { check_distribution_deployment(&mut ctx.ctx).await; } +distribution_test_context!( + TerraformAwsPushEnabledDemo, + DistributionFlow::TerraformAwsPush, + TestApp::EnabledDemo +); + +#[test_context(TerraformAwsPushEnabledDemo)] +#[tokio::test] +async fn terraform_aws_push_enabled_demo(ctx: &mut TerraformAwsPushEnabledDemo) { + check_distribution_deployment(&mut ctx.ctx).await; +} + distribution_test_context!( TerraformGcpPushRust, DistributionFlow::TerraformGcpPush, diff --git a/tests/e2e/test-apps/enabled-demo/alien.ts b/tests/e2e/test-apps/enabled-demo/alien.ts new file mode 100644 index 000000000..1ad13ccbe --- /dev/null +++ b/tests/e2e/test-apps/enabled-demo/alien.ts @@ -0,0 +1,124 @@ +import * as alien from "@alienplatform/core" + +// A deployer-input gate per resource type, in matched on/off pairs. The e2e +// answers the four `*On` inputs true and the four `*Off` inputs false at apply +// time, then verifies each on-resource (and its grant) exists in the cloud +// while each off-resource is absent. Defaults are false so the on-resources +// only appear when the harness actually threads the answer through — otherwise +// the test would pass without proving the gate value was applied. +const io = alien.inputs({ + kvOn: alien.boolean({ + providedBy: "deployer", + required: false, + default: false, + label: "Enable the on key-value store", + description: "Answered true by the e2e; the store must exist.", + }), + kvOff: alien.boolean({ + providedBy: "deployer", + required: false, + default: false, + label: "Enable the off key-value store", + description: "Answered false by the e2e; the store must be absent.", + }), + storageOn: alien.boolean({ + providedBy: "deployer", + required: false, + default: false, + label: "Enable the on object store", + description: "Answered true by the e2e; the bucket must exist.", + }), + storageOff: alien.boolean({ + providedBy: "deployer", + required: false, + default: false, + label: "Enable the off object store", + description: "Answered false by the e2e; the bucket must be absent.", + }), + queueOn: alien.boolean({ + providedBy: "deployer", + required: false, + default: false, + label: "Enable the on queue", + description: "Answered true by the e2e; the queue must exist.", + }), + queueOff: alien.boolean({ + providedBy: "deployer", + required: false, + default: false, + label: "Enable the off queue", + description: "Answered false by the e2e; the queue must be absent.", + }), + vaultOn: alien.boolean({ + providedBy: "deployer", + required: false, + default: false, + label: "Enable the on secret store", + description: "Answered true by the e2e; its grant must exist.", + }), + vaultOff: alien.boolean({ + providedBy: "deployer", + required: false, + default: false, + label: "Enable the off secret store", + description: "Answered false by the e2e; its grant must be absent.", + }), +}) + +// Ungated positive control: proves setup actually provisioned resources, so an +// absent off-resource is a real gate outcome rather than an empty deployment. +// Frozen (setup-created) so the worker's read grant bakes into the execution +// role at setup. A Live data resource would defer that grant to a runtime +// PutRolePolicy on the setup-owned role, which the management role is not +// permitted to do — orthogonal to the gate this app exists to exercise. +const state = new alien.Kv("state").build() + +const kvOn = new alien.Kv("optional-kv-on").enabled(io.kvOn).build() +const kvOff = new alien.Kv("optional-kv-off").enabled(io.kvOff).build() +const storageOn = new alien.Storage("optional-storage-on").enabled(io.storageOn).build() +const storageOff = new alien.Storage("optional-storage-off").enabled(io.storageOff).build() +const queueOn = new alien.Queue("optional-queue-on").enabled(io.queueOn).build() +const queueOff = new alien.Queue("optional-queue-off").enabled(io.queueOff).build() +const vaultOn = new alien.Vault("optional-vault-on").enabled(io.vaultOn).build() +const vaultOff = new alien.Vault("optional-vault-off").enabled(io.vaultOff).build() + +const agent = new alien.Worker("agent") + .code({ type: "source", src: "./", toolchain: { type: "typescript" } }) + .commandsEnabled(true) + .publicEndpoint("api") + .permissions("execution") + .build() + +export default new alien.Stack("enabled-demo") + .inputs(io) + .add(state, "frozen") + .add(kvOn, "frozen") + .add(kvOff, "frozen") + .add(storageOn, "frozen") + .add(storageOff, "frozen") + .add(queueOn, "frozen") + .add(queueOff, "frozen") + .add(vaultOn, "frozen") + .add(vaultOff, "frozen") + .add(agent, "live") + .permissions({ + profiles: { + // Each gated resource carries its own resource-scoped grant so the e2e can + // assert the grant follows the gate (present when on, gone when off). The + // worker binds only the ungated `state` store; it depends on no gated + // resource, so the ungated-dependent-of-a-gated-resource preflight stays + // satisfied. + execution: { + state: ["kv/data-read"], + "optional-kv-on": ["kv/data-read"], + "optional-kv-off": ["kv/data-read"], + "optional-storage-on": ["storage/data-read"], + "optional-storage-off": ["storage/data-read"], + "optional-queue-on": ["queue/data-read"], + "optional-queue-off": ["queue/data-read"], + "optional-vault-on": ["vault/data-read"], + "optional-vault-off": ["vault/data-read"], + }, + }, + }) + .build() diff --git a/tests/e2e/test-apps/enabled-demo/package.json b/tests/e2e/test-apps/enabled-demo/package.json new file mode 100644 index 000000000..9a9a297d8 --- /dev/null +++ b/tests/e2e/test-apps/enabled-demo/package.json @@ -0,0 +1,21 @@ +{ + "name": "enabled-demo", + "version": "1.0.0", + "type": "module", + "private": true, + "scripts": { + "dev": "tsdown --watch --no-clean", + "build": "tsdown", + "test:ts": "tsc --noEmit" + }, + "dependencies": { + "@alienplatform/sdk": "workspace:*", + "@alienplatform/core": "workspace:*", + "hono": "^4.0.0" + }, + "devDependencies": { + "@types/node": "^22.10.5", + "tsdown": "^0.13.0", + "typescript": "^5.7.3" + } +} diff --git a/tests/e2e/test-apps/enabled-demo/src/index.ts b/tests/e2e/test-apps/enabled-demo/src/index.ts new file mode 100644 index 000000000..778ac8334 --- /dev/null +++ b/tests/e2e/test-apps/enabled-demo/src/index.ts @@ -0,0 +1,20 @@ +import { command } from "@alienplatform/sdk" +import { Hono } from "hono" + +const app = new Hono() + +app.get("/health", c => { + return c.json({ + status: "ok", + timestamp: new Date().toISOString(), + }) +}) + +command("echo", async params => { + return { + ...params, + timestamp: new Date().toISOString(), + } +}) + +export default app diff --git a/tests/e2e/test-apps/enabled-demo/tsconfig.json b/tests/e2e/test-apps/enabled-demo/tsconfig.json new file mode 100644 index 000000000..5d7d34a68 --- /dev/null +++ b/tests/e2e/test-apps/enabled-demo/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "lib": ["ES2022"], + "moduleResolution": "bundler", + "noEmit": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} diff --git a/tests/e2e/test-apps/enabled-demo/tsdown.config.ts b/tests/e2e/test-apps/enabled-demo/tsdown.config.ts new file mode 100644 index 000000000..63c84ccc5 --- /dev/null +++ b/tests/e2e/test-apps/enabled-demo/tsdown.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "tsdown" + +export default defineConfig({ + entry: ["src/index.ts"], + format: ["esm"], + clean: true, + dts: false, +}) From 2d6046dd8fe5a2b25a2883d72a6efc3191f91713 Mon Sep 17 00:00:00 2001 From: Itamar Zand Date: Thu, 23 Jul 2026 17:26:14 +0300 Subject: [PATCH 08/10] chore: register enabled-demo in the pnpm lockfile --- pnpm-lock.yaml | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 629e5ea69..2089fb2bc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -333,6 +333,28 @@ importers: specifier: ^5.8.3 version: 5.8.3 + tests/e2e/test-apps/enabled-demo: + dependencies: + '@alienplatform/core': + specifier: link:../../../../packages/core + version: link:../../../../packages/core + '@alienplatform/sdk': + specifier: link:../../../../packages/sdk + version: link:../../../../packages/sdk + hono: + specifier: ^4.0.0 + version: 4.11.3 + devDependencies: + '@types/node': + specifier: ^22.10.5 + version: 22.19.13 + tsdown: + specifier: ^0.13.0 + version: 0.13.5(typescript@5.8.3) + typescript: + specifier: ^5.7.3 + version: 5.8.3 + tests/e2e/test-apps/runtime-less-mixed: dependencies: '@alienplatform/bindings': From c11105a261612d952bbf7c6c4b1ca7332b3ac854 Mon Sep 17 00:00:00 2001 From: Itamar Zand Date: Thu, 23 Jul 2026 17:38:47 +0300 Subject: [PATCH 09/10] fix(e2e): avoid spreading untyped command params in enabled-demo --- tests/e2e/test-apps/enabled-demo/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/e2e/test-apps/enabled-demo/src/index.ts b/tests/e2e/test-apps/enabled-demo/src/index.ts index 778ac8334..88ca533d1 100644 --- a/tests/e2e/test-apps/enabled-demo/src/index.ts +++ b/tests/e2e/test-apps/enabled-demo/src/index.ts @@ -12,7 +12,7 @@ app.get("/health", c => { command("echo", async params => { return { - ...params, + params, timestamp: new Date().toISOString(), } }) From f28767abd0210a9fda4afb95bc4bc3ad1ea2d861 Mon Sep 17 00:00:00 2001 From: Itamar Zand Date: Fri, 24 Jul 2026 23:32:53 +0300 Subject: [PATCH 10/10] test(e2e): gate a worker pair in the enabled-demo distribution app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The compute gate rides the live strip, so the real-cloud proof is the pair pattern the app already uses: the accepted worker's function must exist while the declined worker's function is never provisioned — and both dedicated profiles' service accounts must exist, the persisted baseline that lets a later acceptance recreate the function without a setup change. Neither gated worker links anything, so no grant depends on them. --- crates/alien-test/src/distribution.rs | 2 ++ crates/alien-test/tests/distribution.rs | 28 +++++++++++++++++++ tests/e2e/test-apps/enabled-demo/alien.ts | 34 +++++++++++++++++++++++ 3 files changed, 64 insertions(+) diff --git a/crates/alien-test/src/distribution.rs b/crates/alien-test/src/distribution.rs index 8374067d8..bab2582ed 100644 --- a/crates/alien-test/src/distribution.rs +++ b/crates/alien-test/src/distribution.rs @@ -3225,6 +3225,8 @@ fn enabled_demo_gate_answers(app: TestApp) -> &'static [(&'static str, bool)] { ("input_queue_off", false), ("input_vault_on", true), ("input_vault_off", false), + ("input_worker_on", true), + ("input_worker_off", false), ], _ => &[], } diff --git a/crates/alien-test/tests/distribution.rs b/crates/alien-test/tests/distribution.rs index 15964039a..9bf95b8cc 100644 --- a/crates/alien-test/tests/distribution.rs +++ b/crates/alien-test/tests/distribution.rs @@ -104,6 +104,7 @@ async fn check_enabled_demo(ctx: &mut alien_test::TestContext) -> anyhow::Result "optional-storage-on", "optional-queue-on", "optional-vault-on", + "optional-worker-on", ] { anyhow::ensure!( present.contains(id), @@ -115,6 +116,7 @@ async fn check_enabled_demo(ctx: &mut alien_test::TestContext) -> anyhow::Result "optional-storage-off", "optional-queue-off", "optional-vault-off", + "optional-worker-off", ] { anyhow::ensure!( !present.contains(id), @@ -154,6 +156,32 @@ async fn check_enabled_demo(ctx: &mut alien_test::TestContext) -> anyhow::Result "optional-queue-off", ) .await?; + // A compute gate rides the live strip: the declined worker's function is + // never provisioned, the accepted one is. + assert_cloud_gate_pair( + &env, + &["lambda", "list-functions", "--output", "json"], + "optional-worker-on", + "optional-worker-off", + ) + .await?; + // The declined worker's provisioning baseline persists: both dedicated + // profiles' service accounts exist, so a later acceptance can recreate + // the function without a setup change. + assert_cloud_gate_pair( + &env, + &["iam", "list-roles", "--output", "json"], + "optional-on-sa", + "never-a-role-with-this-name", + ) + .await?; + assert_cloud_gate_pair( + &env, + &["iam", "list-roles", "--output", "json"], + "optional-off-sa", + "never-a-role-with-this-name", + ) + .await?; Ok(()) } diff --git a/tests/e2e/test-apps/enabled-demo/alien.ts b/tests/e2e/test-apps/enabled-demo/alien.ts index 1ad13ccbe..bbf559b6b 100644 --- a/tests/e2e/test-apps/enabled-demo/alien.ts +++ b/tests/e2e/test-apps/enabled-demo/alien.ts @@ -63,6 +63,20 @@ const io = alien.inputs({ label: "Enable the off secret store", description: "Answered false by the e2e; its grant must be absent.", }), + workerOn: alien.boolean({ + providedBy: "deployer", + required: false, + default: false, + label: "Enable the on worker", + description: "Answered true by the e2e; its function must exist.", + }), + workerOff: alien.boolean({ + providedBy: "deployer", + required: false, + default: false, + label: "Enable the off worker", + description: "Answered false by the e2e; its function must be absent.", + }), }) // Ungated positive control: proves setup actually provisioned resources, so an @@ -89,6 +103,22 @@ const agent = new alien.Worker("agent") .permissions("execution") .build() +// A compute gate is a live gate: the declined worker's function must never be +// provisioned while its profile-derived service account still exists — the +// baseline that lets a later acceptance recreate the function. Each gated +// worker gets a dedicated profile so that baseline is exercised without an +// ungated peer, and neither links any resource, so no grant depends on them. +const workerOn = new alien.Worker("optional-worker-on") + .code({ type: "source", src: "./", toolchain: { type: "typescript" } }) + .permissions("optional-on") + .enabled(io.workerOn) + .build() +const workerOff = new alien.Worker("optional-worker-off") + .code({ type: "source", src: "./", toolchain: { type: "typescript" } }) + .permissions("optional-off") + .enabled(io.workerOff) + .build() + export default new alien.Stack("enabled-demo") .inputs(io) .add(state, "frozen") @@ -101,6 +131,8 @@ export default new alien.Stack("enabled-demo") .add(vaultOn, "frozen") .add(vaultOff, "frozen") .add(agent, "live") + .add(workerOn, "live") + .add(workerOff, "live") .permissions({ profiles: { // Each gated resource carries its own resource-scoped grant so the e2e can @@ -119,6 +151,8 @@ export default new alien.Stack("enabled-demo") "optional-vault-on": ["vault/data-read"], "optional-vault-off": ["vault/data-read"], }, + "optional-on": {}, + "optional-off": {}, }, }) .build()