Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
23adc48
feat(core): centralize resource gateability policy
ItamarZand88 Jul 24, 2026
5f38aa4
feat(terraform): classify shared support blocks on the fragment
ItamarZand88 Jul 24, 2026
dca44fd
feat(terraform): add the generator-side gating post-pass
ItamarZand88 Jul 24, 2026
dffa8c3
feat(terraform): run the gating post-pass from the generator
ItamarZand88 Jul 24, 2026
5fc3956
refactor(terraform): strip gate threading from the store emitters
ItamarZand88 Jul 24, 2026
ee855de
refactor(cloudformation): gate through the policy, drop the emitter o…
ItamarZand88 Jul 24, 2026
18887e1
test(terraform): gating matrix over every registered emitter
ItamarZand88 Jul 24, 2026
d33a51b
test: gating matrices for CloudFormation and the SDK surface
ItamarZand88 Jul 24, 2026
0296234
fix(terraform): review fixes for the gating post-pass
ItamarZand88 Jul 24, 2026
726a475
fix(sdk): make the gateability surface test pass the tsc gate
ItamarZand88 Jul 24, 2026
aedf7b5
feat: gate compute, email, and opensearch
ItamarZand88 Jul 24, 2026
548fa3a
fix(deployment): split the decline strip around the mutations
ItamarZand88 Jul 24, 2026
ab2e23e
feat(deployment): a frozen gate is answered once
ItamarZand88 Jul 24, 2026
155c340
feat(manager): record and enforce frozen-gate answers at import
ItamarZand88 Jul 24, 2026
a25fb92
test(manager): pass the imported answers through the reimport test ca…
ItamarZand88 Jul 24, 2026
b0a98fe
revert(deployment): keep the fixity contract at protocol version 1
ItamarZand88 Jul 24, 2026
40ebd2a
test(e2e): add enabled-demo real-cloud distribution test for .enabled
ItamarZand88 Jul 23, 2026
68596e3
chore: register enabled-demo in the pnpm lockfile
ItamarZand88 Jul 23, 2026
36d6ab8
fix(e2e): avoid spreading untyped command params in enabled-demo
ItamarZand88 Jul 23, 2026
b660bb5
test(e2e): gate a worker pair in the enabled-demo distribution app
ItamarZand88 Jul 24, 2026
8bb9523
fix(deployment): close three fixity edge cases from review
ItamarZand88 Jul 24, 2026
eeecd0d
fix(terraform): carry deployer input values through external registra…
ItamarZand88 Jul 24, 2026
e49d60a
fix(deployment): resolve frozen gate answers before the mutations eve…
ItamarZand88 Jul 24, 2026
e42fdcd
fix(deployment): derive the legacy update baseline from the settled r…
ItamarZand88 Jul 24, 2026
98cfb67
fix(deployment): scope frozen dominance to inputs still gating a froz…
ItamarZand88 Jul 24, 2026
cbbc241
fix(deployment): reconstruct only the legacy gate answers the state p…
ItamarZand88 Jul 25, 2026
6212288
refactor(sdk): gate the remaining builders through the shared base
ItamarZand88 Jul 25, 2026
274bc7e
fix(terraform): gate the management grants that name a gated resource
ItamarZand88 Jul 25, 2026
2444066
test(terraform): assert the gate per block, not by phrase
ItamarZand88 Jul 25, 2026
2f6e17e
fix(sdk): import Resource as a type in the rebased builders
ItamarZand88 Jul 25, 2026
7c55b45
fix(deployment): end frozen dominance when the gate is freed
ItamarZand88 Jul 25, 2026
413db98
fix(deployment): report the audited answer source under the same scoping
ItamarZand88 Jul 25, 2026
6e4ce96
fix(terraform): uniquify statement ids in the gated management policy
ItamarZand88 Jul 25, 2026
a9fa576
fix(azure): let a declined resource take its management grant with it
ItamarZand88 Jul 25, 2026
0eeb011
fix(deployment): make the recorded answer the only thing that strips
ItamarZand88 Jul 25, 2026
d737a25
test(gcp): pin that no ungated management grant names a gated resource
ItamarZand88 Jul 25, 2026
74b545a
refactor(deployment): one place decides which side of setup a gate fa…
ItamarZand88 Jul 25, 2026
9797b50
refactor(terraform): the generator owns every gate, contributions inc…
ItamarZand88 Jul 25, 2026
d1e86a2
docs(ci): say why the gating app runs on one cloud
ItamarZand88 Jul 25, 2026
0e39f68
fix(manager): refuse a gate answer the settled release can no longer …
ItamarZand88 Jul 25, 2026
46dba8d
Merge remote-tracking branch 'origin/main' into itamar/alien-330-gene…
ItamarZand88 Jul 26, 2026
95b8215
Merge branch 'itamar/alien-330-generator-owned-gating' into itamar/al…
ItamarZand88 Jul 26, 2026
61f7e32
chore(sdk): carry the persisted gate answers field in the platform ap…
ItamarZand88 Jul 26, 2026
c4f9984
Merge remote-tracking branch 'origin/main' into itamar/alien-330-gate…
ItamarZand88 Jul 26, 2026
6d35501
docs(deployment): legacy acceptance still derives without the settled…
ItamarZand88 Jul 26, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .github/workflows/e2e-cloud.yml
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ on:
- comprehensive-rust
- comprehensive-ts
- full-stack-microservices
- enabled-demo
- command-routing-ts
- container-rust
- runtime-less-mixed
Expand Down Expand Up @@ -359,6 +360,17 @@ 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
# AWS alone proves this app's contract. Which resources get stripped is
# decided by cloud-agnostic deployment code, and the rendered Terraform
# for a gated resource is linted by `terraform validate` on all three
# providers in alien-terraform's generator tests. Only the applied
# declined state is provider-specific, and proving it on GCP and Azure
# too would triple this app's cloud cost.
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"}]')
Expand Down
2 changes: 1 addition & 1 deletion client-sdks/platform/openapi.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion client-sdks/platform/rust/openapi-3.0.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion client-sdks/platform/rust/openapi.json

Large diffs are not rendered by default.

200 changes: 185 additions & 15 deletions crates/alien-cloudformation/tests/generator/gating_matrix_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Stack> {
Expand Down Expand Up @@ -42,23 +45,105 @@ fn gated_fixture(resource_type: &str) -> Option<Stack> {
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!(
Expand Down Expand Up @@ -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()),
Expand All @@ -122,7 +211,7 @@ fn every_registered_emitter_is_policy_refused_or_renders_gated() {
);
}

/// Vault gates purely through the generatorits emitter never carried
/// Vault gates purely through the generator; its emitter contains no
/// gating code. On AWS the vault's secrets are created lazily at runtime by
/// name prefix, so a granting-profile-free vault contributes no template
/// resources at all; its gated render is exactly the registration splice,
Expand Down Expand Up @@ -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;
Expand Down
21 changes: 21 additions & 0 deletions crates/alien-core/src/deployment/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -52,6 +53,16 @@ pub struct RuntimeMetadata {
#[serde(skip_serializing_if = "Option::is_none")]
pub prepared_stack: Option<crate::Stack>,

/// 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")]
Expand Down Expand Up @@ -170,11 +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<String, bool>;

/// 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.
///
/// `persisted_gate_answers` on the runtime metadata stays at this version:
/// 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 from the settled state. A bump would 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.
Expand Down
Loading
Loading