Skip to content
Merged
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
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ Startup time, idle memory, and binary size are tracked as constants in `scripts/
- Step Functions is fully shipped with complete ASL interpreter (all state types), error handling (Retry/Catch), and cross-service task integrations (Lambda, SQS, SNS, EventBridge, DynamoDB).
- API Gateway v2 (HTTP APIs) is fully shipped with Lambda proxy integration v2.0, HTTP proxy, Mock integrations, route matching with path parameters and wildcards, CORS, and JWT/Lambda authorizers.
- `GET /_fakecloud/credentials` vends AWS container-credentials-format JSON (minted + registered like AssumeRole, so it verifies under `--verify-sigv4`); point an app's `AWS_CONTAINER_CREDENTIALS_FULL_URI` at it to resolve the SDK default credential chain locally with no code change. Role ARN configurable via `--credentials-role-arn`.
- EC2 IMDS is served on `/latest/*` (IMDSv1 + IMDSv2 token, `meta-data/iam/security-credentials/*`, `iam/info`, `instance-id`, `placement/*`, `dynamic/instance-identity/document`); consumed via `AWS_EC2_METADATA_SERVICE_ENDPOINT`. Credentials share the same registered cache as `/_fakecloud/credentials`. Instance ID via `--imds-instance-id`. `--imds-link-local` additionally binds `169.254.169.254:80` (IMDS) + `169.254.170.2:80/creds` (ECS creds) for apps that hardcode those IPs; needs root plus the addresses pre-aliased on loopback (fakecloud binds them but never creates/deletes the alias), with graceful fallback if the bind fails.
- EC2 IMDS is served on `/latest/*` (IMDSv1 + IMDSv2 token, `meta-data/iam/security-credentials/*`, `iam/info`, `instance-id`, `placement/*`, `dynamic/instance-identity/document`); consumed via `AWS_EC2_METADATA_SERVICE_ENDPOINT`. Credentials share the same registered cache as `/_fakecloud/credentials`. Instance ID via `--imds-instance-id`. `--imds-link-local` additionally binds `169.254.169.254:80` (IMDS) + `169.254.170.2:80/creds` (ECS creds; `/v2/credentials/<task-id>` there serves an ECS task's role creds) for apps that hardcode those IPs; needs root plus the addresses pre-aliased on loopback (fakecloud binds them but never creates/deletes the alias), with graceful fallback if the bind fails.
- ECS task-role credentials: `GET /_fakecloud/ecs/creds/{task_id}` (injected as `AWS_CONTAINER_CREDENTIALS_FULL_URI` for tasks with a `taskRoleArn`) vends a session for the task role named after the task ID (`assumed-role/<role>/<task-id>`), minted + registered like AssumeRole and cached per task until near expiry (`fakecloud-server` `ecs_creds`, `WorkloadCredentialCache`); revoked when the task stops (1s sweep). Stopped/unknown/role-less tasks get the ECS agent's HTTP 400 `{"code":"InvalidIdInRequest","message":"CredentialsV2Request: Credentials not found","HTTPErrorCode":400}`. RegisterTaskDefinition/RunTask overrides/CFN apply `fakecloud_ecs::validate_task_role` (trust policy must name `ecs-tasks.amazonaws.com`; cross-account refused under `--iam strict`) with ECS's `ClientException` "ECS was unable to assume the role ...".
- `--dns` runs a real DNS resolver (UDP+TCP, `--dns-addr` default `0.0.0.0:53`, port 53 needs root) answering the wire-encodable types (A/AAAA/CNAME/MX/TXT/NS/PTR/SPF/CAA/SRV/SOA) from the Route 53 records created in fakecloud. Longest-suffix zone match across all accounts; CNAME chase into local zones with the requested type appended, and an external CNAME target forward-resolved upstream so a stub client still gets an address. Names in no local zone forward to `--dns-upstream` (default first non-loopback `/etc/resolv.conf` nameserver, else `8.8.8.8:53`); forward socket is connect()ed + txid-checked. UDP honors EDNS0 size and sets TC to force TCP fallback past it. Pure resolution logic in `fakecloud_route53::resolver`, hand-rolled wire codec in the server `dns` module reusing `dnssec::encode_rdata`/`type_code`. Best-effort detached listener; a bind failure is logged and the main server is unaffected. The same resolution is exposed for tests at `GET /_fakecloud/dns/resolve?name=<n>&type=<A|...|SOA>` (defaults `A`, unsupported type -> 400) returning `{name,type,status(ANSWERED|NODATA|NXDOMAIN|NOT_AUTHORITATIVE),authoritative,records:[{name,type,ttl,value}],external_cname}` (`external_cname` names the target when an A/AAAA CNAME chain exits all local zones, since this path does no upstream I/O), wrapped as `dns_resolve`/`dnsResolve` in all seven SDKs -- no socket needed.
- When adding new service behavior, prefer complete, realistic implementations over placeholder API coverage.
18 changes: 18 additions & 0 deletions crates/fakecloud-cloudformation/src/resource_provisioner/ecs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,21 @@ impl ResourceProvisioner {
Ok(())
}

/// The same role checks `RegisterTaskDefinition` runs: a role whose trust
/// policy lets ECS tasks assume it, in the stack's account under IAM
/// enforcement.
fn validate_ecs_task_role(&self, role_arn: &str) -> Result<(), String> {
let validator =
fakecloud_iam::pass_role::IamRoleTrustValidator::new(self.iam_state.clone());
fakecloud_ecs::validate_task_role(
&self.account_id,
role_arn,
Some(&validator),
self.iam_mode,
)
.map_err(|e| e.to_string())
}

pub(super) fn create_ecs_task_definition(
&self,
resource: &ResourceDefinition,
Expand Down Expand Up @@ -98,6 +113,9 @@ impl ResourceProvisioner {
.get("ExecutionRoleArn")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
for role_arn in [&task_role_arn, &execution_role_arn].into_iter().flatten() {
self.validate_ecs_task_role(role_arn)?;
}
let network_mode = props
.get("NetworkMode")
.and_then(|v| v.as_str())
Expand Down
45 changes: 45 additions & 0 deletions crates/fakecloud-cloudformation/src/resource_provisioner/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10635,6 +10635,51 @@ mod tests {
assert_eq!(prov.existing_name(&legacy).as_deref(), Some("Subnets"));
}

#[test]
fn task_definition_roles_run_the_register_task_definition_checks() {
let prov = make_provisioner();
let untrusted = prov
.create_resource(&make_resource(
"AWS::IAM::Role",
"Untrusted",
serde_json::json!({
"RoleName": "untrusted-task-role",
"AssumeRolePolicyDocument": {"Version": "2012-10-17", "Statement": []}
}),
))
.unwrap();
let def = |props: serde_json::Value| {
let mut p = serde_json::json!({
"ContainerDefinitions": [{"Name": "app", "Image": "nginx", "Memory": 256}]
});
p.as_object_mut()
.unwrap()
.extend(props.as_object().unwrap().clone());
make_resource("AWS::ECS::TaskDefinition", "Task", p)
};
for key in ["TaskRoleArn", "ExecutionRoleArn"] {
let err = prov
.create_resource(&def(serde_json::json!({ key: untrusted.physical_id })))
.unwrap_err();
assert!(
err.contains("ClientException")
&& err.contains("ECS was unable to assume the role"),
"{key}: {err}"
);
}

// Another account's role: accepted with IAM enforcement off (the
// default), refused like AWS with it on.
let foreign =
serde_json::json!({"TaskRoleArn": "arn:aws:iam::999999999999:role/elsewhere"});
prov.create_resource(&def(foreign.clone()))
.expect("default mode accepts another account's role");
let mut enforcing = make_provisioner();
enforcing.iam_mode = fakecloud_core::auth::IamMode::Strict;
let err = enforcing.create_resource(&def(foreign)).unwrap_err();
assert!(err.contains("ClientException"), "{err}");
}

#[test]
fn an_unnamed_task_definition_update_registers_the_next_revision() {
let prov = make_provisioner();
Expand Down
Loading
Loading