Skip to content

Commit 95a4614

Browse files
authored
fix(provisioning): stop unwrapping CloudFront OriginGroups in drift readback (#876)
1 parent 5a36ce8 commit 95a4614

8 files changed

Lines changed: 96 additions & 39 deletions

File tree

docs/_generated/integ-coverage.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -509,7 +509,8 @@
509509
"l2"
510510
],
511511
"s3-cloudfront": [
512-
"l2"
512+
"l2",
513+
"literal"
513514
]
514515
}
515516
},

docs/_generated/integ-last-run.tsv

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,4 +136,4 @@ opensearch-domain-getatt 2026-06-14T10:54:56Z PASS 2000 verify.sh CC-API GetAtt
136136
s3-vectors 2026-06-14T13:39:25Z PASS 60 verify.sh Tags backfill + in-place Tags update() (TagResource/UntagResource) re-run after create dedup; 0 orphans
137137
dynamodb-ondemand 2026-06-14T14:54:16Z PASS 120 verify.sh BillingMode/ProvisionedThroughput in-place UPDATE + backfills; 0 orphans
138138
glue-update-hardening 2026-06-15T01:57:37Z PASS verify.sh rc ok, orph clean (re-run after review fix; deploy+update+destroy clean)
139-
s3-cloudfront 2026-06-15T04:03:59Z PASS verify.sh rc ok, orph clean (#872 re-run after review fixes; bucket policy + distribution clean; clean destroy)
139+
s3-cloudfront 2026-06-15T04:27:23Z PASS verify.sh rc ok, orph clean (#873 OriginGroups drift clean + distribution/bucket-policy clean; clean destroy)

docs/changelog-cdkd.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ The CLAUDE.md `## Known Limitations` section retains the load-bearing summary
1818

1919
**Recently Implemented** (2026-06-15):
2020

21+
- ✅ **Fix: stop unwrapping `AWS::CloudFront::Distribution.OriginGroups` in drift readback (closes the OriginGroups phantom drift, issue [#873](https://github.com/go-to-k/cdkd/issues/873))** — `src/provisioning/providers/cloudfront-distribution-provider.ts`. **Bug:** `OriginGroups` was listed in `QUANTITY_ITEM_FIELDS` (the top-level fields that are a bare array in CFn and a `{ Quantity, Items }` wrapper in the SDK, so cdkd wraps them on write and unwraps them on read). But unlike `Origins` / `CacheBehaviors` / `Aliases` / `CustomErrorResponses` (genuinely bare lists in CFn), the CFn `DistributionConfig.OriginGroups` property is ITSELF a `{ Quantity, Items }` object — confirmed by synthesizing a distribution with an origin group (`Quantity` + `Items[]`, each item carrying inner `{ Quantity, Items }` `Members` and `FailoverCriteria.StatusCodes`). So `convertToCfnFormat` was unwrapping the top-level OriginGroups to a bare array, which no longer matched the template's `{ Quantity, Items }`, firing phantom drift on every `cdkd drift` for any distribution with an origin group. (#871 had suppressed OriginGroups via `getDriftUnknownPaths` as a stopgap, under the mistaken assumption — from that PR's review — that the inner Members/StatusCodes were bare arrays needing a `revertOriginGroup`; the synth evidence shows the whole subtree is `{ Quantity, Items }`, so the real fix is the opposite: don't touch it at all.) **Fix:** remove `OriginGroups` from `QUANTITY_ITEM_FIELDS` so it passes through UNTOUCHED in both directions (create still works — `wrapWithQuantity` was a no-op on the already-`{ Quantity, Items }` value), and remove `DistributionConfig.OriginGroups` from `getDriftUnknownPaths` so origin-group drift is now actually reported. 1 new unit test (OriginGroups `{ Quantity, Items }` with inner Members/StatusCodes passes through byte-equal, not unwrapped); `getDriftUnknownPaths` test updated. Validated against real AWS: the `s3-cloudfront` fixture gains an `OriginGroup` (primary S3-OAI origin + HTTP fallback + failover status codes), and `verify.sh` asserts the distribution carries exactly 1 OriginGroup AND that the whole distribution (OriginGroups now included) reports clean on a fresh deploy.
22+
2123
- ✅ **Fix: canonicalize CloudFront OAI grant principals in `S3BucketPolicyProvider.readCurrentState` (closes the phantom drift on an S3 BucketPolicy granting an OAI, issue [#872](https://github.com/go-to-k/cdkd/issues/872))** — `src/provisioning/providers/s3-bucket-policy-provider.ts`. **Bug:** a bucket policy statement that grants a CloudFront Origin Access Identity (OAI) read access stores its principal as the OAI's S3 canonical user id (`{ CanonicalUser: <64-hex> }`, what CDK's `Fn::GetAtt [<OAI>, S3CanonicalUserId]` resolves to). But `s3:GetBucketPolicy` returns that same principal in TWO other unstable forms over the policy's lifetime: a transient `{ AWS: <IAM-unique-id> }` (e.g. `AIDA…`) right after `PutBucketPolicy`, then the settled `{ AWS: arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity <id> }`. The drift comparator saw these three equivalent representations as different and fired a guaranteed false positive on every `cdkd drift` run for any stack with an OAI + S3 bucket policy (the deploy-time `observedProperties` capture caught the transient unique-id form; the later drift read got the ARN form — neither matched the template's canonical user id). **Fix:** `readCurrentState` now canonicalizes every recognizable OAI principal back to `{ CanonicalUser: <id> }`. For the settled `arn:…:cloudfront:user/…<oaiId>` form it maps the OAI id to its `S3CanonicalUserId`, **preferring the same-stack sibling OAI resource's already-read state attribute** (zero AWS call / no `cloudfront:GetCloudFrontOriginAccessIdentity` IAM grant — the OAI's `S3CanonicalUserId` is a readOnly attribute cdkd already resolved at deploy time, threaded into the drift read via the new `ReadCurrentStateContext.siblings[].attributes` + `.physicalId` fields) and falling back to `GetCloudFrontOriginAccessIdentity(<oaiId>)` only when the OAI is NOT a same-stack sibling (an imported / external OAI). The transient bare-IAM-unique-id form (which carries no recoverable link to the OAI) is canonicalized only by matching the corresponding template statement (same Effect / Action / Resource) carrying a `{ CanonicalUser }` principal — safe because a user cannot author a bare IAM unique id as a bucket-policy principal, so it only ever fires for AWS's transient rendering. Both the deploy-time capture and the drift read run through the same normalization, so both converge to the canonical user id and compare equal; a genuinely-different OAI resolves to a different canonical id so real drift is still detected (strict reconcile — never a blanket suppress). Non-OAI principals (`*`, service principals, normal role ARNs) are untouched. The `ReadCurrentStateContext.siblings` shape gained optional `physicalId` + `attributes` (populated by `buildReadCurrentStateContext` in `src/cli/commands/drift.ts`) so any provider can reconcile a sibling's computed identity from already-read state without an extra AWS call. 13 unit tests (ARN→CanonicalUser via sibling state attribute with NO CloudFront call; strict-reconcile: a DIFFERENT OAI resolves to a DIFFERENT canonical id so real drift is NOT suppressed; AWS-array principal + ambiguous-template-match left unchanged; ARN→CanonicalUser via `GetCloudFrontOriginAccessIdentity` fallback when not a sibling; bare-unique-id→template-match; no-template-match left unchanged; best-effort ARN-lookup-failure left unchanged; non-OAI principals untouched; one CloudFront call cached across statements). Validated against real AWS by extending the `s3-cloudfront` integ's `verify.sh` to assert the `AWS::S3::BucketPolicy` reports clean (in the `clean` drift bucket, not `drifted`) on a fresh deploy — the distribution-scoping caveat the CloudFront PR (#871) added is removed now that the bucket policy no longer phantom-drifts. (Approach mirrors the sibling cdk-real-drift project's fix per the #872 cross-note: read the OAI's `S3CanonicalUserId` from already-read state rather than re-fetching it.)
2224

2325
**Recently Implemented** (2026-06-14):

docs/integ-coverage.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ Registered without an integ fixture, with an explicit `// allow-no-integ: <ratio
5757
| `AWS::CertificateManager::Certificate` | [`acm-certificate`](../tests/integration/acm-certificate/) (l2) |
5858
| `AWS::CloudFormation::Stack` | [`export-nested-stack`](../tests/integration/export-nested-stack/) (literal)<br>[`import-nested-stack`](../tests/integration/import-nested-stack/) (literal)<br>[`nested-stack`](../tests/integration/nested-stack/) (literal)<br>[`nested-stack-3level`](../tests/integration/nested-stack-3level/) (literal)<br>[`nested-stack-deep`](../tests/integration/nested-stack-deep/) (literal) |
5959
| `AWS::CloudFront::CloudFrontOriginAccessIdentity` | [`s3-cloudfront`](../tests/integration/s3-cloudfront/) (l2) |
60-
| `AWS::CloudFront::Distribution` | [`bench-cdk-sample`](../tests/integration/bench-cdk-sample/) (l2)<br>[`cloudfront-function-url`](../tests/integration/cloudfront-function-url/) (l2)<br>[`local-start-cloudfront`](../tests/integration/local-start-cloudfront/) (l2)<br>[`s3-cloudfront`](../tests/integration/s3-cloudfront/) (l2) |
60+
| `AWS::CloudFront::Distribution` | [`bench-cdk-sample`](../tests/integration/bench-cdk-sample/) (l2)<br>[`cloudfront-function-url`](../tests/integration/cloudfront-function-url/) (l2)<br>[`local-start-cloudfront`](../tests/integration/local-start-cloudfront/) (l2)<br>[`s3-cloudfront`](../tests/integration/s3-cloudfront/) (l2,literal) |
6161
| `AWS::CloudTrail::Trail` | [`infra-security`](../tests/integration/infra-security/) (l2,literal) |
6262
| `AWS::CloudWatch::Alarm` | [`cloudwatch`](../tests/integration/cloudwatch/) (l2,literal)<br>[`composite-stack`](../tests/integration/composite-stack/) (l2,literal)<br>[`deep-getatt-chains`](../tests/integration/deep-getatt-chains/) (l1)<br>[`full-stack-demo`](../tests/integration/full-stack-demo/) (l2)<br>[`log-pipeline`](../tests/integration/log-pipeline/) (l2)<br>[`scheduled-task`](../tests/integration/scheduled-task/) (l2) |
6363
| `AWS::CodeBuild::Project` | [`ci-cd`](../tests/integration/ci-cd/) (l2,literal) |

src/provisioning/providers/cloudfront-distribution-provider.ts

Lines changed: 23 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -28,16 +28,21 @@ import type {
2828
} from '../../types/resource.js';
2929

3030
/**
31-
* Fields in the DistributionConfig that follow the { Quantity, Items } pattern.
32-
* The CDK template may provide just Items (an array); we wrap it with Quantity.
31+
* Top-level `DistributionConfig` fields that are a BARE ARRAY in the CFn
32+
* template but a `{ Quantity, Items }` wrapper in the SDK shape — so
33+
* `convertToSdkFormat` wraps them and `convertToCfnFormat` unwraps them.
34+
*
35+
* `OriginGroups` is deliberately NOT here (issue #873): unlike `Origins` /
36+
* `CacheBehaviors` / `Aliases` / `CustomErrorResponses` (bare lists in CFn), the
37+
* CFn `DistributionConfig.OriginGroups` property is ITSELF a `{ Quantity, Items }`
38+
* object, and so are its nested `Members` and `FailoverCriteria.StatusCodes`.
39+
* CDK synthesizes that full `{ Quantity, Items }` shape and `GetDistributionConfig`
40+
* returns the same, so OriginGroups must pass through UNTOUCHED in both
41+
* directions — unwrapping it (it was incorrectly listed here before) turned the
42+
* read-back OriginGroups into a bare array that no longer matched the template's
43+
* `{ Quantity, Items }`, firing phantom drift.
3344
*/
34-
const QUANTITY_ITEM_FIELDS = [
35-
'Origins',
36-
'CacheBehaviors',
37-
'CustomErrorResponses',
38-
'Aliases',
39-
'OriginGroups',
40-
];
45+
const QUANTITY_ITEM_FIELDS = ['Origins', 'CacheBehaviors', 'CustomErrorResponses', 'Aliases'];
4146

4247
/**
4348
* Nested fields inside each CacheBehavior / DefaultCacheBehavior that use
@@ -437,27 +442,18 @@ export class CloudFrontDistributionProvider implements ResourceProvider {
437442
* logging bucket to its `<bucket>.s3.amazonaws.com` regional domain on
438443
* read, which never matches the bare bucket domain a template may
439444
* carry; treat it as drift-unknown to avoid a guaranteed mismatch.
440-
* - `DistributionConfig.OriginGroups`: each origin group carries its own
441-
* inner `{ Quantity, Items }` wrappers (`Members`,
442-
* `FailoverCriteria.StatusCodes`) that `convertToCfnFormat` does not yet
443-
* unwrap symmetrically (only the top-level OriginGroups list is unwrapped;
444-
* there is no `revertOriginGroup` pass, and `convertToSdkFormat` likewise
445-
* does not descend into them). Against the observed baseline both sides go
446-
* through the same `readCurrentState` so this never false-positives on a
447-
* normal deploy, but against the `properties`-fallback baseline (older
448-
* state without `observedProperties`) the raw `{ Quantity, Items }` shape
449-
* would diff the bare-array template form. Suppress it until a dedicated
450-
* fix lands a `revertOriginGroup` + a real OriginGroups integ fixture
451-
* (there is no OriginGroups fixture to verify the inner shape today). See
452-
* the PR #871 review thread.
445+
*
446+
* NOTE: `DistributionConfig.OriginGroups` is NO LONGER suppressed (issue
447+
* #873). The earlier suppression worked around `convertToCfnFormat`
448+
* incorrectly unwrapping the top-level OriginGroups `{ Quantity, Items }`
449+
* wrapper; the real fix (dropping OriginGroups from `QUANTITY_ITEM_FIELDS` so
450+
* it passes through untouched in both directions) makes OriginGroups compare
451+
* equal, so it is now drift-checked normally and a real OriginGroups change
452+
* IS reported.
453453
*/
454454
getDriftUnknownPaths(resourceType: string): string[] {
455455
if (resourceType !== 'AWS::CloudFront::Distribution') return [];
456-
return [
457-
'DistributionConfig.CallerReference',
458-
'DistributionConfig.Logging.Bucket',
459-
'DistributionConfig.OriginGroups',
460-
];
456+
return ['DistributionConfig.CallerReference', 'DistributionConfig.Logging.Bucket'];
461457
}
462458

463459
/**

tests/integration/s3-cloudfront/lib/s3-cloudfront-stack.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,12 +43,24 @@ export class S3CloudFrontStack extends cdk.Stack {
4343
})
4444
);
4545

46+
// Primary S3-via-OAI origin, plus an OriginGroup with an HTTP fallback so
47+
// the distribution carries an `AWS::CloudFront::Distribution.OriginGroups`
48+
// block — exercises CloudFrontDistributionProvider drift normalization of
49+
// the OriginGroups inner `{ Quantity, Items }` wrappers (Members /
50+
// FailoverCriteria.StatusCodes), issue #873.
51+
const s3Origin = origins.S3BucketOrigin.withOriginAccessIdentity(bucket, {
52+
originAccessIdentity: oai,
53+
});
54+
const originGroup = new origins.OriginGroup({
55+
primaryOrigin: s3Origin,
56+
fallbackOrigin: new origins.HttpOrigin('fallback.example.com'),
57+
fallbackStatusCodes: [500, 502, 503, 504],
58+
});
59+
4660
// Create CloudFront Distribution
4761
const distribution = new cloudfront.Distribution(this, 'Distribution', {
4862
defaultBehavior: {
49-
origin: origins.S3BucketOrigin.withOriginAccessIdentity(bucket, {
50-
originAccessIdentity: oai,
51-
}),
63+
origin: originGroup,
5264
viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
5365
},
5466
defaultRootObject: 'index.html',

tests/integration/s3-cloudfront/verify.sh

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,18 @@ if [ -z "${DIST_ARN}" ] || [ "${DIST_ARN}" = "None" ]; then
103103
exit 1
104104
fi
105105

106+
# --- Assertion: the distribution actually carries an OriginGroup -------
107+
# Proves the OriginGroups drift path (#873) is genuinely exercised — if a
108+
# future fixture change drops the origin group, this fails loudly rather than
109+
# silently stop testing OriginGroups normalization.
110+
OG_QTY=$(aws cloudfront get-distribution-config --id "${DIST_ID}" --region "${REGION}" \
111+
--query 'DistributionConfig.OriginGroups.Quantity' --output text 2>/dev/null)
112+
if [ "${OG_QTY}" != "1" ]; then
113+
echo "FAIL: distribution does not carry exactly 1 OriginGroup (got Quantity=${OG_QTY}) — the #873 drift path is not being exercised" >&2
114+
exit 1
115+
fi
116+
echo " OK: distribution carries an OriginGroup (exercises #873 OriginGroups drift normalization)"
117+
106118
# --- Assertion: AWS reflects the two CDK Tags -------------------------
107119
TAGS_JSON=$(aws cloudfront list-tags-for-resource --resource "${DIST_ARN}" --region "${REGION}" \
108120
--query 'Tags.Items' --output json)

tests/unit/provisioning/cloudfront-distribution-provider.test.ts

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -945,6 +945,45 @@ describe('CloudFrontDistributionProvider', () => {
945945
expect(result!['DistributionConfig']).toEqual({ Enabled: true, Comment: '' });
946946
});
947947

948+
it('passes OriginGroups through UNCHANGED — it is {Quantity,Items} in both CFn and the SDK (#873)', async () => {
949+
// The CFn DistributionConfig.OriginGroups property (+ its inner Members /
950+
// FailoverCriteria.StatusCodes) is ITSELF a { Quantity, Items } object, so
951+
// it must NOT be unwrapped like Origins/CacheBehaviors/Aliases — the
952+
// template and the AWS read-back are identical and compare equal.
953+
const originGroups = {
954+
Quantity: 1,
955+
Items: [
956+
{
957+
Id: 'group1',
958+
FailoverCriteria: { StatusCodes: { Quantity: 4, Items: [500, 502, 503, 504] } },
959+
Members: {
960+
Quantity: 2,
961+
Items: [{ OriginId: 'origin1' }, { OriginId: 'origin2' }],
962+
},
963+
},
964+
],
965+
};
966+
mockSend.mockResolvedValueOnce({
967+
DistributionConfig: { CallerReference: 'ref', Enabled: true, Comment: '', OriginGroups: originGroups },
968+
});
969+
mockSend.mockResolvedValueOnce({
970+
Distribution: { ARN: 'arn:aws:cloudfront::111122223333:distribution/EDFDVBD6EXAMPLE' },
971+
});
972+
mockSend.mockResolvedValueOnce({ Tags: { Items: [] } });
973+
974+
const result = await provider.readCurrentState(
975+
'EDFDVBD6EXAMPLE',
976+
'MyDistribution',
977+
'AWS::CloudFront::Distribution'
978+
);
979+
980+
// OriginGroups is byte-equal to the input { Quantity, Items } shape — not
981+
// unwrapped to a bare array (which is what caused the #873 phantom drift).
982+
expect((result!['DistributionConfig'] as Record<string, unknown>)['OriginGroups']).toEqual(
983+
originGroups
984+
);
985+
});
986+
948987
it('still returns DistributionConfig when the tag read fails', async () => {
949988
mockSend.mockResolvedValueOnce({
950989
DistributionConfig: { CallerReference: 'ref', Enabled: true },
@@ -995,15 +1034,10 @@ describe('CloudFrontDistributionProvider', () => {
9951034
});
9961035

9971036
describe('getDriftUnknownPaths', () => {
998-
it('excludes CallerReference + Logging.Bucket + OriginGroups for CloudFront::Distribution', () => {
1037+
it('excludes CallerReference + Logging.Bucket for CloudFront::Distribution (OriginGroups is now drift-checked, #873)', () => {
9991038
expect(provider.getDriftUnknownPaths('AWS::CloudFront::Distribution')).toEqual([
10001039
'DistributionConfig.CallerReference',
10011040
'DistributionConfig.Logging.Bucket',
1002-
// OriginGroups inner {Quantity,Items} (Members / FailoverCriteria.
1003-
// StatusCodes) is not unwrapped by convertToCfnFormat yet, so it is
1004-
// suppressed to avoid a properties-fallback-baseline false positive
1005-
// until a dedicated revertOriginGroup + fixture lands.
1006-
'DistributionConfig.OriginGroups',
10071041
]);
10081042
});
10091043

0 commit comments

Comments
 (0)