Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 6 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,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"}]')
Expand Down
198 changes: 184 additions & 14 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 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
22 changes: 22 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,22 @@ 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.
///
/// 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.
Expand Down
Loading
Loading