Skip to content

Commit aecf9db

Browse files
committed
fix(core,cloudformation): unknown X-Amz-Target -> UnknownOperationException; UpdateStack GetAtt merge (bug-hunt)
- compat (1.44): a request carrying an unrecognized X-Amz-Target fell through to the apigateway catch-all and 404'd with a misleading "Stage not found". It's unambiguously an awsJson call, so dispatch now returns UnknownOperationException (400) the way AWS does. - orchestration (1.46): UpdateStack rebuilt StackResource.attributes from the update handler's subset only, dropping create-time attributes (and the well-known GetAtt overlay), so a 2nd deploy made Fn::GetAtt / Outputs resolve to the literal "Logical.Attr". Both create and update now go through a shared merged_resource_attributes helper that layers create-time attrs, the update handler's fresh values, and the well-known get_att overlay. Confirmed victim: AWS::Pipes::Pipe (CreationTime is create-time-only) now keeps its GetAtt output across an update. Tests: e2e dispatch_unknown_target (UnknownOperationException, not the apigateway 404) and cloudformation_pipes::cfn_update_preserves_create_time_getatt_output.
1 parent e0b0769 commit aecf9db

4 files changed

Lines changed: 208 additions & 11 deletions

File tree

crates/fakecloud-cloudformation/src/service.rs

