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-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/deployment/state.rs b/crates/alien-core/src/deployment/state.rs index e96d4088e..db0b010d3 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,11 +181,22 @@ 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. +/// +/// 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. 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, @@ -138,27 +143,217 @@ 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) +} + +/// 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) +} - for resource_id in &declined { +/// 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!( 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 +462,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 +512,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 +562,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 +578,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 +590,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 +600,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,42 +611,114 @@ 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), + 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); + } + + /// 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_err("no value and no default cannot resolve"); - assert!(error.message.contains("cacheEnabled"), "{}", error.message); + .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] 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..b45cc551d 100644 --- a/crates/alien-deployment/src/updating.rs +++ b/crates/alien-deployment/src/updating.rs @@ -68,16 +68,35 @@ 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( + // 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(), + )?; + } + 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 + // 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,9 +143,25 @@ 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. + 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 99b67a2e1..828e17c52 100644 --- a/crates/alien-deployment/tests/test_platform.rs +++ b/crates/alien-deployment/tests/test_platform.rs @@ -1545,6 +1545,177 @@ 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 +/// 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..e13c9cedf 100644 --- a/crates/alien-manager/src/routes/stack.rs +++ b/crates/alien-manager/src/routes/stack.rs @@ -190,7 +190,22 @@ 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( + // 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. + let prepared_stack = match alien_deployment::strip_declined_frozen_resources( prepared_stack, &stack_state, &req.input_values, @@ -198,6 +213,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 @@ -285,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(), }; @@ -345,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, @@ -800,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() } } @@ -812,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!( @@ -1282,6 +1347,7 @@ mod setup_update_authorization_tests { &target, "release", &request(), + Default::default(), ) .expect("stable setup import should succeed"); @@ -1299,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 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:#}" + ); } 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 d000d7e2f..6308e4259 100644 --- a/crates/alien-preflights/src/compile_time/resource_enabled_valid.rs +++ b/crates/alien-preflights/src/compile_time/resource_enabled_valid.rs @@ -279,10 +279,11 @@ mod tests { assert!(errors_for(stack).await.is_empty()); } - /// Compute stays out: gating code-carrying resources is a rollout - /// question, not a data-plane opt-out. + /// Compute gates as an existence choice: declining a live workload rides + /// the same removal path as deleting it from a release, so the gate + /// passes the same rules as any live data resource. #[tokio::test] - async fn rejects_a_gated_compute_resource() { + async fn accepts_a_gated_compute_resource() { let stack = Stack::new("test-stack".to_string()) .inputs(vec![boolean_input()]) .add_enabled_when( @@ -297,9 +298,68 @@ mod tests { ) .build(); + 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("cannot be optional"), "{errors:?}"); + assert!( + errors[0].contains("depends on 'jobs'"), + "{errors:?}" + ); } #[tokio::test] diff --git a/crates/alien-terraform/tests/generator/gating_matrix_tests.rs b/crates/alien-terraform/tests/generator/gating_matrix_tests.rs index b6075136e..f5cdd874e 100644 --- a/crates/alien-terraform/tests/generator/gating_matrix_tests.rs +++ b/crates/alien-terraform/tests/generator/gating_matrix_tests.rs @@ -8,8 +8,9 @@ use super::helpers::{assert_terraform_valid, gate_input, render, snapshot_module}; use alien_core::{ - AzureResourceGroup, AzureServiceBusNamespace, AzureStorageAccount, Kv, Platform, Queue, - ResourceLifecycle, Stack, StackBuilder, StackSettings, Storage, Vault, + ownership_policy_for_resource_type, AzureResourceGroup, AzureServiceBusNamespace, + AzureStorageAccount, Kv, Platform, Queue, ResourceLifecycle, Stack, StackBuilder, + StackSettings, Storage, Vault, Worker, WorkerCode, }; use alien_terraform::{TerraformTarget, TfRegistry}; @@ -119,6 +120,10 @@ fn every_registered_emitter_is_policy_refused_or_renders_gated() { // `a_gate_on_a_policy_refused_type_fails_at_render`). continue; } + if !ownership_policy_for_resource_type(resource_type).allows_frozen() { + assert_live_gate_ignored_by_setup(resource_type, platform); + continue; + } match gated_fixture(resource_type, platform) { Some(stack) => 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/crates/alien-test/src/distribution.rs b/crates/alien-test/src/distribution.rs index 4ef534daa..bab2582ed 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,28 @@ 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), + ("input_worker_on", true), + ("input_worker_off", false), + ], + _ => &[], + } +} + fn terraform_tfvars( prepared: &DistributionPrepared, target: alien_terraform::TerraformTarget, @@ -3230,6 +3255,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..9bf95b8cc 100644 --- a/crates/alien-test/tests/distribution.rs +++ b/crates/alien-test/tests/distribution.rs @@ -60,9 +60,171 @@ 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", + "optional-worker-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", + "optional-worker-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?; + // 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(()) +} + +/// 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 +852,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/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, + ) } } 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': 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..bbf559b6b --- /dev/null +++ b/tests/e2e/test-apps/enabled-demo/alien.ts @@ -0,0 +1,158 @@ +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.", + }), + 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 +// 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() + +// 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") + .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") + .add(workerOn, "live") + .add(workerOff, "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"], + }, + "optional-on": {}, + "optional-off": {}, + }, + }) + .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..88ca533d1 --- /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, +})