diff --git a/AGENTS.md b/AGENTS.md index c512aba8e..8e77c7a10 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,7 +5,7 @@ Local AWS cloud emulator. Part of the faisca project family. ## Product Context - FakeCloud is a local AWS emulator focused on high-fidelity behavior and AWS-compatible responses. -- Current project state: 105 AWS services, 7,476 operations, 248,557/248,557 Smithy conformance variants pass — true 100% across every implemented service, no flake margin. See [the parity matrix](website/content/docs/parity.md) for the full service-by-service breakdown of control-plane vs data-plane coverage and known limitations. +- Current project state: 105 AWS services, 7,491 operations, 248,557/248,557 Smithy conformance variants pass — true 100% across every implemented service, no flake margin. See [the parity matrix](website/content/docs/parity.md) for the full service-by-service breakdown of control-plane vs data-plane coverage and known limitations. - The broader roadmap prioritizes services that LocalStack keeps behind paid tiers, especially ECS, ELB/ALB, CloudFront, CloudWatch Metrics, and EC2. - Introspection SDKs (Rust, Python, TypeScript, Go, PHP, Java) are already built and maintained for the `/_fakecloud/*` endpoints. diff --git a/README.md b/README.md index 231503535..fa509a3a1 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ Works as a drop-in for LocalStack in CI, with Terraform (`endpoints` block), CDK ## Supported services -105 services, 7,476 operations, and true 100% conformance across every implemented service. +105 services, 7,491 operations, and true 100% conformance across every implemented service. Highlights: S3, DynamoDB, SQS, SNS, EventBridge, Lambda, IAM, STS, KMS, Secrets Manager, CloudFormation, SES, Cognito, Kinesis, RDS (6 real engines), ElastiCache, ECS/ECR, EC2, Step Functions, API Gateway v1/v2, Bedrock, and 80+ more. diff --git a/crates/fakecloud-conformance/src/probe/response.rs b/crates/fakecloud-conformance/src/probe/response.rs index f109da9af..251217bed 100644 --- a/crates/fakecloud-conformance/src/probe/response.rs +++ b/crates/fakecloud-conformance/src/probe/response.rs @@ -232,6 +232,9 @@ pub(super) fn service_common_errors(service_name: &str) -> &'static [&'static st // Application status checks: the probe addresses a check by a // synthetic id, which AWS answers with this not-found code. "InvalidApplicationStatusCheckId.NotFound", + // IPAM internet-registry associations: the probe addresses an + // association by a synthetic id, which AWS answers with this code. + "InvalidIpamInternetRegistryAssociationId.NotFound", "InvalidID", ], // EKS under-declares two client errors that the real API returns for diff --git a/crates/fakecloud-conformance/tests/ec2.rs b/crates/fakecloud-conformance/tests/ec2.rs index 0dd08265e..d9338ce48 100644 --- a/crates/fakecloud-conformance/tests/ec2.rs +++ b/crates/fakecloud-conformance/tests/ec2.rs @@ -12958,3 +12958,488 @@ async fn ec2_detach_image_watermark() { let body = resp.text().await.unwrap(); assert!(body.contains("true"), "unexpected: {body}"); } + +// ---- IPAM internet-registry associations and routing policy registrations ---- + +/// Percent-encode a Query parameter, using the unreserved set. +fn urlencode(v: &str) -> String { + let mut out = String::with_capacity(v.len()); + for b in v.bytes() { + match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + out.push(b as char) + } + _ => out.push_str(&format!("%{b:02X}")), + } + } + out +} + +/// Drives EC2's Query protocol directly. The vendored aws-sdk-ec2 predates the +/// internet-registry surface, so these operations are exercised over the wire. +struct Ec2Query { + endpoint: String, + http: reqwest::Client, +} + +impl Ec2Query { + fn new(server: &TestServer) -> Self { + Self { + endpoint: server.endpoint().to_string(), + http: reqwest::Client::new(), + } + } + + async fn send(&self, action: &str, params: &[(&str, &str)]) -> (u16, String) { + // Query protocol: everything is form-encoded in the body. + let mut form = vec![ + format!("Action={}", urlencode(action)), + "Version=2016-11-15".to_string(), + ]; + form.extend( + params + .iter() + .map(|(k, v)| format!("{}={}", urlencode(k), urlencode(v))), + ); + let resp = self + .http + .post(&self.endpoint) + .header( + "authorization", + "AWS4-HMAC-SHA256 Credential=test/20240101/us-east-1/ec2/aws4_request, \ + SignedHeaders=host, Signature=test", + ) + .header("content-type", "application/x-www-form-urlencoded") + .body(form.join("&")) + .send() + .await + .expect("EC2 query request failed"); + let status = resp.status().as_u16(); + (status, resp.text().await.unwrap_or_default()) + } + + async fn call(&self, action: &str, params: &[(&str, &str)]) -> String { + let (status, body) = self.send(action, params).await; + assert_eq!(status, 200, "{action} failed: {body}"); + body + } +} + +/// Pull the first `value` out of an EC2 Query response. +fn xml_value(body: &str, tag: &str) -> String { + let open = format!("<{tag}>"); + let close = format!(""); + let start = body + .find(&open) + .unwrap_or_else(|| panic!("no <{tag}> in {body}")) + + open.len(); + let end = body[start..] + .find(&close) + .unwrap_or_else(|| panic!("unclosed <{tag}> in {body}")) + + start; + body[start..end].to_string() +} + +/// An IPAM, an internet-registry association on it, and one registration. +async fn make_ir_association(c: &aws_sdk_ec2::Client, q: &Ec2Query) -> String { + let ipam = make_ipam(c).await; + let body = q + .call( + "CreateIpamInternetRegistryAssociation", + &[ + ("IpamId", &ipam), + ("Rir", "arin"), + ("OrganizationHandle", "EXAMPLE-ORG"), + ], + ) + .await; + xml_value(&body, "ipamInternetRegistryAssociationId") +} + +#[test_action("ec2", "CreateIpamInternetRegistryAssociation", checksum = "5f34bb6b")] +#[test_action( + "ec2", + "DescribeIpamInternetRegistryAssociations", + checksum = "3e5b69be" +)] +#[test_action("ec2", "EnableIpamInternetRegistryAssociation", checksum = "5b7cab96")] +#[test_action("ec2", "DeleteIpamInternetRegistryAssociation", checksum = "aa2e586a")] +#[tokio::test] +async fn ec2_ipam_internet_registry_association_lifecycle() { + let s = TestServer::start().await; + let c = s.ec2_client().await; + let q = Ec2Query::new(&s); + + let id = make_ir_association(&c, &q).await; + assert!(id.starts_with("ipam-ir-assoc-"), "{id}"); + + let body = q + .call("DescribeIpamInternetRegistryAssociations", &[]) + .await; + assert!(body.contains(&id), "{body}"); + assert!(body.contains("arin"), "{body}"); + // A new association cannot publish until it is enabled. + assert!(body.contains("pending-enable"), "{body}"); + + let body = q + .call( + "EnableIpamInternetRegistryAssociation", + &[ + ("IpamInternetRegistryAssociationId", &id), + ("RpkiVersion", "1"), + ("ServiceUri", "https://rpki.example/up-down"), + ("ChildHandle", "child"), + ("ParentHandle", "parent"), + ("ParentBpkiTa", "TA=="), + ], + ) + .await; + assert!(body.contains("enable-complete"), "{body}"); + // The child request is a document carried inside XML, so it arrives + // entity-escaped. + assert!(body.contains("child_handle="child""), "{body}"); + + let body = q + .call( + "DeleteIpamInternetRegistryAssociation", + &[("IpamInternetRegistryAssociationId", &id)], + ) + .await; + assert!(body.contains("delete-complete"), "{body}"); + let body = q + .call("DescribeIpamInternetRegistryAssociations", &[]) + .await; + assert!(!body.contains(&id), "the association must be gone: {body}"); +} + +#[test_action("ec2", "CreateIpamRoutingPolicyRegistration", checksum = "c0fe61b5")] +#[test_action("ec2", "ModifyIpamRoutingPolicyRegistration", checksum = "82482c84")] +#[test_action("ec2", "DeleteIpamRoutingPolicyRegistration", checksum = "bd003f39")] +#[test_action("ec2", "GetIpamRoutingPolicyRegistrations", checksum = "f832dfa9")] +#[test_action("ec2", "GetIpamRoutingPolicyRegistrationDeltas", checksum = "1dd1c788")] +#[tokio::test] +async fn ec2_ipam_routing_policy_registration_lifecycle() { + let s = TestServer::start().await; + let c = s.ec2_client().await; + let q = Ec2Query::new(&s); + let id = make_ir_association(&c, &q).await; + + let body = q + .call( + "CreateIpamRoutingPolicyRegistration", + &[ + ("IpamInternetRegistryAssociationId", &id), + ("Cidr", "10.0.0.0/16"), + ("Asn.1", "64512"), + ("MaxLength", "24"), + ], + ) + .await; + let first_delta = xml_value(&body, "deltaId"); + assert!(body.contains("published"), "{body}"); + + let body = q + .call( + "GetIpamRoutingPolicyRegistrations", + &[("IpamInternetRegistryAssociationId", &id)], + ) + .await; + assert!(body.contains("10.0.0.0/16"), "{body}"); + assert!(body.contains("64512"), "{body}"); + assert!(body.contains("24"), "{body}"); + assert!(body.contains("create-complete"), "{body}"); + + // Creating the same CIDR twice is a conflict, not a silent overwrite. + let (status, _) = q + .send( + "CreateIpamRoutingPolicyRegistration", + &[ + ("IpamInternetRegistryAssociationId", &id), + ("Cidr", "10.0.0.0/16"), + ("Asn.1", "64512"), + ], + ) + .await; + assert_eq!(status, 400); + + let body = q + .call( + "ModifyIpamRoutingPolicyRegistration", + &[ + ("IpamInternetRegistryAssociationId", &id), + ("Cidr", "10.0.0.0/16"), + ("Asn.1", "64513"), + ], + ) + .await; + let second_delta = xml_value(&body, "deltaId"); + assert_ne!(first_delta, second_delta, "each change is its own delta"); + + let body = q + .call( + "GetIpamRoutingPolicyRegistrations", + &[("IpamInternetRegistryAssociationId", &id)], + ) + .await; + assert!(body.contains("64513"), "{body}"); + assert!(body.contains("update-complete"), "{body}"); + + // Deltas are the audit trail, and survive the registration they describe. + let body = q + .call( + "GetIpamRoutingPolicyRegistrationDeltas", + &[("IpamInternetRegistryAssociationId", &id)], + ) + .await; + assert!( + body.contains(&first_delta) && body.contains(&second_delta), + "{body}" + ); + + let forward = q + .call( + "GetIpamRoutingPolicyRegistrationDeltas", + &[ + ("IpamInternetRegistryAssociationId", &id), + ("ChronologicalOrder", "forward"), + ], + ) + .await; + let reverse = q + .call( + "GetIpamRoutingPolicyRegistrationDeltas", + &[ + ("IpamInternetRegistryAssociationId", &id), + ("ChronologicalOrder", "reverse"), + ], + ) + .await; + assert!( + forward.find(&first_delta) < forward.find(&second_delta), + "forward is oldest first" + ); + assert!( + reverse.find(&second_delta) < reverse.find(&first_delta), + "reverse is newest first" + ); + + // A single delta can be fetched by id. + let one = q + .call( + "GetIpamRoutingPolicyRegistrationDeltas", + &[ + ("IpamInternetRegistryAssociationId", &id), + ("DeltaId", &first_delta), + ], + ) + .await; + assert!( + one.contains(&first_delta) && !one.contains(&second_delta), + "{one}" + ); + + q.call( + "DeleteIpamRoutingPolicyRegistration", + &[ + ("IpamInternetRegistryAssociationId", &id), + ("Cidr", "10.0.0.0/16"), + ], + ) + .await; + let body = q + .call( + "GetIpamRoutingPolicyRegistrations", + &[("IpamInternetRegistryAssociationId", &id)], + ) + .await; + assert!(!body.contains("10.0.0.0/16"), "{body}"); +} + +#[test_action( + "ec2", + "BatchModifyIpamRoutingPolicyRegistrations", + checksum = "e50d439a" +)] +#[tokio::test] +async fn ec2_batch_modify_ipam_routing_policy_registrations() { + let s = TestServer::start().await; + let c = s.ec2_client().await; + let q = Ec2Query::new(&s); + let id = make_ir_association(&c, &q).await; + + let delta = r#"{"add":[{"cidr":"192.0.2.0/24","asns":["64512"],"maxLength":25}, + {"cidr":"198.51.100.0/24","asns":["64513"]}]}"#; + let body = q + .call( + "BatchModifyIpamRoutingPolicyRegistrations", + &[ + ("IpamInternetRegistryAssociationId", &id), + ("DeltaJson", delta), + ], + ) + .await; + assert!(body.contains("published"), "{body}"); + + let body = q + .call( + "GetIpamRoutingPolicyRegistrations", + &[("IpamInternetRegistryAssociationId", &id)], + ) + .await; + assert!( + body.contains("192.0.2.0/24") && body.contains("198.51.100.0/24"), + "{body}" + ); + + // The same document can remove them again. + q.call( + "BatchModifyIpamRoutingPolicyRegistrations", + &[ + ("IpamInternetRegistryAssociationId", &id), + ("DeltaJson", r#"{"remove":["192.0.2.0/24"]}"#), + ], + ) + .await; + let body = q + .call( + "GetIpamRoutingPolicyRegistrations", + &[("IpamInternetRegistryAssociationId", &id)], + ) + .await; + assert!(!body.contains("192.0.2.0/24"), "{body}"); + assert!(body.contains("198.51.100.0/24"), "{body}"); + + // Malformed JSON is rejected rather than recorded as a delta. + let (status, _) = q + .send( + "BatchModifyIpamRoutingPolicyRegistrations", + &[ + ("IpamInternetRegistryAssociationId", &id), + ("DeltaJson", "not json"), + ], + ) + .await; + assert_eq!(status, 400); +} + +#[test_action("ec2", "GetIpamRouteOriginAuthorizations", checksum = "b9bc9048")] +#[test_action("ec2", "GetIpamInternetRegistryAssociationAsns", checksum = "4ccf1619")] +#[test_action( + "ec2", + "GetIpamInternetRegistryAssociationCidrs", + checksum = "4835f267" +)] +#[tokio::test] +async fn ec2_ipam_registry_views_derive_from_registrations() { + let s = TestServer::start().await; + let c = s.ec2_client().await; + let q = Ec2Query::new(&s); + let id = make_ir_association(&c, &q).await; + + q.call( + "CreateIpamRoutingPolicyRegistration", + &[ + ("IpamInternetRegistryAssociationId", &id), + ("Cidr", "203.0.113.0/24"), + ("Asn.1", "64512"), + ("Asn.2", "64513"), + ("MaxLength", "26"), + ], + ) + .await; + + // One authorization per CIDR and ASN pair. + let body = q + .call( + "GetIpamRouteOriginAuthorizations", + &[("IpamInternetRegistryAssociationId", &id)], + ) + .await; + assert_eq!( + body.matches("203.0.113.0/24").count(), + 2, + "{body}" + ); + assert!( + body.contains("64512") && body.contains("64513"), + "{body}" + ); + + let body = q + .call( + "GetIpamInternetRegistryAssociationAsns", + &[("IpamInternetRegistryAssociationId", &id)], + ) + .await; + assert!( + body.contains("64512") && body.contains("64513"), + "{body}" + ); + + let body = q + .call( + "GetIpamInternetRegistryAssociationCidrs", + &[("IpamInternetRegistryAssociationId", &id)], + ) + .await; + assert!(body.contains("203.0.113.0/24"), "{body}"); +} + +#[test_action("ec2", "GetIpamDiscoveredRoutes", checksum = "222ea81a")] +#[test_action("ec2", "GetIpamRouteProtectionFindings", checksum = "501db0c9")] +#[tokio::test] +async fn ec2_ipam_route_discovery_and_protection_findings() { + let s = TestServer::start().await; + let c = s.ec2_client().await; + let q = Ec2Query::new(&s); + + let ipam = make_ipam(&c).await; + let body = q + .call( + "CreateIpamInternetRegistryAssociation", + &[ + ("IpamId", &ipam), + ("Rir", "ripe"), + ("OrganizationHandle", "EXAMPLE-ORG"), + ], + ) + .await; + let id = xml_value(&body, "ipamInternetRegistryAssociationId"); + q.call( + "CreateIpamRoutingPolicyRegistration", + &[ + ("IpamInternetRegistryAssociationId", &id), + ("Cidr", "192.0.2.0/24"), + ("Asn.1", "64512"), + ], + ) + .await; + + let rd = make_rd(&c).await; + let body = q + .call( + "GetIpamDiscoveredRoutes", + &[ + ("IpamResourceDiscoveryId", &rd), + ("ResourceRegion", "us-east-1"), + ], + ) + .await; + assert!(body.contains("192.0.2.0/24"), "{body}"); + + // A registration carrying an ASN is a valid, signed announcement. + let body = q + .call("GetIpamRouteProtectionFindings", &[("IpamId", &ipam)]) + .await; + assert!(body.contains("valid"), "{body}"); + assert!(body.contains(""), "{body}"); + + // An unknown IPAM is a not-found rather than an empty result. + let (status, _) = q + .send( + "GetIpamRouteProtectionFindings", + &[("IpamId", "ipam-ghost")], + ) + .await; + assert_eq!(status, 400); +} diff --git a/crates/fakecloud-core/src/container_net.rs b/crates/fakecloud-core/src/container_net.rs index 197b7904c..f4bf39be7 100644 --- a/crates/fakecloud-core/src/container_net.rs +++ b/crates/fakecloud-core/src/container_net.rs @@ -90,15 +90,23 @@ fn probe_cli(cli: &str) -> bool { let Ok(mut child) = child else { return false; }; + wait_bounded(&mut child) && child.wait().map(|s| s.success()).unwrap_or(false) +} + +/// Wait for `child` up to [`CLI_PROBE_TIMEOUT`], killing it on expiry. Returns +/// whether it exited on its own. Every container-CLI call goes through this: +/// a liveness probe answering does not promise the next call will, and an +/// unbounded one blocks the caller rather than just that command. +pub fn wait_bounded(child: &mut std::process::Child) -> bool { let deadline = std::time::Instant::now() + CLI_PROBE_TIMEOUT; loop { match child.try_wait() { - Ok(Some(status)) => return status.success(), + Ok(Some(_)) => return true, Ok(None) => {} Err(_) => return false, } if std::time::Instant::now() >= deadline { - // Daemon is wedged: kill the blocked probe and report unavailable. + // Daemon is wedged: kill the blocked call and report failure. let _ = child.kill(); let _ = child.wait(); return false; @@ -107,6 +115,49 @@ fn probe_cli(cli: &str) -> bool { } } +/// Run a container-CLI command and return its stdout, or `None` when it fails +/// or outruns [`CLI_PROBE_TIMEOUT`]. +pub fn bounded_output(cli: &str, args: &[&str]) -> Option { + let mut child = std::process::Command::new(cli) + .args(args) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()) + .spawn() + .ok()?; + // Drain stdout while waiting. A child whose output outgrows the pipe + // buffer blocks on write until someone reads it, so waiting for exit + // first would deadlock until the deadline and then report the sweep as + // failed -- `docker ps -a` across a busy host is exactly that much output. + let mut stdout = child.stdout.take()?; + let reader = std::thread::spawn(move || { + let mut buf = Vec::new(); + let _ = std::io::Read::read_to_end(&mut stdout, &mut buf); + buf + }); + if !wait_bounded(&mut child) { + return None; + } + let status = child.wait().ok()?; + let buf = reader.join().ok()?; + status + .success() + .then(|| String::from_utf8_lossy(&buf).into_owned()) +} + +/// Run a container-CLI command for its effect only, bounded the same way. +/// Returns whether it succeeded. +pub fn bounded_status(cli: &str, args: &[String]) -> bool { + let Ok(mut child) = std::process::Command::new(cli) + .args(args) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + else { + return false; + }; + wait_bounded(&mut child) && child.wait().map(|s| s.success()).unwrap_or(false) +} + /// True when `cli` is podman or a podman-compatible binary. Matches on the /// filename component so absolute paths (`/opt/homebrew/bin/podman`) and /// wrappers (`podman-remote`) both register as podman. Docker Desktop's @@ -587,3 +638,55 @@ mod tests { ); } } + +#[cfg(test)] +mod bounded_cli_tests { + use super::*; + + /// A wedged daemon leaves the CLI blocked on connect forever. Every + /// container call has to end at the bound instead of hanging its caller, + /// which for the reaper means hanging server startup. + #[test] + fn a_hanging_cli_call_is_cut_off() { + let start = std::time::Instant::now(); + let mut child = std::process::Command::new("sleep") + .arg("600") + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .expect("sleep is available"); + assert!(!wait_bounded(&mut child)); + assert!( + start.elapsed() < CLI_PROBE_TIMEOUT + std::time::Duration::from_secs(5), + "the wait must end at the bound" + ); + } + + /// Output larger than a pipe buffer (64 KiB on Linux) must come back + /// whole. Waiting for the child to exit before reading blocks it on write + /// forever, so this used to burn the full timeout and report failure. + #[test] + fn output_larger_than_the_pipe_buffer_still_comes_back() { + let start = std::time::Instant::now(); + // 200_000 bytes: comfortably past the buffer on every supported host. + let out = bounded_output("sh", &["-c", "printf 'x%.0s' $(seq 1 200000)"]) + .expect("a large but prompt call must succeed"); + assert_eq!(out.len(), 200_000, "output was truncated"); + assert!( + start.elapsed() < CLI_PROBE_TIMEOUT, + "a prompt call must not reach the deadline" + ); + } + + #[test] + fn a_prompt_cli_call_returns_its_output() { + assert_eq!( + bounded_output("echo", &["abc123"]) + .as_deref() + .map(str::trim), + Some("abc123") + ); + assert!(bounded_status("true", &[])); + assert!(!bounded_status("false", &[])); + } +} diff --git a/crates/fakecloud-ec2/src/service/ipam_registry.rs b/crates/fakecloud-ec2/src/service/ipam_registry.rs new file mode 100644 index 000000000..9325de18a --- /dev/null +++ b/crates/fakecloud-ec2/src/service/ipam_registry.rs @@ -0,0 +1,1066 @@ +//! IPAM internet-registry associations and the routing policy registrations +//! (RPKI route origin authorizations) published through them. +//! +//! An association ties an IPAM to one Regional Internet Registry. Registrations +//! hang off it, keyed by CIDR, and every change to them produces a delta: the +//! deltas are the audit trail, so they outlive the registrations they describe. + +use chrono::Utc; + +use fakecloud_aws::ec2query::{ec2_elem, ec2_list}; +use fakecloud_core::service::{AwsRequest, AwsResponse, AwsServiceError}; + +use crate::service::Ec2Service; +use crate::service_helpers::{ + gen_id, indexed_list, invalid_parameter_value, not_found, require, validate_enum, + validate_max_results, +}; +use crate::state::{ + Ec2State, IpamInternetRegistryAssociation, IpamRoutingPolicyRegistration, + IpamRoutingPolicyRegistrationDelta, Tag, +}; + +const RIRS: &[&str] = &["ripe", "apnic", "arin", "lacnic"]; + +fn mr(req: &AwsRequest) -> Result<(), AwsServiceError> { + validate_max_results(&req.query_params, 5, 1000) +} + +fn region_of(req: &AwsRequest) -> String { + if req.region.is_empty() { + "us-east-1".to_string() + } else { + req.region.clone() + } +} + +fn dry_run(req: &AwsRequest) -> bool { + req.query_params + .get("DryRun") + .is_some_and(|v| v.eq_ignore_ascii_case("true")) +} + +/// Parse an RFC 3339 time bound, rejecting a malformed one rather than letting +/// a byte comparison silently filter everything out. +fn parse_time_bound( + req: &AwsRequest, + key: &str, +) -> Result>, AwsServiceError> { + match req.query_params.get(key).filter(|v| !v.is_empty()) { + Some(v) => chrono::DateTime::parse_from_rfc3339(v) + .map(|t| Some(t.with_timezone(&Utc))) + .map_err(|_| invalid_parameter_value(format!("Invalid value '{v}' for {key}"))), + None => Ok(None), + } +} + +fn delta_time(d: &IpamRoutingPolicyRegistrationDelta) -> Option> { + chrono::DateTime::parse_from_rfc3339(&d.created_at) + .ok() + .map(|t| t.with_timezone(&Utc)) +} + +/// The prefix length of a CIDR, for comparing a ROA's MaxLength against the +/// prefix it covers. +fn cidr_prefix_len(cidr: &str) -> Option { + cidr.split_once('/') + .and_then(|(_, len)| len.parse::().ok()) +} + +fn now_rfc3339() -> String { + Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true) +} + +fn association_not_found(id: &str) -> AwsServiceError { + not_found("InvalidIpamInternetRegistryAssociationId.NotFound", id) +} + +fn get_association<'a>( + state: &'a mut Ec2State, + id: &str, +) -> Result<&'a mut IpamInternetRegistryAssociation, AwsServiceError> { + state + .ipam_ir_associations + .get_mut(id) + .ok_or_else(|| association_not_found(id)) +} + +fn association_xml(a: &IpamInternetRegistryAssociation, owner: &str, tags: &[Tag]) -> String { + let mut s = String::new(); + s.push_str(&ec2_elem("ownerId", owner)); + s.push_str(&ec2_elem("ipamInternetRegistryAssociationId", &a.id)); + s.push_str(&ec2_elem( + "ipamInternetRegistryAssociationArn", + &format!( + "arn:aws:ec2::{owner}:ipam-internet-registry-association/{}", + a.id + ), + )); + s.push_str(&ec2_elem("ipamId", &a.ipam_id)); + s.push_str(&ec2_elem("ipamRegion", &a.region)); + s.push_str(&ec2_elem("rir", &a.rir)); + s.push_str(&ec2_elem("organizationHandle", &a.organization_handle)); + if let Some(d) = &a.description { + s.push_str(&ec2_elem("description", d)); + } + s.push_str(&ec2_elem("state", &a.state)); + if let Some(x) = &a.child_request_xml { + s.push_str(&ec2_elem("childRequestXml", x)); + } + if !tags.is_empty() { + s.push_str(&super::tags::tag_set_xml(tags)); + } + s +} + +fn delta_xml(d: &IpamRoutingPolicyRegistrationDelta) -> String { + let mut s = String::new(); + s.push_str(&ec2_elem("deltaId", &d.delta_id)); + s.push_str(&ec2_elem("deltaJson", &d.delta_json)); + s.push_str(&ec2_elem("state", &d.state)); + if let Some(m) = &d.state_message { + s.push_str(&ec2_elem("stateMessage", m)); + } + s +} + +fn registration_xml(r: &IpamRoutingPolicyRegistration) -> String { + let mut s = String::new(); + s.push_str(&ec2_elem("cidr", &r.cidr)); + let asns: Vec = r.asns.iter().map(|a| ec2_elem("item", a)).collect(); + if !asns.is_empty() { + s.push_str(&format!("{}", asns.join(""))); + } + if let Some(p) = r.permit_more_specific_announcements { + s.push_str(&format!( + "{p}" + )); + } + if let Some(m) = r.max_length { + s.push_str(&format!("{m}")); + } + if let Some(d) = &r.description { + s.push_str(&ec2_elem("description", d)); + } + s.push_str(&ec2_elem("latestDeltaId", &r.latest_delta_id)); + s.push_str(&ec2_elem("state", &r.state)); + s +} + +/// Record a delta against an association and return its id. Deltas publish +/// immediately here: there is no RIR round trip to wait on. +fn push_delta(a: &mut IpamInternetRegistryAssociation, delta_json: String) -> String { + let delta = IpamRoutingPolicyRegistrationDelta { + delta_id: gen_id("ipam-delta"), + delta_json, + state: "published".to_string(), + state_message: None, + created_at: now_rfc3339(), + }; + let id = delta.delta_id.clone(); + a.deltas.push(delta); + id +} + +fn delta_response( + action: &'static str, + req: &AwsRequest, + d: &IpamRoutingPolicyRegistrationDelta, +) -> AwsResponse { + Ec2Service::respond( + action, + &req.request_id, + &format!( + "{}", + delta_xml(d) + ), + ) +} + +// ---- associations ---- + +pub(crate) fn create_ipam_internet_registry_association( + svc: &Ec2Service, + req: &AwsRequest, +) -> Result { + let ipam_id = require(&req.query_params, "IpamId")?; + let rir = require(&req.query_params, "Rir")?; + let organization_handle = require(&req.query_params, "OrganizationHandle")?; + validate_enum(&req.query_params, "Rir", RIRS)?; + if dry_run(req) { + return Ok(Ec2Service::respond( + "CreateIpamInternetRegistryAssociation", + &req.request_id, + "", + )); + } + + let owner = req.account_id.clone(); + let region = region_of(req); + let id = gen_id("ipam-ir-assoc"); + let association = IpamInternetRegistryAssociation { + id: id.clone(), + ipam_id, + region, + rir, + organization_handle, + description: req.query_params.get("Description").cloned(), + // The association exists but cannot publish until it is enabled + // against the registry's RPKI service. + state: "pending-enable".to_string(), + child_request_xml: None, + registrations: Default::default(), + deltas: Vec::new(), + }; + + let mut accounts = svc.state.write(); + let state = accounts.get_or_create(&req.account_id); + if !state.ipams.contains_key(&association.ipam_id) { + return Err(not_found("InvalidIpamId.NotFound", &association.ipam_id)); + } + let tags = { + crate::service::tags::apply_tag_specifications( + state, + &req.query_params, + &id, + "ipam-internet-registry-association", + ); + state.tags.get(&id).cloned().unwrap_or_default() + }; + state.ipam_ir_associations.insert(id, association.clone()); + Ok(Ec2Service::respond( + "CreateIpamInternetRegistryAssociation", + &req.request_id, + &format!( + "{}", + association_xml(&association, &owner, &tags) + ), + )) +} + +pub(crate) fn enable_ipam_internet_registry_association( + svc: &Ec2Service, + req: &AwsRequest, +) -> Result { + let id = require(&req.query_params, "IpamInternetRegistryAssociationId")?; + let rpki_version = require(&req.query_params, "RpkiVersion")?; + let service_uri = require(&req.query_params, "ServiceUri")?; + let child_handle = require(&req.query_params, "ChildHandle")?; + let parent_handle = require(&req.query_params, "ParentHandle")?; + let parent_bpki_ta = require(&req.query_params, "ParentBpkiTa")?; + + let owner = req.account_id.clone(); + let mut accounts = svc.state.write(); + let state = accounts.get_or_create(&req.account_id); + let tags = state.tags.get(&id).cloned().unwrap_or_default(); + let a = get_association(state, &id)?; + // A DryRun validates the request -- including that the association exists + // -- and changes nothing, matching how the rest of EC2 treats one. + if dry_run(req) { + return Ok(Ec2Service::respond( + "EnableIpamInternetRegistryAssociation", + &req.request_id, + "", + )); + } + // The child request is the RPKI provisioning document the registry needs; + // it is what the caller takes to the RIR to finish setup. + a.child_request_xml = Some(format!( + "\ + {parent_bpki_ta}\ + " + )); + a.state = "enable-complete".to_string(); + let body = format!( + "{}", + association_xml(a, &owner, &tags) + ); + Ok(Ec2Service::respond( + "EnableIpamInternetRegistryAssociation", + &req.request_id, + &body, + )) +} + +pub(crate) fn delete_ipam_internet_registry_association( + svc: &Ec2Service, + req: &AwsRequest, +) -> Result { + let id = require(&req.query_params, "IpamInternetRegistryAssociationId")?; + let owner = req.account_id.clone(); + let mut accounts = svc.state.write(); + let state = accounts.get_or_create(&req.account_id); + if !state.ipam_ir_associations.contains_key(&id) { + return Err(association_not_found(&id)); + } + // A DryRun validates the request -- including that the association exists + // -- and changes nothing, matching how the rest of EC2 treats one. + if dry_run(req) { + return Ok(Ec2Service::respond( + "DeleteIpamInternetRegistryAssociation", + &req.request_id, + "", + )); + } + let tags = state.tags.get(&id).cloned().unwrap_or_default(); + let mut association = state + .ipam_ir_associations + .remove(&id) + .ok_or_else(|| association_not_found(&id))?; + // The response reports the association in its terminal state; the + // registrations published through it go with it. + association.state = "delete-complete".to_string(); + association.registrations.clear(); + state.tags.remove(&id); + Ok(Ec2Service::respond( + "DeleteIpamInternetRegistryAssociation", + &req.request_id, + &format!( + "{}", + association_xml(&association, &owner, &tags) + ), + )) +} + +pub(crate) fn describe_ipam_internet_registry_associations( + svc: &Ec2Service, + req: &AwsRequest, +) -> Result { + mr(req)?; + let ids = indexed_list(&req.query_params, "IpamInternetRegistryAssociationId"); + let owner = req.account_id.clone(); + let accounts = svc.state.read(); + let mut items = Vec::new(); + if let Some(state) = accounts.get(&req.account_id) { + for (id, a) in &state.ipam_ir_associations { + if !ids.is_empty() && !ids.contains(id) { + continue; + } + let tags = state.tags.get(id).cloned().unwrap_or_default(); + items.push(association_xml(a, &owner, &tags)); + } + } + Ok(Ec2Service::respond( + "DescribeIpamInternetRegistryAssociations", + &req.request_id, + &ec2_list("ipamInternetRegistryAssociationSet", &items), + )) +} + +// ---- routing policy registrations ---- + +/// Shared body for Create and Modify: both take the same registration fields +/// and report the delta the change produced. +fn upsert_registration( + svc: &Ec2Service, + req: &AwsRequest, + action: &'static str, +) -> Result { + let id = require(&req.query_params, "IpamInternetRegistryAssociationId")?; + let cidr = require(&req.query_params, "Cidr")?; + let asns = indexed_list(&req.query_params, "Asn"); + if asns.is_empty() { + return Err(invalid_parameter_value("Asns must not be empty")); + } + let max_length = + match req.query_params.get("MaxLength").filter(|v| !v.is_empty()) { + Some(v) => Some(v.parse::().map_err(|_| { + invalid_parameter_value(format!("Invalid value '{v}' for MaxLength")) + })?), + None => None, + }; + // `IpamRoutingPolicyRegistrationMaxLength` carries `@range 0..48`, and the + // member documents that it must not be shorter than the CIDR's own prefix + // length -- a ROA that authorizes less than the prefix it covers announces + // nothing. + crate::service_helpers::validate_int_range(&req.query_params, "MaxLength", 0, 48)?; + if let (Some(m), Some(prefix_len)) = (max_length, cidr_prefix_len(&cidr)) { + if m < prefix_len { + return Err(invalid_parameter_value(format!( + "MaxLength must be greater than or equal to the prefix length of {cidr}" + ))); + } + } + + let mut accounts = svc.state.write(); + let state = accounts.get_or_create(&req.account_id); + let a = get_association(state, &id)?; + // A DryRun validates the request -- including that the association exists + // -- and changes nothing, matching how the rest of EC2 treats one. + if dry_run(req) { + return Ok(Ec2Service::respond(action, &req.request_id, "")); + } + + let creating = action == "CreateIpamRoutingPolicyRegistration"; + if creating && a.registrations.contains_key(&cidr) { + return Err(invalid_parameter_value(format!( + "A routing policy registration already exists for {cidr}" + ))); + } + if !creating && !a.registrations.contains_key(&cidr) { + return Err(not_found( + "InvalidIpamRoutingPolicyRegistration.NotFound", + &cidr, + )); + } + + let delta_json = serde_json::json!({ + "action": if creating { "create" } else { "modify" }, + "cidr": cidr, + "asns": asns, + "maxLength": max_length, + }) + .to_string(); + let delta_id = push_delta(a, delta_json); + a.registrations.insert( + cidr.clone(), + IpamRoutingPolicyRegistration { + cidr, + asns, + permit_more_specific_announcements: req + .query_params + .get("PermitMoreSpecificAnnouncements") + .map(|v| v.eq_ignore_ascii_case("true")), + max_length, + description: req.query_params.get("Description").cloned(), + latest_delta_id: delta_id.clone(), + state: if creating { + "create-complete".to_string() + } else { + "update-complete".to_string() + }, + }, + ); + let delta = a.deltas.last().expect("the delta was just pushed").clone(); + Ok(delta_response(action, req, &delta)) +} + +pub(crate) fn create_ipam_routing_policy_registration( + svc: &Ec2Service, + req: &AwsRequest, +) -> Result { + upsert_registration(svc, req, "CreateIpamRoutingPolicyRegistration") +} + +pub(crate) fn modify_ipam_routing_policy_registration( + svc: &Ec2Service, + req: &AwsRequest, +) -> Result { + upsert_registration(svc, req, "ModifyIpamRoutingPolicyRegistration") +} + +pub(crate) fn delete_ipam_routing_policy_registration( + svc: &Ec2Service, + req: &AwsRequest, +) -> Result { + let id = require(&req.query_params, "IpamInternetRegistryAssociationId")?; + let cidr = require(&req.query_params, "Cidr")?; + let mut accounts = svc.state.write(); + let state = accounts.get_or_create(&req.account_id); + let a = get_association(state, &id)?; + // A DryRun validates the request -- including that the association exists + // -- and changes nothing, matching how the rest of EC2 treats one. + if dry_run(req) { + return Ok(Ec2Service::respond( + "DeleteIpamRoutingPolicyRegistration", + &req.request_id, + "", + )); + } + if a.registrations.remove(&cidr).is_none() { + return Err(not_found( + "InvalidIpamRoutingPolicyRegistration.NotFound", + &cidr, + )); + } + let delta_json = serde_json::json!({ "action": "delete", "cidr": cidr }).to_string(); + push_delta(a, delta_json); + let delta = a.deltas.last().expect("the delta was just pushed").clone(); + Ok(delta_response( + "DeleteIpamRoutingPolicyRegistration", + req, + &delta, + )) +} + +/// A batch of registration changes, described by a JSON document rather than +/// indexed parameters. The whole batch lands as one delta. +pub(crate) fn batch_modify_ipam_routing_policy_registrations( + svc: &Ec2Service, + req: &AwsRequest, +) -> Result { + let id = require(&req.query_params, "IpamInternetRegistryAssociationId")?; + let delta_json = require(&req.query_params, "DeltaJson")?; + let parsed: serde_json::Value = serde_json::from_str(&delta_json) + .map_err(|_| invalid_parameter_value("DeltaJson is not valid JSON"))?; + + let mut accounts = svc.state.write(); + let state = accounts.get_or_create(&req.account_id); + let a = get_association(state, &id)?; + // A DryRun validates the request -- including that the association exists + // -- and changes nothing, matching how the rest of EC2 treats one. + if dry_run(req) { + return Ok(Ec2Service::respond( + "BatchModifyIpamRoutingPolicyRegistrations", + &req.request_id, + "", + )); + } + let delta_id = push_delta(a, delta_json.clone()); + + // The document lists the registrations to add and the CIDRs to remove. + if let Some(additions) = parsed.get("add").and_then(|v| v.as_array()) { + for entry in additions { + let Some(cidr) = entry.get("cidr").and_then(|v| v.as_str()) else { + continue; + }; + let asns: Vec = entry + .get("asns") + .and_then(|v| v.as_array()) + .map(|a| { + a.iter() + .filter_map(|v| v.as_str().map(str::to_string)) + .collect() + }) + .unwrap_or_default(); + a.registrations.insert( + cidr.to_string(), + IpamRoutingPolicyRegistration { + cidr: cidr.to_string(), + asns, + permit_more_specific_announcements: entry + .get("permitMoreSpecificAnnouncements") + .and_then(|v| v.as_bool()), + max_length: entry.get("maxLength").and_then(|v| v.as_i64()), + description: entry + .get("description") + .and_then(|v| v.as_str()) + .map(str::to_string), + latest_delta_id: delta_id.clone(), + state: "create-complete".to_string(), + }, + ); + } + } + if let Some(removals) = parsed.get("remove").and_then(|v| v.as_array()) { + for entry in removals { + if let Some(cidr) = entry.as_str().or_else(|| entry.get("cidr")?.as_str()) { + a.registrations.remove(cidr); + } + } + } + + let delta = a.deltas.last().expect("the delta was just pushed").clone(); + Ok(delta_response( + "BatchModifyIpamRoutingPolicyRegistrations", + req, + &delta, + )) +} + +pub(crate) fn get_ipam_routing_policy_registrations( + svc: &Ec2Service, + req: &AwsRequest, +) -> Result { + mr(req)?; + let id = require(&req.query_params, "IpamInternetRegistryAssociationId")?; + let cidr = req.query_params.get("Cidr").filter(|v| !v.is_empty()); + let accounts = svc.state.read(); + let items: Vec = accounts + .get(&req.account_id) + .and_then(|s| s.ipam_ir_associations.get(&id)) + .ok_or_else(|| association_not_found(&id))? + .registrations + .values() + .filter(|r| cidr.is_none_or(|c| &r.cidr == c)) + .map(registration_xml) + .collect(); + Ok(Ec2Service::respond( + "GetIpamRoutingPolicyRegistrations", + &req.request_id, + &ec2_list("ipamRoutingPolicyRegistrationSet", &items), + )) +} + +pub(crate) fn get_ipam_routing_policy_registration_deltas( + svc: &Ec2Service, + req: &AwsRequest, +) -> Result { + mr(req)?; + let id = require(&req.query_params, "IpamInternetRegistryAssociationId")?; + validate_enum( + &req.query_params, + "ChronologicalOrder", + &["forward", "reverse"], + )?; + let delta_id = req.query_params.get("DeltaId").filter(|v| !v.is_empty()); + let start = parse_time_bound(req, "StartTime")?; + let end = parse_time_bound(req, "EndTime")?; + + let accounts = svc.state.read(); + let a = accounts + .get(&req.account_id) + .and_then(|s| s.ipam_ir_associations.get(&id)) + .ok_or_else(|| association_not_found(&id))?; + + let mut deltas: Vec<&IpamRoutingPolicyRegistrationDelta> = a + .deltas + .iter() + .filter(|d| delta_id.is_none_or(|want| &d.delta_id == want)) + // Compare instants, not strings: the stored timestamps carry + // milliseconds and an SDK omits them when they are zero, so a byte-wise + // `>=` drops every delta in the same second as the bound. + .filter(|d| start.is_none_or(|s| delta_time(d).is_none_or(|t| t >= s))) + .filter(|d| end.is_none_or(|e| delta_time(d).is_none_or(|t| t <= e))) + .collect(); + // Deltas are stored oldest first; `reverse` reports newest first. + if req + .query_params + .get("ChronologicalOrder") + .map(String::as_str) + == Some("reverse") + { + deltas.reverse(); + } + let items: Vec = deltas.into_iter().map(delta_xml).collect(); + Ok(Ec2Service::respond( + "GetIpamRoutingPolicyRegistrationDeltas", + &req.request_id, + &ec2_list("ipamRoutingPolicyRegistrationDeltaSet", &items), + )) +} + +/// The route origin authorizations an association publishes: one per +/// registration and ASN pair, which is the shape a relying party consumes. +pub(crate) fn get_ipam_route_origin_authorizations( + svc: &Ec2Service, + req: &AwsRequest, +) -> Result { + mr(req)?; + let id = require(&req.query_params, "IpamInternetRegistryAssociationId")?; + let cidr = req.query_params.get("Cidr").filter(|v| !v.is_empty()); + let accounts = svc.state.read(); + let a = accounts + .get(&req.account_id) + .and_then(|s| s.ipam_ir_associations.get(&id)) + .ok_or_else(|| association_not_found(&id))?; + + let mut items = Vec::new(); + for r in a.registrations.values() { + if cidr.is_some_and(|c| &r.cidr != c) { + continue; + } + for asn in &r.asns { + let mut s = ec2_elem("cidr", &r.cidr) + &ec2_elem("asn", asn); + if let Some(m) = r.max_length { + s.push_str(&format!("{m}")); + } + items.push(s); + } + } + Ok(Ec2Service::respond( + "GetIpamRouteOriginAuthorizations", + &req.request_id, + &ec2_list("ipamRouteOriginAuthorizationSet", &items), + )) +} + +/// Per-ASN and per-CIDR views of what the registry has observed for an +/// association. Both derive from the registrations it publishes. +pub(crate) fn get_ipam_internet_registry_association_asns( + svc: &Ec2Service, + req: &AwsRequest, +) -> Result { + mr(req)?; + let id = require(&req.query_params, "IpamInternetRegistryAssociationId")?; + let accounts = svc.state.read(); + let a = accounts + .get(&req.account_id) + .and_then(|s| s.ipam_ir_associations.get(&id)) + .ok_or_else(|| association_not_found(&id))?; + + let mut asns: Vec<&String> = a.registrations.values().flat_map(|r| &r.asns).collect(); + asns.sort(); + asns.dedup(); + let now = now_rfc3339(); + let items: Vec = asns + .into_iter() + .map(|asn| ec2_elem("asn", asn) + &ec2_elem("lastObservedAt", &now)) + .collect(); + Ok(Ec2Service::respond( + "GetIpamInternetRegistryAssociationAsns", + &req.request_id, + &ec2_list("ipamInternetRegistryAssociationAsnSet", &items), + )) +} + +pub(crate) fn get_ipam_internet_registry_association_cidrs( + svc: &Ec2Service, + req: &AwsRequest, +) -> Result { + mr(req)?; + let id = require(&req.query_params, "IpamInternetRegistryAssociationId")?; + let accounts = svc.state.read(); + let a = accounts + .get(&req.account_id) + .and_then(|s| s.ipam_ir_associations.get(&id)) + .ok_or_else(|| association_not_found(&id))?; + + let now = now_rfc3339(); + let items: Vec = a + .registrations + .keys() + .map(|cidr| ec2_elem("cidr", cidr) + &ec2_elem("lastObservedAt", &now)) + .collect(); + Ok(Ec2Service::respond( + "GetIpamInternetRegistryAssociationCidrs", + &req.request_id, + &ec2_list("ipamInternetRegistryAssociationCidrSet", &items), + )) +} + +/// Routes a resource discovery has seen in a region. fakecloud runs no BGP +/// collector, so the discovered set is what the account's own registrations +/// advertise there rather than a fabricated view of the internet. +pub(crate) fn get_ipam_discovered_routes( + svc: &Ec2Service, + req: &AwsRequest, +) -> Result { + mr(req)?; + let discovery_id = require(&req.query_params, "IpamResourceDiscoveryId")?; + let resource_region = require(&req.query_params, "ResourceRegion")?; + let owner = req.account_id.clone(); + let accounts = svc.state.read(); + let state = accounts + .get(&req.account_id) + .ok_or_else(|| not_found("InvalidIpamResourceDiscoveryId.NotFound", &discovery_id))?; + if !state.ipam_resource_discoveries.contains_key(&discovery_id) { + return Err(not_found( + "InvalidIpamResourceDiscoveryId.NotFound", + &discovery_id, + )); + } + + let now = now_rfc3339(); + let mut items = Vec::new(); + for a in state.ipam_ir_associations.values() { + if a.region != resource_region { + continue; + } + for r in a.registrations.values() { + let asn = r.asns.first().cloned().unwrap_or_default(); + items.push(format!( + "{}{}{}{}{}{}{}", + ec2_elem("ipamResourceDiscoveryId", &discovery_id), + ec2_elem("resourceRegion", &resource_region), + ec2_elem("resourceOwnerId", &owner), + ec2_elem("cidr", &r.cidr), + ec2_elem("asn", &asn), + ec2_elem("state", "advertised"), + ec2_elem("sampleTime", &now), + )); + } + } + Ok(Ec2Service::respond( + "GetIpamDiscoveredRoutes", + &req.request_id, + &ec2_list("ipamDiscoveredRouteSet", &items), + )) +} + +/// Route protection findings: a registration whose CIDR is authorized for its +/// ASNs is `valid`; one an association publishes with no ASN at all is +/// `unknown`, which is what an unsigned announcement looks like to RPKI. +pub(crate) fn get_ipam_route_protection_findings( + svc: &Ec2Service, + req: &AwsRequest, +) -> Result { + mr(req)?; + let ipam_id = require(&req.query_params, "IpamId")?; + let owner = req.account_id.clone(); + let accounts = svc.state.read(); + let state = accounts + .get(&req.account_id) + .ok_or_else(|| not_found("InvalidIpamId.NotFound", &ipam_id))?; + if !state.ipams.contains_key(&ipam_id) { + return Err(not_found("InvalidIpamId.NotFound", &ipam_id)); + } + + let now = now_rfc3339(); + let mut items = Vec::new(); + for a in state.ipam_ir_associations.values() { + if a.ipam_id != ipam_id { + continue; + } + for r in a.registrations.values() { + let asn = r.asns.first().cloned().unwrap_or_default(); + // `IpamRpkiStrength` is `strict | permissive`. A registration that + // names its origin ASNs authorizes exactly those, which is the + // strict posture; one with none authorizes nothing specific. + let (status, strength) = if r.asns.is_empty() { + ("unknown", "permissive") + } else { + ("valid", "strict") + }; + // A finding's `roaSet` holds `IpamRouteOriginAuthorization`, whose + // prefix member is `prefix`. The `cidr` spelling belongs to + // `IpamRouteOriginAuthorizationInfo`, the shape + // GetIpamRouteOriginAuthorizations returns -- emitting it here + // makes an SDK read the prefix as absent. + let roas: Vec = r + .asns + .iter() + .map(|asn| { + let mut s = ec2_elem("asn", asn) + &ec2_elem("prefix", &r.cidr); + if let Some(m) = r.max_length { + s.push_str(&format!("{m}")); + } + s + }) + .collect(); + let mut finding = format!( + "{}{}{}{}{}{}{}", + ec2_elem("resourceOwnerId", &owner), + ec2_elem("resourceRegion", &a.region), + ec2_elem("cidr", &r.cidr), + ec2_elem("asn", &asn), + ec2_elem("rpkiStatus", status), + ec2_elem("rpkiStrength", strength), + ec2_elem("sampleTime", &now), + ); + if !roas.is_empty() { + finding.push_str(&ec2_list("roaSet", &roas)); + } + items.push(finding); + } + } + Ok(Ec2Service::respond( + "GetIpamRouteProtectionFindings", + &req.request_id, + &format!( + "{}{}", + ec2_elem("ipamId", &ipam_id), + ec2_list("routeProtectionFindingSet", &items) + ), + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::{ec2_request as req, err_of}; + + fn body(resp: AwsResponse) -> String { + String::from_utf8_lossy(resp.body.expect_bytes()).to_string() + } + + /// Register an IPAM directly so an association has something to attach to. + fn seed_ipam(svc: &Ec2Service) { + let mut accounts = svc.state.write(); + let state = accounts.get_or_create("000000000000"); + state.ipams.insert( + "ipam-1".to_string(), + crate::state::Ipam { + id: "ipam-1".to_string(), + public_scope_id: "ipam-scope-pub".to_string(), + private_scope_id: "ipam-scope-priv".to_string(), + tier: "advanced".to_string(), + description: String::new(), + }, + ); + } + + fn make_association(svc: &Ec2Service) -> String { + seed_ipam(svc); + let b = body( + create_ipam_internet_registry_association( + svc, + &req( + "CreateIpamInternetRegistryAssociation", + &[ + ("IpamId", "ipam-1"), + ("Rir", "arin"), + ("OrganizationHandle", "ORG-1"), + ], + ), + ) + .unwrap(), + ); + b.split("") + .nth(1) + .unwrap() + .split("") + .next() + .unwrap() + .to_string() + } + + fn register(svc: &Ec2Service, id: &str, cidr: &str, max_length: Option<&str>) { + let mut params: Vec<(&str, &str)> = vec![ + ("IpamInternetRegistryAssociationId", id), + ("Cidr", cidr), + ("Asn.1", "64512"), + ]; + if let Some(m) = max_length { + params.push(("MaxLength", m)); + } + create_ipam_routing_policy_registration( + svc, + &req("CreateIpamRoutingPolicyRegistration", ¶ms), + ) + .unwrap(); + } + + /// A finding's `roaSet` carries `IpamRouteOriginAuthorization`, whose + /// prefix member is `prefix`; `cidr` belongs to a different shape and an + /// SDK discards it. + #[test] + fn route_protection_findings_use_the_modeled_roa_members() { + let svc = Ec2Service::new(); + let id = make_association(&svc); + register(&svc, &id, "192.0.2.0/24", Some("24")); + + let b = body( + get_ipam_route_protection_findings( + &svc, + &req("GetIpamRouteProtectionFindings", &[("IpamId", "ipam-1")]), + ) + .unwrap(), + ); + assert!(b.contains("192.0.2.0/24"), "{b}"); + assert!( + !b.contains(""), + "cidr is the wrong member name here: {b}" + ); + // `IpamRpkiStrength` is `strict | permissive` — nothing else. + assert!(b.contains("strict"), "{b}"); + assert!(!b.contains("strong"), "{b}"); + } + + /// A DryRun validates the request, so it cannot report success for an + /// association that does not exist. + #[test] + fn a_dry_run_still_resolves_the_association() { + let svc = Ec2Service::new(); + let missing = "ipam-ir-assoc-nope"; + for r in [ + delete_ipam_internet_registry_association( + &svc, + &req( + "DeleteIpamInternetRegistryAssociation", + &[ + ("IpamInternetRegistryAssociationId", missing), + ("DryRun", "true"), + ], + ), + ), + delete_ipam_routing_policy_registration( + &svc, + &req( + "DeleteIpamRoutingPolicyRegistration", + &[ + ("IpamInternetRegistryAssociationId", missing), + ("Cidr", "192.0.2.0/24"), + ("DryRun", "true"), + ], + ), + ), + ] { + assert_eq!( + err_of(r).code(), + "InvalidIpamInternetRegistryAssociationId.NotFound" + ); + } + + // And a dry run against a live association changes nothing. + let id = make_association(&svc); + register(&svc, &id, "192.0.2.0/24", None); + delete_ipam_routing_policy_registration( + &svc, + &req( + "DeleteIpamRoutingPolicyRegistration", + &[ + ("IpamInternetRegistryAssociationId", &id), + ("Cidr", "192.0.2.0/24"), + ("DryRun", "true"), + ], + ), + ) + .unwrap(); + let b = body( + get_ipam_routing_policy_registrations( + &svc, + &req( + "GetIpamRoutingPolicyRegistrations", + &[("IpamInternetRegistryAssociationId", &id)], + ), + ) + .unwrap(), + ); + assert!(b.contains("192.0.2.0/24"), "{b}"); + } + + /// `MaxLength` carries `@range 0..48` and must cover at least the prefix. + #[test] + fn max_length_is_bounded_by_the_model_and_the_prefix() { + let svc = Ec2Service::new(); + let id = make_association(&svc); + for bad in ["49", "200", "16"] { + let err = err_of(create_ipam_routing_policy_registration( + &svc, + &req( + "CreateIpamRoutingPolicyRegistration", + &[ + ("IpamInternetRegistryAssociationId", &id), + ("Cidr", "192.0.2.0/24"), + ("Asn.1", "64512"), + ("MaxLength", bad), + ], + ), + )); + assert_eq!(err.code(), "InvalidParameterValue", "MaxLength={bad}"); + } + register(&svc, &id, "192.0.2.0/24", Some("32")); + } + + /// A time bound is compared as an instant, so a delta recorded in the same + /// second as the bound is not silently dropped, and a malformed bound is + /// rejected rather than filtering everything out. + #[test] + fn delta_time_bounds_compare_instants() { + let svc = Ec2Service::new(); + let id = make_association(&svc); + register(&svc, &id, "192.0.2.0/24", None); + + // A whole-second bound at the epoch start still includes the delta. + let b = body( + get_ipam_routing_policy_registration_deltas( + &svc, + &req( + "GetIpamRoutingPolicyRegistrationDeltas", + &[ + ("IpamInternetRegistryAssociationId", &id), + ("StartTime", "2000-01-01T00:00:00Z"), + ], + ), + ) + .unwrap(), + ); + assert!(b.contains(""), "{b}"); + + let err = err_of(get_ipam_routing_policy_registration_deltas( + &svc, + &req( + "GetIpamRoutingPolicyRegistrationDeltas", + &[ + ("IpamInternetRegistryAssociationId", &id), + ("StartTime", "banana"), + ], + ), + )); + assert_eq!(err.code(), "InvalidParameterValue"); + } +} diff --git a/crates/fakecloud-ec2/src/service/mod.rs b/crates/fakecloud-ec2/src/service/mod.rs index 9d9e0b956..6c26f4f54 100644 --- a/crates/fakecloud-ec2/src/service/mod.rs +++ b/crates/fakecloud-ec2/src/service/mod.rs @@ -15,6 +15,7 @@ pub(crate) mod instance; mod ipam; mod ipam_discovery; mod ipam_policy; +mod ipam_registry; mod lgw; mod meta; mod nacl; @@ -596,6 +597,21 @@ pub const SUPPORTED_ACTIONS: &[&str] = &[ "DescribeIpamExternalResourceVerificationTokens", // IPAM policies + prefix-list resolvers "CreateIpamPolicy", + "BatchModifyIpamRoutingPolicyRegistrations", + "CreateIpamInternetRegistryAssociation", + "CreateIpamRoutingPolicyRegistration", + "DeleteIpamInternetRegistryAssociation", + "DeleteIpamRoutingPolicyRegistration", + "DescribeIpamInternetRegistryAssociations", + "EnableIpamInternetRegistryAssociation", + "GetIpamDiscoveredRoutes", + "GetIpamInternetRegistryAssociationAsns", + "GetIpamInternetRegistryAssociationCidrs", + "GetIpamRouteOriginAuthorizations", + "GetIpamRouteProtectionFindings", + "GetIpamRoutingPolicyRegistrationDeltas", + "GetIpamRoutingPolicyRegistrations", + "ModifyIpamRoutingPolicyRegistration", "DeleteIpamPolicy", "DescribeIpamPolicies", "EnableIpamPolicy", @@ -2234,6 +2250,49 @@ impl AwsService for Ec2Service { ipam_discovery::describe_ipam_external_resource_verification_tokens(self, &request) } "CreateIpamPolicy" => ipam_policy::create_ipam_policy(self, &request), + "BatchModifyIpamRoutingPolicyRegistrations" => { + ipam_registry::batch_modify_ipam_routing_policy_registrations(self, &request) + } + "CreateIpamInternetRegistryAssociation" => { + ipam_registry::create_ipam_internet_registry_association(self, &request) + } + "CreateIpamRoutingPolicyRegistration" => { + ipam_registry::create_ipam_routing_policy_registration(self, &request) + } + "DeleteIpamInternetRegistryAssociation" => { + ipam_registry::delete_ipam_internet_registry_association(self, &request) + } + "DeleteIpamRoutingPolicyRegistration" => { + ipam_registry::delete_ipam_routing_policy_registration(self, &request) + } + "DescribeIpamInternetRegistryAssociations" => { + ipam_registry::describe_ipam_internet_registry_associations(self, &request) + } + "EnableIpamInternetRegistryAssociation" => { + ipam_registry::enable_ipam_internet_registry_association(self, &request) + } + "GetIpamDiscoveredRoutes" => ipam_registry::get_ipam_discovered_routes(self, &request), + "GetIpamInternetRegistryAssociationAsns" => { + ipam_registry::get_ipam_internet_registry_association_asns(self, &request) + } + "GetIpamInternetRegistryAssociationCidrs" => { + ipam_registry::get_ipam_internet_registry_association_cidrs(self, &request) + } + "GetIpamRouteOriginAuthorizations" => { + ipam_registry::get_ipam_route_origin_authorizations(self, &request) + } + "GetIpamRouteProtectionFindings" => { + ipam_registry::get_ipam_route_protection_findings(self, &request) + } + "GetIpamRoutingPolicyRegistrationDeltas" => { + ipam_registry::get_ipam_routing_policy_registration_deltas(self, &request) + } + "GetIpamRoutingPolicyRegistrations" => { + ipam_registry::get_ipam_routing_policy_registrations(self, &request) + } + "ModifyIpamRoutingPolicyRegistration" => { + ipam_registry::modify_ipam_routing_policy_registration(self, &request) + } "DeleteIpamPolicy" => ipam_policy::delete_ipam_policy(self, &request), "DescribeIpamPolicies" => ipam_policy::describe_ipam_policies(self, &request), "EnableIpamPolicy" => ipam_policy::enable_ipam_policy(self, &request), diff --git a/crates/fakecloud-ec2/src/state.rs b/crates/fakecloud-ec2/src/state.rs index 70b1926a5..017459ccb 100644 --- a/crates/fakecloud-ec2/src/state.rs +++ b/crates/fakecloud-ec2/src/state.rs @@ -1161,6 +1161,56 @@ pub struct IpamResourceDiscovery { pub description: String, } +/// An IPAM association with a Regional Internet Registry, and the routing +/// policy registrations (ROAs) published through it. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct IpamInternetRegistryAssociation { + pub id: String, + pub ipam_id: String, + pub region: String, + /// `Rir`: ripe | apnic | arin | lacnic. + pub rir: String, + pub organization_handle: String, + pub description: Option, + /// `IpamInternetRegistryAssociationState`. + pub state: String, + /// Set once the association is enabled against the RIR's RPKI service. + #[serde(default)] + pub child_request_xml: Option, + /// Routing policy registrations, keyed by CIDR. + #[serde(default)] + pub registrations: BTreeMap, + /// Every delta ever applied, oldest first. Deltas are the audit trail of + /// registration changes, so they outlive the registrations themselves. + #[serde(default)] + pub deltas: Vec, +} + +/// One CIDR's route origin authorization within an association. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct IpamRoutingPolicyRegistration { + pub cidr: String, + pub asns: Vec, + pub permit_more_specific_announcements: Option, + pub max_length: Option, + pub description: Option, + pub latest_delta_id: String, + /// `IpamRoutingPolicyRegistrationState`. + pub state: String, +} + +/// A single change to an association's registrations. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct IpamRoutingPolicyRegistrationDelta { + pub delta_id: String, + pub delta_json: String, + /// `IpamRoutingPolicyRegistrationDeltaState`. + pub state: String, + pub state_message: Option, + /// RFC 3339, for the time-window filters on the delta query. + pub created_at: String, +} + /// An IPAM policy. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct IpamPolicy { @@ -1865,6 +1915,9 @@ pub struct Ec2State { /// external-token-id -> ipam-id. #[serde(default)] pub ipam_ext_tokens: BTreeMap, + /// association-id -> the IPAM's internet-registry (RIR) association. + #[serde(default)] + pub ipam_ir_associations: BTreeMap, #[serde(default)] pub ipam_policies: BTreeMap, #[serde(default)] diff --git a/crates/fakecloud-server/src/reaper.rs b/crates/fakecloud-server/src/reaper.rs index 5e8b65edb..43063c703 100644 --- a/crates/fakecloud-server/src/reaper.rs +++ b/crates/fakecloud-server/src/reaper.rs @@ -12,8 +12,6 @@ //! and remove any whose owner is no longer alive. Objects owned by the //! currently-running fakecloud process are always skipped. -use std::process::{Command, Stdio}; - /// Reap orphaned fakecloud-owned containers whose server PID is no longer alive. /// /// Uses the same CLI detection policy as the runtimes: honors @@ -63,13 +61,14 @@ fn reap_orphans(cli: &str, list_args: &[&str], remove_argv: impl Fn(&str) -> Vec "{{.ID}} {{.Label \"fakecloud-instance\"}}", ]); - let output = match Command::new(cli).args(&args).stderr(Stdio::null()).output() { - Ok(o) if o.status.success() => o, - _ => return 0, + // Bounded: the liveness probe answering does not promise this call will, + // and the reap runs synchronously before the server starts serving, so an + // unbounded call here wedges startup rather than just the sweep. + let Some(listing) = fakecloud_core::container_net::bounded_output(cli, &args) else { + return 0; }; let self_pid = std::process::id(); - let listing = String::from_utf8_lossy(&output.stdout); let mut reaped = 0usize; for line in listing.lines() { @@ -85,13 +84,7 @@ fn reap_orphans(cli: &str, list_args: &[&str], remove_argv: impl Fn(&str) -> Vec if pid == self_pid || pid_alive(pid) { continue; } - let removed = Command::new(cli) - .args(remove_argv(id)) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .map(|s| s.success()) - .unwrap_or(false); + let removed = fakecloud_core::container_net::bounded_status(cli, &remove_argv(id)); if removed { reaped += 1; } diff --git a/crates/fakecloud-testkit/src/lib.rs b/crates/fakecloud-testkit/src/lib.rs index e96028786..57cf5e948 100644 --- a/crates/fakecloud-testkit/src/lib.rs +++ b/crates/fakecloud-testkit/src/lib.rs @@ -471,23 +471,76 @@ fn sweep_instance_containers(cli: &str, pid: u32) { return; } let label = format!("fakecloud-instance=fakecloud-{pid}"); - let Ok(output) = Command::new(cli) - .args(["ps", "-aq", "--filter", &format!("label={label}")]) - .stderr(Stdio::null()) - .output() + let Some(ids) = bounded_output(cli, &["ps", "-aq", "--filter", &format!("label={label}")]) else { return; }; - if !output.status.success() { - return; - } - let ids = String::from_utf8_lossy(&output.stdout); for id in ids.split_whitespace() { - let _ = Command::new(cli) - .args(["rm", "-f", id]) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status(); + bounded_status(cli, &["rm", "-f", id]); + } +} + +/// How long any container-CLI call in the harness may take. A healthy daemon +/// answers immediately; a wedged one (stale `DOCKER_HOST`, Docker Desktop mid +/// start, a broken socket) blocks on connect forever, and an unbounded call +/// here hangs the whole test run rather than the one container sweep. +const CLI_TIMEOUT: Duration = Duration::from_secs(10); + +/// Run a container-CLI command, returning its stdout, or `None` when it fails +/// or outruns [`CLI_TIMEOUT`]. +fn bounded_output(cli: &str, args: &[&str]) -> Option { + let mut child = Command::new(cli) + .args(args) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .ok()?; + // Drain stdout while waiting: a child that fills the pipe buffer blocks on + // write, so waiting for exit first would deadlock until the deadline. + let mut stdout = child.stdout.take()?; + let reader = std::thread::spawn(move || { + let mut buf = Vec::new(); + let _ = std::io::Read::read_to_end(&mut stdout, &mut buf); + buf + }); + if !wait_bounded(&mut child) { + return None; + } + let status = child.wait().ok()?; + let buf = reader.join().ok()?; + status + .success() + .then(|| String::from_utf8_lossy(&buf).into_owned()) +} + +/// Run a container-CLI command for its effect only, bounded the same way. +fn bounded_status(cli: &str, args: &[&str]) { + if let Ok(mut child) = Command::new(cli) + .args(args) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + { + wait_bounded(&mut child); + } +} + +/// Wait for `child` up to [`CLI_TIMEOUT`], killing it on expiry. Returns +/// whether it exited on its own. +fn wait_bounded(child: &mut std::process::Child) -> bool { + let deadline = std::time::Instant::now() + CLI_TIMEOUT; + loop { + match child.try_wait() { + Ok(Some(_)) => return true, + Ok(None) => {} + Err(_) => return false, + } + if std::time::Instant::now() >= deadline { + let _ = child.kill(); + let _ = child.wait(); + return false; + } + std::thread::sleep(Duration::from_millis(25)); } } @@ -630,20 +683,10 @@ fn probe_cli(cli: &str) -> bool { else { return false; }; - let deadline = std::time::Instant::now() + Duration::from_secs(10); - loop { - match child.try_wait() { - Ok(Some(status)) => return status.success(), - Ok(None) => {} - Err(_) => return false, - } - if std::time::Instant::now() >= deadline { - let _ = child.kill(); - let _ = child.wait(); - return false; - } - std::thread::sleep(Duration::from_millis(25)); + if !wait_bounded(&mut child) { + return false; } + child.wait().map(|s| s.success()).unwrap_or(false) } /// Prefix that `fakecloud-server` prints before the bound port on stdout. @@ -1012,3 +1055,39 @@ mod handshake_tests { assert_eq!(PORT_HANDSHAKE_PREFIX, "FAKECLOUD_PORT="); } } + +#[cfg(test)] +mod bounded_cli_tests { + use super::*; + + /// A container CLI that never returns must not hang the harness. `sleep` + /// stands in for a wedged daemon: the bound has to cut it off. + #[test] + fn a_hanging_cli_call_is_cut_off() { + let start = std::time::Instant::now(); + let mut child = Command::new("sleep") + .arg("600") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("sleep is available"); + assert!( + !wait_bounded(&mut child), + "a hung call must not report success" + ); + assert!( + start.elapsed() < CLI_TIMEOUT + Duration::from_secs(5), + "the wait must end at the bound, not run on" + ); + } + + #[test] + fn a_prompt_cli_call_returns_its_output() { + assert_eq!( + bounded_output("echo", &["container-id"]) + .as_deref() + .map(str::trim), + Some("container-id") + ); + } +} diff --git a/website/content/docs/about/conformance.md b/website/content/docs/about/conformance.md index d7a35ce05..437a7210b 100644 --- a/website/content/docs/about/conformance.md +++ b/website/content/docs/about/conformance.md @@ -35,7 +35,7 @@ Every response is validated against the operation's Smithy output shape. Missing ## Current coverage -248,557/248,557 generated test variants across all 105 services (7,476 operations) pass on every commit — true 100% conformance with no flake margin and no skipped services. The exact pass/total per service is checked into [`conformance-baseline.json`](https://github.com/faiscadev/fakecloud/blob/main/conformance-baseline.json). +248,557/248,557 generated test variants across all 105 services (7,491 operations) pass on every commit — true 100% conformance with no flake margin and no skipped services. The exact pass/total per service is checked into [`conformance-baseline.json`](https://github.com/faiscadev/fakecloud/blob/main/conformance-baseline.json). See the harness and methodology at [`crates/fakecloud-conformance/`](https://github.com/faiscadev/fakecloud/tree/main/crates/fakecloud-conformance). diff --git a/website/content/docs/migration-from-localstack.md b/website/content/docs/migration-from-localstack.md index 394a974fa..6cc8c7c35 100644 --- a/website/content/docs/migration-from-localstack.md +++ b/website/content/docs/migration-from-localstack.md @@ -9,7 +9,7 @@ If your local development workflow is blocked by LocalStack's account requiremen ## Key Differences - **No API Key**: fakecloud is fully functional offline. No `ACTIVATE_PRO` or account login required. - **Single Binary**: Replace heavy Docker-in-Docker setups with a ~19MB binary that starts in <300ms. -- **Parity**: 100% API conformance across 7,476 operations, including features LocalStack gates behind Pro (like ECR and Bedrock). +- **Parity**: 100% API conformance across 7,491 operations, including features LocalStack gates behind Pro (like ECR and Bedrock). ## Service Mapping | Feature | LocalStack | fakecloud | diff --git a/website/content/docs/parity.md b/website/content/docs/parity.md index dd15d381f..5f7542764 100644 --- a/website/content/docs/parity.md +++ b/website/content/docs/parity.md @@ -4,7 +4,7 @@ description = "Service-by-service behavior parity: what is real, what is synthes weight = 1 +++ -fakecloud implements **105 AWS services** with **7,476 operations**. **248,557/248,557 generated Smithy conformance variants pass** on every commit — true 100% across every implemented service, no flake margin and no skipped services. Conformance checks request/response shapes, field names, and error codes against [AWS's own Smithy models](https://github.com/faiscadev/fakecloud/blob/main/conformance-baseline.json). Behavior parity varies by service — some run real infrastructure (Postgres, Redis, Docker containers), some run a real control plane but return synthesized data for complex queries, and a few have control-plane-only coverage with no data-plane enforcement. +fakecloud implements **105 AWS services** with **7,491 operations**. **248,557/248,557 generated Smithy conformance variants pass** on every commit — true 100% across every implemented service, no flake margin and no skipped services. Conformance checks request/response shapes, field names, and error codes against [AWS's own Smithy models](https://github.com/faiscadev/fakecloud/blob/main/conformance-baseline.json). Behavior parity varies by service — some run real infrastructure (Postgres, Redis, Docker containers), some run a real control plane but return synthesized data for complex queries, and a few have control-plane-only coverage with no data-plane enforcement. | Service | Ops | Protocol | Control plane | Data plane | Known limitations | | --- | --- | --- | --- | --- | --- | @@ -104,7 +104,7 @@ fakecloud implements **105 AWS services** with **7,476 operations**. **248,557/2 | [Firehose](@/docs/services/firehose.md) | 12 | JSON 1.1 | Full | Full | Real S3 destination delivery with buffering hints honored. Other destinations (Redshift, OpenSearch, Splunk, HTTP endpoint) round-trip configuration. Server-side encryption (`Start`/`StopDeliveryStreamEncryption`) persists and surfaces in `DescribeDeliveryStream`. | | [Glue](@/docs/services/glue.md) | 299 | JSON 1.1 | Full | Partial | Full control plane: Data Catalog (databases, tables, partitions with `GetPartitions` `Expression` pruning), jobs, crawlers, classifiers, connections, triggers, workflows, blueprints, dev endpoints, schema registry, interactive sessions, ML transforms, data quality, user-defined functions, usage profiles, column statistics, and tagging. Status transitions are real (crawler `READY`↔`RUNNING`, trigger/workflow/run lifecycles). Job/crawler/Spark *execution* itself is synthesized — fakecloud is not a Spark engine. | | [Organizations](@/docs/services/organizations.md) | 63 | JSON 1.1 | Full | Full | Full org tree (roots, OUs, accounts), policies with SCP enforcement, handshakes, delegated administrators, service access, tagging, and a resource policy. Billing responsibility transfers ride handshake-backed records. `CreateAccount` transitions `IN_PROGRESS` -> `SUCCEEDED` after a short synthetic delay. | -| [EC2](@/docs/services/ec2.md) | 786 | ec2Query | Full | Partial | Full 786-op control plane: VPCs, subnets, security groups, route tables, gateways, ENIs, instances, EBS volumes/snapshots, AMIs (+ watermarks), network ACLs, VPC peering/endpoints, flow logs, launch templates, spot/fleet, capacity/reserved/dedicated hosts, transit gateways (+ multicast/peering/metering/policy-table entries), VPN + Client VPN, IPAM, Verified Access, Network Insights, Outpost/local-gateway/CoIP, and Instance Connect. Instances run as real containers — Docker/Podman by default or native Kubernetes Pods (`FAKECLOUD_EC2_BACKEND=k8s`) — running user-data at boot, with the instance lifecycle mapped to the container lifecycle and `GetConsoleOutput` returning the container log; the control plane degrades to metadata-only when no container runtime is present. A few model ops absent from the vendored SDK are validated via raw ec2Query. | +| [EC2](@/docs/services/ec2.md) | 801 | ec2Query | Full | Partial | Full 786-op control plane: VPCs, subnets, security groups, route tables, gateways, ENIs, instances, EBS volumes/snapshots, AMIs (+ watermarks), network ACLs, VPC peering/endpoints, flow logs, launch templates, spot/fleet, capacity/reserved/dedicated hosts, transit gateways (+ multicast/peering/metering/policy-table entries), VPN + Client VPN, IPAM, Verified Access, Network Insights, Outpost/local-gateway/CoIP, and Instance Connect. Instances run as real containers — Docker/Podman by default or native Kubernetes Pods (`FAKECLOUD_EC2_BACKEND=k8s`) — running user-data at boot, with the instance lifecycle mapped to the container lifecycle and `GetConsoleOutput` returning the container log; the control plane degrades to metadata-only when no container runtime is present. A few model ops absent from the vendored SDK are validated via raw ec2Query. | ## Reading the matrix diff --git a/website/content/docs/services/ec2.md b/website/content/docs/services/ec2.md index 831c10161..b26ce6dc0 100644 --- a/website/content/docs/services/ec2.md +++ b/website/content/docs/services/ec2.md @@ -1,10 +1,10 @@ +++ title = "EC2" -description = "Amazon EC2 — the full 786-operation control plane. VPCs, subnets, security groups, instances, EBS, AMIs, transit gateways, VPN, IPAM, Verified Access, and the entire networking long tail at 100% Smithy conformance." +description = "Amazon EC2 — the full 801-operation control plane. VPCs, subnets, security groups, instances, EBS, AMIs, transit gateways, VPN, IPAM, Verified Access, and the entire networking long tail at 100% Smithy conformance." weight = 41 +++ -fakecloud implements **786 of 786** AWS EC2 operations at 100% Smithy conformance — the complete control plane for the largest service surface in AWS. Request/response shapes, flattened `ec2Query` XML lists, field names, enum validation, and integer/length bounds are checked against AWS's own Smithy model on every commit. +fakecloud implements **801 of 801** AWS EC2 operations at 100% Smithy conformance — the complete control plane for the largest service surface in AWS. Request/response shapes, flattened `ec2Query` XML lists, field names, enum validation, and integer/length bounds are checked against AWS's own Smithy model on every commit. ## Supported features diff --git a/website/content/fake-aws-server.md b/website/content/fake-aws-server.md index 3a5554a6f..583c20e13 100644 --- a/website/content/fake-aws-server.md +++ b/website/content/fake-aws-server.md @@ -16,7 +16,7 @@ Listens on `http://localhost:4566`. Any AWS SDK in any language points at it and ## What "fake AWS server" means here - **Real HTTP server**, not an in-process mock. Your Go / Java / Kotlin / Node / Rust / PHP / Python code uses the regular AWS SDK with `endpoint_url` set to `http://localhost:4566`. -- **Speaks the AWS wire protocol** at true 100% conformance across every implemented service. 105 services, 7,476 operations, 248,557/248,557 Smithy-model-generated test variants pass on every commit. +- **Speaks the AWS wire protocol** at true 100% conformance across every implemented service. 105 services, 7,491 operations, 248,557/248,557 Smithy-model-generated test variants pass on every commit. - **Real execution** for stateful services: Lambda runs your function code in Docker containers across 23 runtimes, RDS runs real PostgreSQL/MySQL/MariaDB/Oracle/SQL Server/Db2, ElastiCache runs real Redis/Valkey/Memcached. - **Real cross-service wiring**: S3 -> Lambda, SQS -> Lambda, SNS fan-out, EventBridge -> Step Functions, and 15+ more integrations execute end-to-end, not as stubs. - **Free, open-source, AGPL-3.0.** No account, no auth token, no paid tier. diff --git a/website/content/faq.md b/website/content/faq.md index bd67b06af..d6097ff78 100644 --- a/website/content/faq.md +++ b/website/content/faq.md @@ -20,7 +20,7 @@ Yes. LocalStack replaced its open-source Community Edition with a proprietary im ### How many AWS services does fakecloud support? -105 services and 7,476 API operations. 248,557/248,557 generated Smithy conformance variants pass on every commit — true 100% across every implemented service, with more services on the roadmap. The explicit goal is 100% of AWS services, each at 100% behavioral conformance, with 100% of cross-service integrations. Services land depth-first — a service is added when it passes the full Smithy-model test variants and cross-service wire-ups. +105 services and 7,491 API operations. 248,557/248,557 generated Smithy conformance variants pass on every commit — true 100% across every implemented service, with more services on the roadmap. The explicit goal is 100% of AWS services, each at 100% behavioral conformance, with 100% of cross-service integrations. Services land depth-first — a service is added when it passes the full Smithy-model test variants and cross-service wire-ups. ### Which AWS services are supported? @@ -109,7 +109,7 @@ GitHub issues: [github.com/faiscadev/fakecloud/issues](https://github.com/faisca {"@type": "Question", "name": "What is fakecloud?", "acceptedAnswer": {"@type": "Answer", "text": "fakecloud is a free, open-source local AWS cloud emulator for integration testing and local development. It runs on a single port (4566), requires no account or auth token, and aims for 100% behavioral conformance with real AWS on every service it implements. AGPL-3.0 licensed."}}, {"@type": "Question", "name": "Is fakecloud free?", "acceptedAnswer": {"@type": "Answer", "text": "Yes. AGPL-3.0, free for commercial use. Using fakecloud as a dev/test dependency has zero AGPL implications for your application."}}, {"@type": "Question", "name": "Is fakecloud a LocalStack alternative?", "acceptedAnswer": {"@type": "Answer", "text": "Yes. LocalStack replaced its open-source Community Edition with a proprietary image in March 2026 that requires an account and auth token. fakecloud is a free, open-source replacement."}}, - {"@type": "Question", "name": "How many AWS services does fakecloud support?", "acceptedAnswer": {"@type": "Answer", "text": "105 services and 7,476 API operations. 248,557/248,557 generated Smithy conformance variants pass on every commit, true 100% across every implemented service, with more on the roadmap. The goal is 100% of AWS services, each at 100% behavioral conformance, with 100% of cross-service integrations."}}, + {"@type": "Question", "name": "How many AWS services does fakecloud support?", "acceptedAnswer": {"@type": "Answer", "text": "105 services and 7,491 API operations. 248,557/248,557 generated Smithy conformance variants pass on every commit, true 100% across every implemented service, with more on the roadmap. The goal is 100% of AWS services, each at 100% behavioral conformance, with 100% of cross-service integrations."}}, {"@type": "Question", "name": "Which AWS services are supported?", "acceptedAnswer": {"@type": "Answer", "text": "S3, SQS, SNS, EventBridge, EventBridge Pipes, EventBridge Scheduler, Lambda, EC2, DynamoDB, IAM, STS, Organizations, SSM, Secrets Manager, CloudWatch Logs, CloudWatch (Metrics & Alarms), KMS, CloudFormation, Cloud Control API, SES (v2 + v1 inbound), Cognito User Pools, Cognito Identity, Kinesis, Firehose, RDS, RDS Data API, Aurora DSQL, Resource Groups, Resource Groups Tagging API, ElastiCache, Step Functions, API Gateway v1 (REST), API Gateway v2 (HTTP), Bedrock, Bedrock Agent, Bedrock Agent Runtime, Bedrock Runtime, ECR, ECS, Elastic Load Balancing v2, CloudFront, Route 53, WAF v2, Application Auto Scaling, Athena, ACM, Glue."}}, {"@type": "Question", "name": "Does fakecloud execute Lambda code for real?", "acceptedAnswer": {"@type": "Answer", "text": "Yes. fakecloud pulls real AWS Lambda runtime containers and executes your handler against them. 27 official runtimes including Node.js 16/18/20/22/24, Python 3.8 through 3.14, Java 11/17/21/25, .NET 6/8/10, Ruby 3.2, Go (go1.x), and custom provided/provided.al2/provided.al2023."}}, {"@type": "Question", "name": "Does fakecloud run real databases for RDS?", "acceptedAnswer": {"@type": "Answer", "text": "Yes. RDS emulation pulls real PostgreSQL, MySQL, MariaDB, Oracle, SQL Server, and Db2 Docker images and runs them as the DB instance."}}, diff --git a/website/content/localstack-alternative.md b/website/content/localstack-alternative.md index df90edcc1..122c36d2d 100644 --- a/website/content/localstack-alternative.md +++ b/website/content/localstack-alternative.md @@ -1,6 +1,6 @@ +++ title = "Free, open-source LocalStack alternative" -description = "fakecloud is a free, open-source local AWS emulator: 105 services, 7,476 operations, 248,557/248,557 Smithy variants pass (true 100% conformance), 6 test-assertion SDKs. No account, no token, no paid tier. Drop-in replacement for LocalStack Community." +description = "fakecloud is a free, open-source local AWS emulator: 105 services, 7,491 operations, 248,557/248,557 Smithy variants pass (true 100% conformance), 6 test-assertion SDKs. No account, no token, no paid tier. Drop-in replacement for LocalStack Community." template = "page.html" aliases = [ "/alternative/localstack/", @@ -31,7 +31,7 @@ This is why fakecloud runs real Lambda code in real runtime containers, runs rea ## What fakecloud gives you - **105 AWS services.** S3, SQS, SNS, EventBridge, EventBridge Pipes, EventBridge Scheduler, Lambda, EC2, DynamoDB, IAM, STS, Organizations, SSM, Secrets Manager, CloudWatch Logs, CloudWatch (Metrics & Alarms), KMS, CloudFormation, Cloud Control API, SES (v2 + v1 inbound), Cognito User Pools, Cognito Identity, Kinesis, Firehose, RDS, RDS Data API, Aurora DSQL, Resource Groups, Resource Groups Tagging API, ElastiCache, Step Functions, API Gateway v1 (REST), API Gateway v2 (HTTP), Bedrock, Bedrock Agent, Bedrock Agent Runtime, Bedrock Runtime, ECR, ECS, Elastic Load Balancing v2, CloudFront, Route 53, WAF v2, Application Auto Scaling, Athena, ACM, Glue. -- **7,476 API operations. True 100% conformance** across every implemented service — 248,557/248,557 Smithy-model-generated test variants pass on every commit. +- **7,491 API operations. True 100% conformance** across every implemented service — 248,557/248,557 Smithy-model-generated test variants pass on every commit. - **Tested against upstream Terraform acceptance tests.** CI runs `hashicorp/terraform-provider-aws` `TestAcc*` suites against fakecloud, catching waiter and field-presence drift that pure SDK tests miss. - **Real Lambda execution.** 23 runtimes in Docker containers. Not a mock, not a stub. Node, Python, Java, Go, .NET, Ruby, custom runtimes. - **Real stateful services.** RDS runs real PostgreSQL/MySQL/MariaDB/Oracle/SQL Server/Db2. ElastiCache runs real Redis/Valkey/Memcached. Your Lambda talking to RDS is talking to a real Postgres (or Oracle, or SQL Server). diff --git a/website/content/supported-services.md b/website/content/supported-services.md index 1c05526fc..da5096df3 100644 --- a/website/content/supported-services.md +++ b/website/content/supported-services.md @@ -1,21 +1,21 @@ +++ title = "AWS Service Coverage & API Conformance" -description = "fakecloud provides 100% API conformance across 7,476 operations. Explore our supported AWS services for local development." +description = "fakecloud provides 100% API conformance across 7,491 operations. Explore our supported AWS services for local development." template = "page.html" +++ -fakecloud provides 100% API conformance across 7,476 operations. Unlike mocks, fakecloud is built against official AWS Smithy models to ensure wire-protocol compatibility and deterministic behavior for local development. +fakecloud provides 100% API conformance across 7,491 operations. Unlike mocks, fakecloud is built against official AWS Smithy models to ensure wire-protocol compatibility and deterministic behavior for local development. ## Coverage Summary - **Total Services**: 105 -- **Total Operations**: 7,476 +- **Total Operations**: 7,491 - **Conformance Engine**: 248,557 Smithy-based test variants - **Startup Time**: ~300ms ## Supported Services ### Compute & Containers -- **EC2**: 786 operations. The complete EC2 control plane — VPCs, subnets, security groups, route tables, gateways, instances, EBS, AMIs, the full 74-op Transit Gateway surface, Site-to-Site + Client VPN, IPAM, Verified Access, Network Insights, and Outpost / local-gateway networking. Instances run as real containers — Docker/Podman by default or native Kubernetes Pods (`FAKECLOUD_EC2_BACKEND=k8s`) — running user-data at boot, with start/stop/reboot/terminate mapped to the container lifecycle and `GetConsoleOutput` returning the container log; degrades to metadata-only when no container runtime is present. +- **EC2**: 801 operations. The complete EC2 control plane — VPCs, subnets, security groups, route tables, gateways, instances, EBS, AMIs, the full 74-op Transit Gateway surface, Site-to-Site + Client VPN, IPAM, Verified Access, Network Insights, and Outpost / local-gateway networking. Instances run as real containers — Docker/Podman by default or native Kubernetes Pods (`FAKECLOUD_EC2_BACKEND=k8s`) — running user-data at boot, with start/stop/reboot/terminate mapped to the container lifecycle and `GetConsoleOutput` returning the container log; degrades to metadata-only when no container runtime is present. - **Lambda**: 73 operations. Full execution environment in real Docker containers across 23 runtimes, cross-service triggers (S3, SNS, SQS, EventBridge). - **ECR**: 58 operations. Full OCI v2 Distribution protocol support for `docker push` and `docker pull`. - **ECS**: 77 operations. Real Fargate-style task execution via Docker, services with rolling deployments, ECS Exec. diff --git a/website/static/llms-full.txt b/website/static/llms-full.txt index 4a267c617..24b61497e 100644 --- a/website/static/llms-full.txt +++ b/website/static/llms-full.txt @@ -6,7 +6,7 @@ fakecloud emulates AWS locally for integration testing and development. It is a single Rust binary (~19 MB, ~300ms startup, ~10 MiB idle memory) — no Docker required to run fakecloud itself, no signup. Point any AWS SDK or the AWS CLI at `http://localhost:4566` with dummy credentials. -**Coverage goal:** 100% of AWS services, each at 100% behavioral conformance, with 100% of cross-service integrations. Approach is depth-first — a service lands when it passes the full Smithy-model test variants and the cross-service wire-ups that matter for it, not when the API surface looks filled in. 105 services (7,476 operations) are shipped today, all at true 100% conformance — 248,557/248,557 generated Smithy variants pass on every commit; more land progressively as they hit the bar. +**Coverage goal:** 100% of AWS services, each at 100% behavioral conformance, with 100% of cross-service integrations. Approach is depth-first — a service lands when it passes the full Smithy-model test variants and the cross-service wire-ups that matter for it, not when the API surface looks filled in. 105 services (7,491 operations) are shipped today, all at true 100% conformance — 248,557/248,557 generated Smithy variants pass on every commit; more land progressively as they hit the bar. Key design principles: - **Depth-first coverage**: every implemented service targets 100% conformance with real AWS, validated on every commit against AWS's own Smithy models — 248,557/248,557 generated test variants pass on every commit, true 100% across every implemented service. CI also runs upstream `hashicorp/terraform-provider-aws` `TestAcc*` suites against fakecloud. @@ -645,7 +645,7 @@ Features: `primary` workgroup + `AwsDataCatalog` GLUE catalog auto-seeded on fir Protocol: JSON 1.1 (`X-Amz-Target: AmazonAthena.`) -### EC2 (767 actions) +### EC2 (791 actions) The complete EC2 control plane — the largest service surface in AWS, all at true 100% Smithy conformance. diff --git a/website/static/llms.txt b/website/static/llms.txt index a1e18a871..1b90d219f 100644 --- a/website/static/llms.txt +++ b/website/static/llms.txt @@ -4,7 +4,7 @@ - **Goal:** 100% of AWS services, each at 100% conformance, with 100% of cross-service integrations. Approach is depth-first — a service is added when it passes the full Smithy-model test variants and cross-service wire-ups, not when the API surface looks filled in. - Single static binary (~19 MB), ~300ms startup, ~10 MiB idle memory, no Docker required to run fakecloud itself -- **105 AWS services shipped today, 7,476 operations, true 100% Smithy conformance — 248,557/248,557 generated test variants pass on every commit**, no flake margin and no skipped services. More services land as they hit the conformance bar; roadmap is driven by real-project demand. +- **105 AWS services shipped today, 7,491 operations, true 100% Smithy conformance — 248,557/248,557 generated test variants pass on every commit**, no flake margin and no skipped services. More services land as they hit the conformance bar; roadmap is driven by real-project demand. - Services: S3, SQS, SNS, EventBridge, EventBridge Pipes, EventBridge Scheduler, Lambda, EC2, DynamoDB, IAM, STS, Organizations, SSM, Secrets Manager, CloudWatch Logs, CloudWatch (Metrics & Alarms), KMS, CloudFormation, Cloud Control API, SES (v2 + v1 inbound), Cognito User Pools, Cognito Identity, Kinesis, Firehose, RDS, RDS Data API, Aurora DSQL, Resource Groups, Resource Groups Tagging API, ElastiCache, MemoryDB, EKS, AWS Backup, AWS AppConfig, Cloud Map, Step Functions, API Gateway v1 (REST), API Gateway v2 (HTTP), Bedrock, Bedrock Agent, Bedrock Agent Runtime, Bedrock Runtime, ECR, ECS, Elastic Load Balancing v2, CloudFront, CloudTrail, Route 53, WAF v2, Application Auto Scaling, Athena, ACM, Glue - 30+ cross-service integrations: S3 notifications, SNS fan-out, EventBridge rules, DynamoDB Streams, CloudWatch Logs subscriptions, Cognito triggers, API Gateway -> Lambda, Step Functions task integrations, SES inbound -> S3/SNS/Lambda, and more - Real Lambda execution via Docker across 23 runtimes (Node.js 16/18/20/22/24, Python 3.8/3.9/3.10/3.11/3.12/3.13/3.14, Java 11/17/21/25, Go 1.x, Ruby 3.3/3.4, .NET 8/10, custom `provided.al2` / `provided.al2023`) @@ -81,7 +81,7 @@ - **Organizations** (63 ops): org tree (roots/OUs/accounts), `CreateAccount` async `IN_PROGRESS -> SUCCEEDED`, policies (SCP/TAG/BACKUP/AISERVICES_OPT_OUT) with **real SCP enforcement** as a permission ceiling under `FAKECLOUD_IAM=strict`, handshakes, delegated administrators, AWS service access, billing responsibility transfers, resource policy, tagging - **Bedrock Agent** (72 ops): agents, agent versions/aliases, action groups, knowledge bases, data sources, flows, prompts, agent collaborators, tagging - **Bedrock Agent Runtime** (31 ops): `InvokeAgent`, `InvokeFlow`, `Retrieve`, `RetrieveAndGenerate`, session management, memory, with configurable + streaming responses -- **EC2** (786 ops): the complete EC2 control plane — VPCs, subnets, security groups, route tables, gateways, instances, EBS, AMIs, the full 74-op Transit Gateway surface, Site-to-Site + Client VPN, IPAM, Verified Access, Network Insights, and Outpost / local-gateway networking. Instances run as real containers — Docker/Podman by default or native Kubernetes Pods (`FAKECLOUD_EC2_BACKEND=k8s`) — running user-data at boot, with start/stop/reboot/terminate mapped to the container lifecycle and `GetConsoleOutput` returning the container log; degrades to metadata-only when no container runtime is present. Real network isolation: a default VPC ships per account+region; each subnet gets its own daemon bridge (cross-VPC instances can't route to each other), and security-group/NACL rules are enforced via host nftables (opt-in `FAKECLOUD_EC2_SG_ENFORCEMENT`, needs CAP_NET_ADMIN) on Docker/Podman or via NetworkPolicy on Kubernetes, degrading to tracked-only without the capability. `ec2Query` protocol with flattened-XML lists +- **EC2** (801 ops): the complete EC2 control plane — VPCs, subnets, security groups, route tables, gateways, instances, EBS, AMIs, the full 74-op Transit Gateway surface, Site-to-Site + Client VPN, IPAM, Verified Access, Network Insights, and Outpost / local-gateway networking. Instances run as real containers — Docker/Podman by default or native Kubernetes Pods (`FAKECLOUD_EC2_BACKEND=k8s`) — running user-data at boot, with start/stop/reboot/terminate mapped to the container lifecycle and `GetConsoleOutput` returning the container log; degrades to metadata-only when no container runtime is present. Real network isolation: a default VPC ships per account+region; each subnet gets its own daemon bridge (cross-VPC instances can't route to each other), and security-group/NACL rules are enforced via host nftables (opt-in `FAKECLOUD_EC2_SG_ENFORCEMENT`, needs CAP_NET_ADMIN) on Docker/Podman or via NetworkPolicy on Kubernetes, degrading to tracked-only without the capability. `ec2Query` protocol with flattened-XML lists ## Introspection endpoints (for tests) diff --git a/website/templates/index.html b/website/templates/index.html index e20e3ab00..855bd2a84 100644 --- a/website/templates/index.html +++ b/website/templates/index.html @@ -12,7 +12,7 @@ "applicationCategory": "DeveloperApplication", "applicationSubCategory": "CloudTestingTool", "operatingSystem": "Linux, macOS, Windows", - "description": "Free, open-source local AWS cloud emulator. 105 services, 7,476 operations, 248,557/248,557 Smithy variants pass — true 100% conformance. Single binary, no account, no auth token.", + "description": "Free, open-source local AWS cloud emulator. 105 services, 7,491 operations, 248,557/248,557 Smithy variants pass — true 100% conformance. Single binary, no account, no auth token.", "url": "https://fakecloud.dev", "downloadUrl": "https://github.com/faiscadev/fakecloud/releases", "codeRepository": "https://github.com/faiscadev/fakecloud", @@ -56,7 +56,7 @@

fakecloud

Local AWS cloud emulator for integration tests. Run your app with normal AWS clients, stay fully local, and use fakecloud SDKs when your tests need deeper visibility.

-

105 services. 7,476 operations. 248,557/248,557 Smithy variants pass — true 100% conformance.

+

105 services. 7,491 operations. 248,557/248,557 Smithy variants pass — true 100% conformance.