Lines changed: 45 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,33 @@ use crate::xml_responses;
3030
/// supplement the eagerly-captured `ProvisionResult::attributes` with
3131
/// live-state lookups via `ResourceProvisioner::get_att`. Resources not
3232
/// listed here just use whatever the create handler captured.
33+
/// Build the full GetAtt attribute set for a resource: start from `base` (the
34+
/// create-time attributes, so values like `CreationTime` survive an update),
35+
/// overlay the resource's own attributes (the handler's fresh values), then
36+
/// overlay any well-known attribute the provisioner can still resolve from live
37+
/// state. Used by both create and update so a 2nd deploy's GetAtt/Outputs don't
38+
/// collapse to the update handler's subset (and resolve to literal
39+
/// `"Logical.Attr"`).
40+
fn merged_resource_attributes(
41+
provisioner: &ResourceProvisioner,
42+
resource: &StackResource,
43+
base: std::collections::BTreeMap<String, String>,
44+
) -> std::collections::BTreeMap<String, String> {
45+
let mut attr_map = base;
46+
for (k, v) in &resource.attributes {
47+
attr_map.insert(k.clone(), v.clone());
48+
}
49+
for attr in well_known_attributes_for(&resource.resource_type) {
50+
if attr_map.contains_key(*attr) {
51+
continue;
52+
}
53+
if let Some(v) = provisioner.get_att(resource, attr) {
54+
attr_map.insert((*attr).to_string(), v);
55+
}
56+
}
57+
attr_map
58+
}
59+
3360
fn well_known_attributes_for(resource_type: &str) -> &'static [&'static str] {
3461
match resource_type {
3562
"AWS::S3::Bucket" => &[
@@ -290,15 +317,11 @@ pub(crate) fn provision_stack_resources(
290317
// overlay anything the provisioner can resolve from
291318
// live state (e.g. attributes that depend on side-effects
292319
// recorded after the create handler returned).
293-
let mut attr_map = stack_resource.attributes.clone();
294-
for attr in well_known_attributes_for(&stack_resource.resource_type) {
295-
if attr_map.contains_key(*attr) {
296-
continue;
297-
}
298-
if let Some(v) = provisioner.get_att(&stack_resource, attr) {
299-
attr_map.insert((*attr).to_string(), v);
300-
}
301-
}
320+
let attr_map = merged_resource_attributes(
321+
provisioner,
322+
&stack_resource,
323+
std::collections::BTreeMap::new(),
324+
);
302325
attributes.insert(stack_resource.logical_id.clone(), attr_map.clone());
303326
// Persist the live-resolved attributes onto the stored
304327
// resource so later readers — notably resolve_template_outputs,
@@ -2778,7 +2801,7 @@ pub(crate) fn apply_resource_updates(
27782801
.cloned();
27792802
if let Some(existing) = existing {
27802803
match provisioner.update_resource(&existing, &resolved_def) {
2781-
Ok(Some(updated)) => {
2804+
Ok(Some(mut updated)) => {
27822805
changes.push(ResourceChange {
27832806
action: ResourceChangeAction::Update,
27842807
logical_id: updated.logical_id.clone(),
@@ -2787,7 +2810,18 @@ pub(crate) fn apply_resource_updates(
27872810
});
27882811
physical_ids
27892812
.insert(updated.logical_id.clone(), updated.physical_id.clone());
2790-
attributes.insert(updated.logical_id.clone(), updated.attributes.clone());
2813+
// Merge onto the create-time attribute set + well-known
2814+
// GetAtt overlay so a 2nd deploy doesn't collapse Outputs
2815+
// to the update handler's subset (dropping e.g.
2816+
// CreationTime and making GetAtt resolve to literal
2817+
// "Logical.Attr").
2818+
let merged = merged_resource_attributes(
2819+
provisioner,
2820+
&updated,
2821+
existing.attributes.clone(),
2822+
);
2823+
attributes.insert(updated.logical_id.clone(), merged.clone());
2824+
updated.attributes = merged;
27912825
if let Some(slot) = stack
27922826
.resources
27932827
.iter_mut()

crates/fakecloud-core/src/dispatch.rs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,24 @@ pub async fn dispatch(
8686
match protocol::detect_service(&parts.headers, &query_params, &body_bytes) {
8787
Some(d) => d,
8888
None => {
89+
// A request carrying X-Amz-Target is unambiguously an awsJson
90+
// call whose operation we couldn't map to a known service. AWS
91+
// answers these with UnknownOperationException; routing them to
92+
// the apigateway catch-all below would 404 with a misleading
93+
// "Stage not found" instead.
94+
if let Some(target) = parts
95+
.headers
96+
.get("x-amz-target")
97+
.and_then(|v| v.to_str().ok())
98+
{
99+
return build_error_response(
100+
StatusCode::BAD_REQUEST,
101+
"UnknownOperationException",
102+
&format!("The operation {target} is not recognized."),
103+
&request_id,
104+
AwsProtocol::Json,
105+
);
106+
}
89107
// OPTIONS requests (CORS preflight) don't carry Authorization headers.
90108
// Route them to S3 since S3 is the only REST service that handles CORS.
91109
// Note: API Gateway CORS preflight is not fully supported in this emulator

crates/fakecloud-e2e/tests/cloudformation_pipes.rs

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,3 +126,112 @@ async fn cfn_provisions_and_runs_pipe() {
126126
let gone = pipes.describe_pipe().name("cfn-pipe").send().await;
127127
assert!(gone.is_err(), "stack delete should remove the pipe");
128128
}
129+
130+
// The pipe update handler returns only a subset of attributes (Arn, state,
131+
// LastModifiedTime) — not the create-time CreationTime. UpdateStack must merge
132+
// the update onto the create-time attribute set so a GetAtt CreationTime output
133+
// keeps resolving to the real timestamp instead of the literal "Pipe.CreationTime".
134+
const TEMPLATE_CREATIONTIME_V1: &str = r#"{
135+
"Resources": {
136+
"SrcQ": { "Type": "AWS::SQS::Queue", "Properties": { "QueueName": "cfn-pipe-ct-src" } },
137+
"TgtQ": { "Type": "AWS::SQS::Queue", "Properties": { "QueueName": "cfn-pipe-ct-tgt" } },
138+
"Pipe": {
139+
"Type": "AWS::Pipes::Pipe",
140+
"Properties": {
141+
"Name": "cfn-pipe-ct",
142+
"DesiredState": "RUNNING",
143+
"RoleArn": "arn:aws:iam::000000000000:role/pipe-role",
144+
"Source": { "Fn::GetAtt": ["SrcQ", "Arn"] },
145+
"Target": { "Fn::GetAtt": ["TgtQ", "Arn"] }
146+
}
147+
}
148+
},
149+
"Outputs": {
150+
"PipeCreatedAt": { "Value": { "Fn::GetAtt": ["Pipe", "CreationTime"] } }
151+
}
152+
}"#;
153+
154+
const TEMPLATE_CREATIONTIME_V2: &str = r#"{
155+
"Resources": {
156+
"SrcQ": { "Type": "AWS::SQS::Queue", "Properties": { "QueueName": "cfn-pipe-ct-src" } },
157+
"TgtQ": { "Type": "AWS::SQS::Queue", "Properties": { "QueueName": "cfn-pipe-ct-tgt" } },
158+
"Pipe": {
159+
"Type": "AWS::Pipes::Pipe",
160+
"Properties": {
161+
"Name": "cfn-pipe-ct",
162+
"DesiredState": "STOPPED",
163+
"RoleArn": "arn:aws:iam::000000000000:role/pipe-role",
164+
"Source": { "Fn::GetAtt": ["SrcQ", "Arn"] },
165+
"Target": { "Fn::GetAtt": ["TgtQ", "Arn"] }
166+
}
167+
}
168+
},
169+
"Outputs": {
170+
"PipeCreatedAt": { "Value": { "Fn::GetAtt": ["Pipe", "CreationTime"] } }
171+
}
172+
}"#;
173+
174+
fn output_value(
175+
desc: &aws_sdk_cloudformation::operation::describe_stacks::DescribeStacksOutput,
176+
key: &str,
177+
) -> String {
178+
desc.stacks()[0]
179+
.outputs()
180+
.iter()
181+
.find(|o| o.output_key() == Some(key))
182+
.and_then(|o| o.output_value())
183+
.unwrap_or_default()
184+
.to_string()
185+
}
186+
187+
#[tokio::test]
188+
async fn cfn_update_preserves_create_time_getatt_output() {
189+
let s = TestServer::start().await;
190+
let cfn = s.cloudformation_client().await;
191+
192+
cfn.create_stack()
193+
.stack_name("pipe-ct-stack")
194+
.template_body(TEMPLATE_CREATIONTIME_V1)
195+
.send()
196+
.await
197+
.expect("create_stack");
198+
199+
let after_create = cfn
200+
.describe_stacks()
201+
.stack_name("pipe-ct-stack")
202+
.send()
203+
.await
204+
.unwrap();
205+
let created_at = output_value(&after_create, "PipeCreatedAt");
206+
assert!(
207+
created_at.parse::<i64>().is_ok(),
208+
"CreationTime output should be a timestamp on create, got {created_at:?}"
209+
);
210+
211+
cfn.update_stack()
212+
.stack_name("pipe-ct-stack")
213+
.template_body(TEMPLATE_CREATIONTIME_V2)
214+
.send()
215+
.await
216+
.expect("update_stack");
217+
218+
let after_update = cfn
219+
.describe_stacks()
220+
.stack_name("pipe-ct-stack")
221+
.send()
222+
.await
223+
.unwrap();
224+
let updated_output = output_value(&after_update, "PipeCreatedAt");
225+
assert_ne!(
226+
updated_output, "Pipe.CreationTime",
227+
"GetAtt collapsed to the literal placeholder after update"
228+
);
229+
assert!(
230+
updated_output.parse::<i64>().is_ok(),
231+
"CreationTime output must survive the update as a timestamp, got {updated_output:?}"
232+
);
233+
assert_eq!(
234+
updated_output, created_at,
235+
"CreationTime must be stable across the update"
236+
);
237+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
//! Dispatch compatibility: a request carrying an unrecognized `X-Amz-Target`
2+
//! header is an awsJson call whose operation we can't map to a known service.
3+
//! AWS answers these with `UnknownOperationException`; fakecloud must not route
4+
//! them to the apigateway catch-all (which 404s with a misleading "Stage not
5+
//! found").
6+
7+
mod helpers;
8+
9+
use helpers::TestServer;
10+
11+
#[tokio::test]
12+
async fn unknown_x_amz_target_returns_unknown_operation_exception() {
13+
let server = TestServer::start().await;
14+
let http = reqwest::Client::new();
15+
16+
let resp = http
17+
.post(server.endpoint())
18+
.header("content-type", "application/x-amz-json-1.1")
19+
.header("x-amz-target", "NotARealService_20990101.DoSomething")
20+
.body("{}")
21+
.send()
22+
.await
23+
.expect("request sent");
24+
25+
assert_eq!(resp.status(), 400, "unknown target must be a 400");
26+
let body = resp.text().await.unwrap();
27+
assert!(
28+
body.contains("UnknownOperationException"),
29+
"expected UnknownOperationException, got: {body}"
30+
);
31+
// And crucially not the misleading apigateway catch-all.
32+
assert!(
33+
!body.contains("Stage") && !body.contains("NotFoundException"),
34+
"must not fall through to the apigateway catch-all: {body}"
35+
);
36+
}

0 commit comments

Comments
 (0)