diff --git a/docs/_generated/cli-flag-coverage.json b/docs/_generated/cli-flag-coverage.json
index 3699bd2eb..4ecb922ba 100644
--- a/docs/_generated/cli-flag-coverage.json
+++ b/docs/_generated/cli-flag-coverage.json
@@ -1231,6 +1231,7 @@
"--apply-immediately",
"--archive-name",
"--arg",
+ "--argjson",
"--asset-bucket",
"--assume-role",
"--attribute",
diff --git a/docs/_generated/integ-coverage.json b/docs/_generated/integ-coverage.json
index 01ed5e66b..90bc191c2 100644
--- a/docs/_generated/integ-coverage.json
+++ b/docs/_generated/integ-coverage.json
@@ -868,6 +868,7 @@
"dynamodb-ttl-attr-change",
"fifo-sqs-event-source",
"full-stack-demo",
+ "glue-update-hardening",
"lambda",
"local-invoke-from-cfn-stack",
"local-run-task-from-state",
@@ -928,6 +929,9 @@
"full-stack-demo": [
"l2"
],
+ "glue-update-hardening": [
+ "l2"
+ ],
"lambda": [
"l2"
],
diff --git a/docs/_generated/integ-last-run.tsv b/docs/_generated/integ-last-run.tsv
index 632f6b333..969653119 100644
--- a/docs/_generated/integ-last-run.tsv
+++ b/docs/_generated/integ-last-run.tsv
@@ -119,7 +119,7 @@ gc-custom-asset-names 2026-07-31T06:41:35Z PASS 120 verify.sh TTL-refresh sweep
getatt-fallback-guard 2026-08-01T10:07:21Z PASS 84 verify.sh 0801 regression sweep; strict-getatt paths ok, 0 orphans
getstackoutput-crossregion 2026-07-26T19:05:27Z PASS 79 verify.sh 0727b sweep-b8 staleness re-run (rc=0); account clean
glue-securityconfig-replace 2026-07-26T18:20:27Z PASS 59 verify.sh 0727b sweep-b3 staleness re-run (rc=0); account clean
-glue-update-hardening 2026-07-26T20:00:52Z PASS 75 verify.sh 0727b sweep-b13 staleness re-run (rc=0); account clean
+glue-update-hardening 2026-08-09T06:55:05Z PASS 71 verify.sh #1391 scanAll/scanRate create+update; 3 Glue IAM-propagation retry gaps found and patterned; clean destroy, 0 orphans
iam-access-key 2026-07-31T07:18:11Z PASS 210 verify.sh new fixture #1323: create+status-flip+secret-preservation+destroy clean (1st run false-FAIL on async DeleteSecret probe, poll added)
iam-managed-policy 2026-07-27T16:40:50Z PASS 32 verify.sh issue #1272 IAM pass-through guard; clean destroy 0 orphans
iam-oidc-provider 2026-07-31T06:25:46Z PASS 82 verify.sh TTL-refresh sweep re-run; 3 phases clean, orph clean
diff --git a/docs/cli-flag-coverage.md b/docs/cli-flag-coverage.md
index ce13f5d66..653e823e3 100644
--- a/docs/cli-flag-coverage.md
+++ b/docs/cli-flag-coverage.md
@@ -62,7 +62,7 @@ Reviewer judgment required per flag — many of these are pure-logic flags adequ
| `--verbose` | [`cache-streaming`](../tests/integration/cache-streaming/)
[`conditions`](../tests/integration/conditions/)
[`cross-region-state-bucket`](../tests/integration/cross-region-state-bucket/)
[`deletion-ordering-complex`](../tests/integration/deletion-ordering-complex/)
[`destroy-interrupt`](../tests/integration/destroy-interrupt/)
[`docdb-neptune`](../tests/integration/docdb-neptune/)
[`drift-revert`](../tests/integration/drift-revert/)
[`drift-revert-arrays`](../tests/integration/drift-revert-arrays/)
[`drift-revert-vpc`](../tests/integration/drift-revert-vpc/)
[`dynamodb-globaltable`](../tests/integration/dynamodb-globaltable/)
[`ec2-instance-fanout`](../tests/integration/ec2-instance-fanout/)
[`elasticache-replicationgroup-getatt`](../tests/integration/elasticache-replicationgroup-getatt/)
[`export`](../tests/integration/export/)
[`export-nested-stack`](../tests/integration/export-nested-stack/)
[`iam-managed-policy`](../tests/integration/iam-managed-policy/)
[`import-attributes`](../tests/integration/import-attributes/)
[`import-auto-mode`](../tests/integration/import-auto-mode/)
[`import-nested-stack`](../tests/integration/import-nested-stack/)
[`local-invoke-agentcore-from-state`](../tests/integration/local-invoke-agentcore-from-state/)
[`local-start-alb-from-state`](../tests/integration/local-start-alb-from-state/)
[`macro-expansion`](../tests/integration/macro-expansion/)
[`migrate-from-bare-cfn`](../tests/integration/migrate-from-bare-cfn/)
[`opensearch-domain-getatt`](../tests/integration/opensearch-domain-getatt/)
[`redshift-cluster-getatt`](../tests/integration/redshift-cluster-getatt/)
[`remove-protection`](../tests/integration/remove-protection/)
[`rollback-sqs-cooldown`](../tests/integration/rollback-sqs-cooldown/)
[`stepfunctions`](../tests/integration/stepfunctions/)
[`tags-propagation`](../tests/integration/tags-propagation/)
[`throttle-wide-dag`](../tests/integration/throttle-wide-dag/) |
| `--yes` | [`agentcore-tools`](../tests/integration/agentcore-tools/)
[`apigateway`](../tests/integration/apigateway/)
[`apigatewayv2-update-removal`](../tests/integration/apigatewayv2-update-removal/)
[`apigw-gateway-response`](../tests/integration/apigw-gateway-response/)
[`apigw-stage-throttling`](../tests/integration/apigw-stage-throttling/)
[`apigw-usage-plan-key`](../tests/integration/apigw-usage-plan-key/)
[`appconfig`](../tests/integration/appconfig/)
[`asset-auto-create`](../tests/integration/asset-auto-create/)
[`asset-bootstrap`](../tests/integration/asset-bootstrap/)
[`asset-migration`](../tests/integration/asset-migration/)
[`aws-custom-resource`](../tests/integration/aws-custom-resource/)
[`backup`](../tests/integration/backup/)
[`bootstrap-free-region`](../tests/integration/bootstrap-free-region/)
[`bucket-deployment`](../tests/integration/bucket-deployment/)
[`budgets`](../tests/integration/budgets/)
[`cache-streaming`](../tests/integration/cache-streaming/)
[`cc-api-fallback`](../tests/integration/cc-api-fallback/)
[`cc-api-fallback-transitions`](../tests/integration/cc-api-fallback-transitions/)
[`cc-getatt-readback`](../tests/integration/cc-getatt-readback/)
[`cc-protection-flip`](../tests/integration/cc-protection-flip/)
[`cc-protection-flip-eks`](../tests/integration/cc-protection-flip-eks/)
[`ci-cd`](../tests/integration/ci-cd/)
[`cloudfront-function-url`](../tests/integration/cloudfront-function-url/)
[`cloudwatch-anomaly-detector`](../tests/integration/cloudwatch-anomaly-detector/)
[`codecommit`](../tests/integration/codecommit/)
[`codedeploy-lambda-deployment-group`](../tests/integration/codedeploy-lambda-deployment-group/)
[`cognito`](../tests/integration/cognito/)
[`cognito-custom-attribute-add`](../tests/integration/cognito-custom-attribute-add/)
[`cognito-identity-pool`](../tests/integration/cognito-identity-pool/)
[`cognito-lambda-triggers`](../tests/integration/cognito-lambda-triggers/)
[`cognito-resource-server`](../tests/integration/cognito-resource-server/)
[`cognito-userpool-user-ref`](../tests/integration/cognito-userpool-user-ref/)
[`conditions`](../tests/integration/conditions/)
[`conditions-and-if`](../tests/integration/conditions-and-if/)
[`conditions-update-2`](../tests/integration/conditions-update-2/)
[`custom-resource-getatt-data`](../tests/integration/custom-resource-getatt-data/)
[`data-analytics`](../tests/integration/data-analytics/)
[`deep-getatt-chains`](../tests/integration/deep-getatt-chains/)
[`deletion-ordering-complex`](../tests/integration/deletion-ordering-complex/)
[`deletion-policy-snapshot`](../tests/integration/deletion-policy-snapshot/)
[`deletion-policy-snapshot-heavy`](../tests/integration/deletion-policy-snapshot-heavy/)
[`destroy-data-guard`](../tests/integration/destroy-data-guard/)
[`destroy-interrupt`](../tests/integration/destroy-interrupt/)
[`diff-intrinsic-target-change`](../tests/integration/diff-intrinsic-target-change/)
[`dlm-lifecycle-policy`](../tests/integration/dlm-lifecycle-policy/)
[`docker-image-asset`](../tests/integration/docker-image-asset/)
[`dsql`](../tests/integration/dsql/)
[`dynamodb-autoscaling`](../tests/integration/dynamodb-autoscaling/)
[`dynamodb-gsi-update`](../tests/integration/dynamodb-gsi-update/)
[`dynamodb-ondemand`](../tests/integration/dynamodb-ondemand/)
[`dynamodb-sse`](../tests/integration/dynamodb-sse/)
[`dynamodb-stream-filter`](../tests/integration/dynamodb-stream-filter/)
[`dynamodb-streams`](../tests/integration/dynamodb-streams/)
[`dynamodb-tableclass-switch`](../tests/integration/dynamodb-tableclass-switch/)
[`dynamodb-ttl-attr-change`](../tests/integration/dynamodb-ttl-attr-change/)
[`ec2-instance`](../tests/integration/ec2-instance/)
[`ec2-instance-fanout`](../tests/integration/ec2-instance-fanout/)
[`ecr-scanning`](../tests/integration/ecr-scanning/)
[`ecs-fargate`](../tests/integration/ecs-fargate/)
[`ecs-schedule-targets`](../tests/integration/ecs-schedule-targets/)
[`ecs-service-update-props`](../tests/integration/ecs-service-update-props/)
[`efs-immutable-replacement`](../tests/integration/efs-immutable-replacement/)
[`efs-standalone`](../tests/integration/efs-standalone/)
[`elasticache-replicationgroup-getatt`](../tests/integration/elasticache-replicationgroup-getatt/)
[`emr-cluster`](../tests/integration/emr-cluster/)
[`emr-instance-configs`](../tests/integration/emr-instance-configs/)
[`eventbridge`](../tests/integration/eventbridge/)
[`eventbridge-api-destination`](../tests/integration/eventbridge-api-destination/)
[`eventbridge-archive`](../tests/integration/eventbridge-archive/)
[`eventbridge-input-transformer`](../tests/integration/eventbridge-input-transformer/)
[`eventbridge-pipes`](../tests/integration/eventbridge-pipes/)
[`eventbridge-scheduler`](../tests/integration/eventbridge-scheduler/)
[`eventsourcemapping-race`](../tests/integration/eventsourcemapping-race/)
[`export`](../tests/integration/export/)
[`export-nested-stack`](../tests/integration/export-nested-stack/)
[`fifo-sqs-event-source`](../tests/integration/fifo-sqs-event-source/)
[`fsx-lustre`](../tests/integration/fsx-lustre/)
[`fsx-ontap`](../tests/integration/fsx-ontap/)
[`fsx-openzfs`](../tests/integration/fsx-openzfs/)
[`fsx-windows`](../tests/integration/fsx-windows/)
[`gc-custom-asset-names`](../tests/integration/gc-custom-asset-names/)
[`getatt-fallback-guard`](../tests/integration/getatt-fallback-guard/)
[`getstackoutput-crossregion`](../tests/integration/getstackoutput-crossregion/)
[`glue-securityconfig-replace`](../tests/integration/glue-securityconfig-replace/)
[`glue-update-hardening`](../tests/integration/glue-update-hardening/)
[`iam-access-key`](../tests/integration/iam-access-key/)
[`iam-managed-policy`](../tests/integration/iam-managed-policy/)
[`iam-oidc-provider`](../tests/integration/iam-oidc-provider/)
[`iam-propagation-stress`](../tests/integration/iam-propagation-stress/)
[`iam-role-policies-drift-clean`](../tests/integration/iam-role-policies-drift-clean/)
[`iam-role-prefixed-name-update`](../tests/integration/iam-role-prefixed-name-update/)
[`import-attributes`](../tests/integration/import-attributes/)
[`import-auto-mode`](../tests/integration/import-auto-mode/)
[`import-nested-stack`](../tests/integration/import-nested-stack/)
[`inplace-attr-propagation`](../tests/integration/inplace-attr-propagation/)
[`kinesis-esm-filter`](../tests/integration/kinesis-esm-filter/)
[`kinesis-stream-mode-switch`](../tests/integration/kinesis-stream-mode-switch/)
[`lambda`](../tests/integration/lambda/)
[`lambda-alias-provisioned-concurrency`](../tests/integration/lambda-alias-provisioned-concurrency/)
[`lambda-arch-switch`](../tests/integration/lambda-arch-switch/)
[`lambda-config-field-removal`](../tests/integration/lambda-config-field-removal/)
[`lambda-destinations`](../tests/integration/lambda-destinations/)
[`lambda-env-removal`](../tests/integration/lambda-env-removal/)
[`lambda-esm-self-managed-kafka`](../tests/integration/lambda-esm-self-managed-kafka/)
[`lambda-event-invoke-config-update`](../tests/integration/lambda-event-invoke-config-update/)
[`lambda-layer-version-update`](../tests/integration/lambda-layer-version-update/)
[`lambda-log-retention`](../tests/integration/lambda-log-retention/)
[`lambda-microvm-image`](../tests/integration/lambda-microvm-image/)
[`lambda-reserved-concurrency`](../tests/integration/lambda-reserved-concurrency/)
[`lambda-snapstart`](../tests/integration/lambda-snapstart/)
[`launchtemplate-asg-inplace`](../tests/integration/launchtemplate-asg-inplace/)
[`loggroup-class-guard`](../tests/integration/loggroup-class-guard/)
[`loggroup-kms-associate`](../tests/integration/loggroup-kms-associate/)
[`migrate-from-bare-cfn`](../tests/integration/migrate-from-bare-cfn/)
[`migrate-from-cfn`](../tests/integration/migrate-from-cfn/)
[`multi-asset`](../tests/integration/multi-asset/)
[`nested-stack-3level`](../tests/integration/nested-stack-3level/)
[`nested-stack-deep`](../tests/integration/nested-stack-deep/)
[`nodejs-function`](../tests/integration/nodejs-function/)
[`opensearch-domain-getatt`](../tests/integration/opensearch-domain-getatt/)
[`propagation-races-2`](../tests/integration/propagation-races-2/)
[`raw-cfn-conditions-params`](../tests/integration/raw-cfn-conditions-params/)
[`rds-aurora`](../tests/integration/rds-aurora/)
[`rds-dbinstance-backfill`](../tests/integration/rds-dbinstance-backfill/)
[`rds-full-stack`](../tests/integration/rds-full-stack/)
[`recreate-mixed-direction`](../tests/integration/recreate-mixed-direction/)
[`recreate-via-cc-api`](../tests/integration/recreate-via-cc-api/)
[`recreate-via-sdk-provider`](../tests/integration/recreate-via-sdk-provider/)
[`redshift-cluster-getatt`](../tests/integration/redshift-cluster-getatt/)
[`rename-refactor`](../tests/integration/rename-refactor/)
[`replacement-fanout`](../tests/integration/replacement-fanout/)
[`replacement-immutable-name`](../tests/integration/replacement-immutable-name/)
[`rollback-deletion-policy-snapshot`](../tests/integration/rollback-deletion-policy-snapshot/)
[`route53`](../tests/integration/route53/)
[`s3-asset-deploy`](../tests/integration/s3-asset-deploy/)
[`s3-cloudfront`](../tests/integration/s3-cloudfront/)
[`s3-directory-bucket`](../tests/integration/s3-directory-bucket/)
[`s3-event-notification`](../tests/integration/s3-event-notification/)
[`s3-lifecycle`](../tests/integration/s3-lifecycle/)
[`s3-object-lock`](../tests/integration/s3-object-lock/)
[`s3-replication-and-filter`](../tests/integration/s3-replication-and-filter/)
[`s3-tables`](../tests/integration/s3-tables/)
[`s3-vectors`](../tests/integration/s3-vectors/)
[`scheduler-custom-group`](../tests/integration/scheduler-custom-group/)
[`schema-v5-to-v6-migration`](../tests/integration/schema-v5-to-v6-migration/)
[`schema-v6-to-v7-migration`](../tests/integration/schema-v6-to-v7-migration/)
[`schema-v7-to-v8-migration`](../tests/integration/schema-v7-to-v8-migration/)
[`sdk-ccapi-crossref`](../tests/integration/sdk-ccapi-crossref/)
[`secrets-dynamic-ref`](../tests/integration/secrets-dynamic-ref/)
[`secrets-rotation-schedule`](../tests/integration/secrets-rotation-schedule/)
[`serverless-api`](../tests/integration/serverless-api/)
[`servicediscovery`](../tests/integration/servicediscovery/)
[`servicediscovery-namespaces`](../tests/integration/servicediscovery-namespaces/)
[`ses-identity`](../tests/integration/ses-identity/)
[`sg-circular-dependency`](../tests/integration/sg-circular-dependency/)
[`sns-event-source`](../tests/integration/sns-event-source/)
[`sns-inline-subscription`](../tests/integration/sns-inline-subscription/)
[`sns-pending-subscription`](../tests/integration/sns-pending-subscription/)
[`sns-sqs-event`](../tests/integration/sns-sqs-event/)
[`sns-subscription-filter`](../tests/integration/sns-subscription-filter/)
[`sqs-esm-max-concurrency`](../tests/integration/sqs-esm-max-concurrency/)
[`stepfunctions`](../tests/integration/stepfunctions/)
[`stepfunctions-logging`](../tests/integration/stepfunctions-logging/)
[`stepfunctions-s3-definition`](../tests/integration/stepfunctions-s3-definition/)
[`synthetics-canary`](../tests/integration/synthetics-canary/)
[`throttle-wide-dag`](../tests/integration/throttle-wide-dag/)
[`update-replace`](../tests/integration/update-replace/)
[`wait-condition-handle`](../tests/integration/wait-condition-handle/) |
-## Long-form flags referenced in integs but NOT declared in src/cli/options.ts (279)
+## Long-form flags referenced in integs but NOT declared in src/cli/options.ts (280)
These are mostly third-party CLI flags (`--query` for `aws` / `--region` for `aws s3 ls` / `--no-paginate` / etc.) OR typos of cdkd flag names. Listed here for visibility — review only if a row matches a cdkd flag with a misspelling.
@@ -83,6 +83,7 @@ These are mostly third-party CLI flags (`--query` for `aws` / `--region` for `aw
- `--apply-immediately`
- `--archive-name`
- `--arg`
+- `--argjson`
- `--asset-bucket`
- `--assume-role`
- `--attribute`
@@ -265,6 +266,5 @@ These are mostly third-party CLI flags (`--query` for `aws` / `--region` for `aw
- `--receipt-handle`
- `--recursive`
- `--replication-group-id`
-- `--repository-description`
-_(79 more entries truncated — see `docs/_generated/cli-flag-coverage.json` for the full list.)_
+_(80 more entries truncated — see `docs/_generated/cli-flag-coverage.json` for the full list.)_
diff --git a/docs/integ-coverage.md b/docs/integ-coverage.md
index 0da512811..f388ddbb0 100644
--- a/docs/integ-coverage.md
+++ b/docs/integ-coverage.md
@@ -75,7 +75,7 @@ Registered without an integ fixture, with an explicit `// allow-no-integ: [`lambda`](../tests/integration/lambda/) (l2,literal) |
-| `AWS::DynamoDB::Table` | [`appsync`](../tests/integration/appsync/) (l2)
[`bench-sdk`](../tests/integration/bench-sdk/) (l2)
[`composite-stack`](../tests/integration/composite-stack/) (l2,literal)
[`data-pipeline`](../tests/integration/data-pipeline/) (l2)
[`dynamodb-autoscaling`](../tests/integration/dynamodb-autoscaling/) (l2,literal)
[`dynamodb-gsi-update`](../tests/integration/dynamodb-gsi-update/) (l2)
[`dynamodb-ondemand`](../tests/integration/dynamodb-ondemand/) (l2)
[`dynamodb-sse`](../tests/integration/dynamodb-sse/) (l2,literal)
[`dynamodb-stream-filter`](../tests/integration/dynamodb-stream-filter/) (l2,literal)
[`dynamodb-streams`](../tests/integration/dynamodb-streams/) (l2)
[`dynamodb-tableclass-switch`](../tests/integration/dynamodb-tableclass-switch/) (l2)
[`dynamodb-ttl-attr-change`](../tests/integration/dynamodb-ttl-attr-change/) (l2)
[`fifo-sqs-event-source`](../tests/integration/fifo-sqs-event-source/) (l2,literal)
[`full-stack-demo`](../tests/integration/full-stack-demo/) (l2)
[`lambda`](../tests/integration/lambda/) (l2)
[`local-invoke-from-cfn-stack`](../tests/integration/local-invoke-from-cfn-stack/) (l2)
[`local-run-task-from-state`](../tests/integration/local-run-task-from-state/) (l2)
[`multi-resource`](../tests/integration/multi-resource/) (l2)
[`multi-stack-deps`](../tests/integration/multi-stack-deps/) (l2)
[`remove-protection`](../tests/integration/remove-protection/) (l2,literal)
[`rename-refactor`](../tests/integration/rename-refactor/) (l2,literal)
[`s3-event-notification`](../tests/integration/s3-event-notification/) (l2)
[`serverless-api`](../tests/integration/serverless-api/) (l2)
[`sns-event-source`](../tests/integration/sns-event-source/) (l2)
[`tags-propagation`](../tests/integration/tags-propagation/) (l2) |
+| `AWS::DynamoDB::Table` | [`appsync`](../tests/integration/appsync/) (l2)
[`bench-sdk`](../tests/integration/bench-sdk/) (l2)
[`composite-stack`](../tests/integration/composite-stack/) (l2,literal)
[`data-pipeline`](../tests/integration/data-pipeline/) (l2)
[`dynamodb-autoscaling`](../tests/integration/dynamodb-autoscaling/) (l2,literal)
[`dynamodb-gsi-update`](../tests/integration/dynamodb-gsi-update/) (l2)
[`dynamodb-ondemand`](../tests/integration/dynamodb-ondemand/) (l2)
[`dynamodb-sse`](../tests/integration/dynamodb-sse/) (l2,literal)
[`dynamodb-stream-filter`](../tests/integration/dynamodb-stream-filter/) (l2,literal)
[`dynamodb-streams`](../tests/integration/dynamodb-streams/) (l2)
[`dynamodb-tableclass-switch`](../tests/integration/dynamodb-tableclass-switch/) (l2)
[`dynamodb-ttl-attr-change`](../tests/integration/dynamodb-ttl-attr-change/) (l2)
[`fifo-sqs-event-source`](../tests/integration/fifo-sqs-event-source/) (l2,literal)
[`full-stack-demo`](../tests/integration/full-stack-demo/) (l2)
[`glue-update-hardening`](../tests/integration/glue-update-hardening/) (l2)
[`lambda`](../tests/integration/lambda/) (l2)
[`local-invoke-from-cfn-stack`](../tests/integration/local-invoke-from-cfn-stack/) (l2)
[`local-run-task-from-state`](../tests/integration/local-run-task-from-state/) (l2)
[`multi-resource`](../tests/integration/multi-resource/) (l2)
[`multi-stack-deps`](../tests/integration/multi-stack-deps/) (l2)
[`remove-protection`](../tests/integration/remove-protection/) (l2,literal)
[`rename-refactor`](../tests/integration/rename-refactor/) (l2,literal)
[`s3-event-notification`](../tests/integration/s3-event-notification/) (l2)
[`serverless-api`](../tests/integration/serverless-api/) (l2)
[`sns-event-source`](../tests/integration/sns-event-source/) (l2)
[`tags-propagation`](../tests/integration/tags-propagation/) (l2) |
| `AWS::EC2::EIP` | [`ec2-vpc`](../tests/integration/ec2-vpc/) (l1) |
| `AWS::EC2::Instance` | [`deletion-ordering-complex`](../tests/integration/deletion-ordering-complex/) (l2)
[`ec2-instance`](../tests/integration/ec2-instance/) (l1)
[`ec2-instance-fanout`](../tests/integration/ec2-instance-fanout/) (l1)
[`propagation-races-2`](../tests/integration/propagation-races-2/) (l1)
[`remove-protection`](../tests/integration/remove-protection/) (l2,literal) |
| `AWS::EC2::InternetGateway` | [`alb`](../tests/integration/alb/) (l2)
[`alb-advanced`](../tests/integration/alb-advanced/) (l2)
[`batch`](../tests/integration/batch/) (l2)
[`bench-cdk-sample`](../tests/integration/bench-cdk-sample/) (l2)
[`cache-streaming`](../tests/integration/cache-streaming/) (l2)
[`cc-protection-flip-eks`](../tests/integration/cc-protection-flip-eks/) (l2)
[`deletion-ordering-complex`](../tests/integration/deletion-ordering-complex/) (l2)
[`destroy-interrupt`](../tests/integration/destroy-interrupt/) (l2)
[`docdb-neptune`](../tests/integration/docdb-neptune/) (l2)
[`drift-revert-arrays`](../tests/integration/drift-revert-arrays/) (l2)
[`drift-revert-vpc`](../tests/integration/drift-revert-vpc/) (l2)
[`ec2-instance`](../tests/integration/ec2-instance/) (l2)
[`ec2-instance-fanout`](../tests/integration/ec2-instance-fanout/) (l2)
[`ec2-vpc`](../tests/integration/ec2-vpc/) (l2)
[`ecs-fargate`](../tests/integration/ecs-fargate/) (l2)
[`ecs-schedule-targets`](../tests/integration/ecs-schedule-targets/) (l2)
[`ecs-service-update-props`](../tests/integration/ecs-service-update-props/) (l2)
[`efs-lambda`](../tests/integration/efs-lambda/) (l2)
[`efs-standalone`](../tests/integration/efs-standalone/) (l2)
[`elasticache-replicationgroup-getatt`](../tests/integration/elasticache-replicationgroup-getatt/) (l2)
[`emr-cluster`](../tests/integration/emr-cluster/) (l2)
[`emr-instance-configs`](../tests/integration/emr-instance-configs/) (l2)
[`fsx-lustre`](../tests/integration/fsx-lustre/) (l2)
[`fsx-ontap`](../tests/integration/fsx-ontap/) (l2)
[`fsx-openzfs`](../tests/integration/fsx-openzfs/) (l2)
[`fsx-windows`](../tests/integration/fsx-windows/) (l2)
[`infra-security`](../tests/integration/infra-security/) (l2)
[`launchtemplate-asg-inplace`](../tests/integration/launchtemplate-asg-inplace/) (l2)
[`local-start-alb-from-state`](../tests/integration/local-start-alb-from-state/) (l2,literal)
[`multi-stack-deps`](../tests/integration/multi-stack-deps/) (l2)
[`propagation-races-2`](../tests/integration/propagation-races-2/) (l2)
[`rds-aurora`](../tests/integration/rds-aurora/) (l2)
[`rds-dbinstance-backfill`](../tests/integration/rds-dbinstance-backfill/) (l2)
[`rds-full-stack`](../tests/integration/rds-full-stack/) (l2)
[`redshift-cluster-getatt`](../tests/integration/redshift-cluster-getatt/) (l2)
[`remove-protection`](../tests/integration/remove-protection/) (l2)
[`rollback-failure-injection`](../tests/integration/rollback-failure-injection/) (l2)
[`sg-circular-dependency`](../tests/integration/sg-circular-dependency/) (l2)
[`vpc-lambda`](../tests/integration/vpc-lambda/) (l2)
[`vpc-lambda-cr-race`](../tests/integration/vpc-lambda-cr-race/) (l2)
[`vpc-nat-gateway`](../tests/integration/vpc-nat-gateway/) (l2) |
diff --git a/src/deployment/retryable-errors.ts b/src/deployment/retryable-errors.ts
index c7b154985..4254112c3 100644
--- a/src/deployment/retryable-errors.ts
+++ b/src/deployment/retryable-errors.ts
@@ -43,6 +43,32 @@ export const IAM_PROPAGATION_ERROR_MESSAGE_PATTERNS: readonly string[] = [
// deleted role only burns the bounded retries before surfacing. Surfaced by
// tests/integration/glue-update-hardening.
'is unable to assume provided role',
+ // SECOND wording of the SAME Glue propagation error, seen 2026-08-09 on the
+ // same fixture once the crawler role gained an extra inline policy:
+ // "Service is unable to assume the role arn:aws:iam::...:role/... to access
+ // null. Please verify the role's TrustPolicy." The `provided role` anchor
+ // above does not match it (`the role `), so the deploy failed outright
+ // instead of retrying. Same class, same bounded retries. NOTE this entry
+ // deliberately drops the service-name anchor the Firehose comment argues for
+ // — AWS emits it with no service prefix — so a permanently mis-configured
+ // role burns the bounded retries (~48s) before surfacing. Accepted: the
+ // phrasing is specific enough that a non-propagation match is unlikely, and
+ // the alternative is failing a legitimate deploy outright.
+ 'is unable to assume the role',
+ // THIRD wording of the same race, and the one that survives once the crawler
+ // is correctly ordered after the role + its policy: Glue assumes the fresh
+ // role and the resulting session's token is not valid yet, surfacing as
+ // "The security token included in the request is invalid. (Service:
+ // AmazonDynamoDBv2; ... Error Code: UnrecognizedClientException)".
+ //
+ // The `(Service:` suffix is the load-bearing part of this anchor, not
+ // decoration. That trailer is the Java SDK's wrapped-error format, so it
+ // appears ONLY when the message was produced by an AWS SERVICE acting on our
+ // behalf. cdkd's OWN expired-credential failure comes from the JS SDK and
+ // carries the bare sentence with no trailer — so an expired SSO session still
+ // fails fast instead of burning the retry budget, which a bare
+ // 'security token included in the request is invalid' pattern would break.
+ 'security token included in the request is invalid. (Service:',
'role defined for the function',
'not authorized to perform',
'execution role',
diff --git a/src/provisioning/providers/glue-provider.ts b/src/provisioning/providers/glue-provider.ts
index 4113a6e5f..a5686994e 100644
--- a/src/provisioning/providers/glue-provider.ts
+++ b/src/provisioning/providers/glue-provider.ts
@@ -370,13 +370,16 @@ export class GlueProvider implements ResourceProvider {
// `OpenTableFormatInput` (Apache Iceberg) is a top-level `CreateTableCommand`
// param — a SIBLING of `TableInput`, NOT nested inside it. The CFn shape
- // (`{ IcebergInput: { MetadataOperation, Version } }`) maps 1:1 to the SDK
- // `OpenTableFormatInput` type (same PascalCase). Omit when absent.
+ // matches the SDK `OpenTableFormatInput` type key-for-key EXCEPT for
+ // `IcebergInput.IcebergTableInput`, which cdkd renames — see
+ // {@link toSdkOpenTableFormatInput}. Omit when absent.
// Iceberg's `MetadataOperation: 'CREATE'` is a create-time directive, so it
- // is intentionally wired on create only — `UpdateTableCommandInput` does not
- // accept `OpenTableFormatInput` (verified against @aws-sdk/client-glue).
+ // is intentionally wired on create only — `UpdateTableCommandInput` has no
+ // `OpenTableFormatInput` member at all (it carries the different,
+ // update-only `UpdateOpenTableFormatInput` shape, which CFn does not model;
+ // verified against @aws-sdk/client-glue `UpdateTableRequest`).
const openTableFormatInput = properties['OpenTableFormatInput'] as
- | OpenTableFormatInput
+ | Record
| undefined;
try {
@@ -386,7 +389,7 @@ export class GlueProvider implements ResourceProvider {
DatabaseName: databaseName,
TableInput: this.buildTableInput(tableInput, tableName),
...(openTableFormatInput !== undefined && {
- OpenTableFormatInput: openTableFormatInput,
+ OpenTableFormatInput: toSdkOpenTableFormatInput(openTableFormatInput),
}),
})
);
@@ -1434,6 +1437,39 @@ function sleep(ms: number): Promise {
return new Promise((resolve) => setTimeout(resolve, ms));
}
+/**
+ * Convert the CFn `AWS::Glue::Table.OpenTableFormatInput` blob to the SDK
+ * `OpenTableFormatInput` shape.
+ *
+ * Every key matches the SDK model except one: CFn's
+ * `IcebergInput.IcebergTableInput` is the SDK's
+ * `IcebergInput.CreateIcebergTableInput` (@aws-sdk/client-glue `models_1.d.ts`
+ * `IcebergInput`). The AWS SDK v3 serializer drops unknown members, so leaving
+ * the CFn spelling in place silently discarded the ENTIRE Iceberg table spec
+ * (`Location` / `Schema` / `PartitionSpec` / `WriteOrder` / `Properties`) while
+ * `CreateTable` still reported success. The renamed object's own members match
+ * CFn 1:1, so this is a single key rename.
+ *
+ * Non-object inputs (an unresolved intrinsic) pass through untouched so AWS
+ * surfaces the real validation error.
+ */
+function toSdkOpenTableFormatInput(input: Record): OpenTableFormatInput {
+ const iceberg = input['IcebergInput'];
+ if (
+ typeof iceberg !== 'object' ||
+ iceberg === null ||
+ Array.isArray(iceberg) ||
+ !('IcebergTableInput' in iceberg)
+ ) {
+ return input as OpenTableFormatInput;
+ }
+ const { IcebergTableInput: icebergTableInput, ...rest } = iceberg as Record;
+ return {
+ ...input,
+ IcebergInput: { ...rest, CreateIcebergTableInput: icebergTableInput },
+ } as OpenTableFormatInput;
+}
+
/**
* Build the SDK `EncryptionConfiguration` from the CFn-shape input
* (`AWS::Glue::SecurityConfiguration.EncryptionConfiguration`). Each
@@ -2127,7 +2163,7 @@ export class GlueCrawlerProvider implements ResourceProvider {
new CreateCrawlerCommand({
Name: name,
Role: role,
- Targets: targets as CrawlerTargets,
+ Targets: toSdkCrawlerTargets(targets),
...buildCrawlerCommonFields(properties),
...(tags && { Tags: tags }),
})
@@ -2159,7 +2195,7 @@ export class GlueCrawlerProvider implements ResourceProvider {
Name: physicalId,
...(properties['Role'] !== undefined && { Role: properties['Role'] as string }),
...(properties['Targets'] !== undefined && {
- Targets: properties['Targets'] as CrawlerTargets,
+ Targets: toSdkCrawlerTargets(properties['Targets'] as Record),
}),
...buildCrawlerCommonFields(properties),
};
@@ -2303,7 +2339,11 @@ export class GlueCrawlerProvider implements ResourceProvider {
const result: Record = {
Name: crawler.Name ?? physicalId,
Role: crawler.Role ?? '',
- Targets: crawler.Targets ? pickDefined(crawler.Targets as Record) : {},
+ // SDK `DynamoDBTarget.{scanAll,scanRate}` -> the CFn `ScanAll` / `ScanRate`
+ // spelling recorded in state, so drift compares like with like.
+ Targets: crawler.Targets
+ ? toCfnCrawlerTargets(pickDefined(crawler.Targets as Record))
+ : {},
DatabaseName: crawler.DatabaseName ?? '',
Description: crawler.Description ?? '',
// CFn `Schedule` is the structured wrapper; reverse-map from the
@@ -2441,6 +2481,119 @@ function buildCrawlerCommonFields(p: Record): Record SDK key renames for `Targets.DynamoDBTargets[]`.
+ *
+ * The SDK's `DynamoDBTarget` is a lowercase island in an otherwise-PascalCase
+ * model: `Path` is PascalCase but the scan-tuning members are `scanAll` /
+ * `scanRate` (@aws-sdk/client-glue `models_0.d.ts` `DynamoDBTarget`), while CFn
+ * spells them `ScanAll` / `ScanRate`. The AWS SDK v3 serializer drops unknown
+ * members, so forwarding the CFn spelling silently loses the scan tuning while
+ * the target itself (matched by `Path`) still reaches AWS. Every other
+ * `CrawlerTargets` sub-type (`S3Target` / `JdbcTarget` / `MongoDBTarget` —
+ * whose own `ScanAll` IS PascalCase — `CatalogTarget` / `DeltaTarget` /
+ * `IcebergTarget` / `HudiTarget`) spells every member exactly as CFn does.
+ */
+const CFN_TO_SDK_DYNAMODB_TARGET_KEYS: Record = {
+ ScanAll: 'scanAll',
+ ScanRate: 'scanRate',
+};
+
+/**
+ * CFn is stringly typed, so a template (or an unresolved-then-resolved
+ * intrinsic) can carry `ScanRate: "0.9"`. The SDK models it as a double and the
+ * serializer forwards a string verbatim, so the value has to be coerced HERE —
+ * this converter is the wire boundary for `Targets` now that it re-shapes the
+ * blob. Non-numeric input passes through so AWS surfaces the real validation
+ * error rather than cdkd mangling it.
+ */
+const SDK_DYNAMODB_TARGET_NUMERIC_KEYS: readonly string[] = ['scanRate'];
+
+/**
+ * Same stringly-typed-CFn reasoning as {@link SDK_DYNAMODB_TARGET_NUMERIC_KEYS},
+ * for the boolean member: a hand-written `ScanAll: "false"` would otherwise
+ * forward the STRING `"false"` — which is truthy — to a boolean member.
+ */
+const SDK_DYNAMODB_TARGET_BOOLEAN_KEYS: readonly string[] = ['scanAll'];
+
+/** CFn booleans arrive as `true` / `false` or as the strings `"true"` / `"false"`. */
+function coerceBoolean(value: unknown): unknown {
+ if (typeof value === 'string') {
+ if (value === 'true') return true;
+ if (value === 'false') return false;
+ }
+ return value;
+}
+
+const SDK_TO_CFN_DYNAMODB_TARGET_KEYS: Record = {
+ scanAll: 'ScanAll',
+ scanRate: 'ScanRate',
+};
+
+/**
+ * Shallow-rename an object's keys per `renames`, leaving unlisted keys — and
+ * non-object values (an unresolved intrinsic) — untouched.
+ */
+function renameRecordKeys(
+ entry: unknown,
+ renames: Record,
+ numericKeys: readonly string[] = [],
+ booleanKeys: readonly string[] = []
+): unknown {
+ if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) return entry;
+ const out: Record = {};
+ for (const [k, v] of Object.entries(entry as Record)) {
+ const key = renames[k] ?? k;
+ if (numericKeys.includes(key)) out[key] = coerceNumber(v);
+ else if (booleanKeys.includes(key)) out[key] = coerceBoolean(v);
+ else out[key] = v;
+ }
+ return out;
+}
+
+/**
+ * Apply `renames` to every element of `targets[key]` when that entry is an
+ * array, returning the original object untouched otherwise so a non-array value
+ * reaches AWS verbatim and surfaces the real validation error.
+ */
+function renameCrawlerTargetList(
+ targets: Record,
+ key: string,
+ renames: Record,
+ numericKeys: readonly string[] = [],
+ booleanKeys: readonly string[] = []
+): Record {
+ const list = targets[key];
+ if (!Array.isArray(list)) return targets;
+ return {
+ ...targets,
+ [key]: list.map((entry) => renameRecordKeys(entry, renames, numericKeys, booleanKeys)),
+ };
+}
+
+/**
+ * Convert the CFn `AWS::Glue::Crawler.Targets` blob to the SDK `CrawlerTargets`
+ * shape — see {@link CFN_TO_SDK_DYNAMODB_TARGET_KEYS} for the one divergence.
+ */
+function toSdkCrawlerTargets(targets: Record): CrawlerTargets {
+ return renameCrawlerTargetList(
+ targets,
+ 'DynamoDBTargets',
+ CFN_TO_SDK_DYNAMODB_TARGET_KEYS,
+ SDK_DYNAMODB_TARGET_NUMERIC_KEYS,
+ SDK_DYNAMODB_TARGET_BOOLEAN_KEYS
+ ) as CrawlerTargets;
+}
+
+/**
+ * Inverse of {@link toSdkCrawlerTargets}: re-shape a `GetCrawler` `Targets`
+ * blob back into the CFn spelling so `cdkd drift` compares like with like
+ * instead of reporting a phantom `ScanRate` removal + `scanRate` addition.
+ */
+function toCfnCrawlerTargets(targets: Record): Record {
+ return renameCrawlerTargetList(targets, 'DynamoDBTargets', SDK_TO_CFN_DYNAMODB_TARGET_KEYS);
+}
+
/**
* SDK Provider for `AWS::Glue::Connection`.
*
diff --git a/tests/integration/glue-update-hardening/lib/glue-update-hardening-stack.ts b/tests/integration/glue-update-hardening/lib/glue-update-hardening-stack.ts
index 508b5e9f0..668b43966 100644
--- a/tests/integration/glue-update-hardening/lib/glue-update-hardening-stack.ts
+++ b/tests/integration/glue-update-hardening/lib/glue-update-hardening-stack.ts
@@ -3,6 +3,7 @@ import { Construct } from 'constructs';
import * as s3 from 'aws-cdk-lib/aws-s3';
import * as iam from 'aws-cdk-lib/aws-iam';
import * as glue from 'aws-cdk-lib/aws-glue';
+import * as dynamodb from 'aws-cdk-lib/aws-dynamodb';
/**
* Glue update / delete hardening integ stack.
@@ -18,6 +19,13 @@ import * as glue from 'aws-cdk-lib/aws-glue';
* CDKD_TEST_UPDATE flips its description to exercise the update path.
* 4. Glue Workflow Tags from a MAP shape (CfnWorkflow `tags` is a `{k:v}` map)
* must reach AWS, not be silently dropped.
+ * 5. Glue Crawler `Targets.DynamoDBTargets[].ScanAll` / `.ScanRate` (issue
+ * #1391). The SDK `DynamoDBTarget` is a lowercase island — `Path` is
+ * PascalCase but the scan tuning is `scanAll` / `scanRate` — while CFn
+ * spells them `ScanAll` / `ScanRate`. The SDK v3 serializer drops unknown
+ * members, so the tuning silently never reached AWS while the target
+ * itself (matched by `Path`) survived. CDKD_TEST_UPDATE flips both values
+ * so the update path is covered too.
*
* All resources are idle (no schedule, ON_DEMAND trigger), so deploy + destroy
* is fast and clean — no quota, no running jobs.
@@ -67,15 +75,52 @@ export class GlueUpdateHardeningStack extends cdk.Stack {
},
});
- // Glue Crawler — idle (no schedule). Targets a path under the script bucket.
- new glue.CfnCrawler(this, 'EventsCrawler', {
+ // DynamoDB table referenced by the Crawler's `dynamoDbTargets` entry below.
+ // Never crawled (the crawler is idle) — it only has to exist so the target
+ // is a real table. `tableName` is a Ref, which gives the DAG the
+ // Crawler -> Table edge for free.
+ const crawlerTable = new dynamodb.Table(this, 'CrawlerTable', {
+ partitionKey: { name: 'pk', type: dynamodb.AttributeType.STRING },
+ billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
+ removalPolicy: cdk.RemovalPolicy.DESTROY,
+ });
+ // CreateCrawler VALIDATES a DynamoDB target eagerly — it calls
+ // dynamodb:DescribeTable as the crawler role — so the grant is required at
+ // create time even though this crawler never runs. Without it the create
+ // fails with AccessDeniedException on DescribeTable.
+ crawlerTable.grantReadData(glueRole);
+
+ // Glue Crawler — idle (no schedule). Targets a path under the script bucket
+ // plus the DynamoDB table above.
+ //
+ // The scan tuning (issue #1391) deliberately uses NON-DEFAULT values so the
+ // readback cannot be satisfied by an AWS-side default: `ScanAll` defaults to
+ // `true` when unset, and `ScanRate` is stored as null when unset (the 0.5 /
+ // 0.25 fallbacks are runtime behavior, not a persisted value). Base deploy
+ // sends `false` / 0.9; CDKD_TEST_UPDATE sends `true` / 1.2 so the update
+ // path is exercised with a second distinct pair.
+ const crawler = new glue.CfnCrawler(this, 'EventsCrawler', {
name: `${this.stackName}-crawler`.toLowerCase(),
role: glueRole.roleArn,
databaseName: `${this.stackName}-crawler-db`.toLowerCase(),
targets: {
s3Targets: [{ path: `s3://${scriptBucket.bucketName}/data/` }],
+ dynamoDbTargets: [
+ {
+ path: crawlerTable.tableName,
+ scanAll: isUpdate,
+ scanRate: isUpdate ? 1.2 : 0.9,
+ },
+ ],
},
});
+ // `grantReadData` above mutates GlueRoleDefaultPolicy, and nothing gives
+ // the crawler a DAG edge to that policy (cdkd only adds implicit
+ // role -> policy edges for Custom Resources and Lambda VpcConfig). Since
+ // CreateCrawler eagerly calls dynamodb:DescribeTable AS the role, the
+ // create otherwise races the policy attach and AWS reports it as
+ // "Service is unable to assume the role ... to access null".
+ crawler.node.addDependency(glueRole);
// Glue Workflow — `tags` is a MAP shape (the shape that exposed the
// silent-drop bug). MaxConcurrentRuns set as a NUMBER (synths as a string).
@@ -100,6 +145,7 @@ export class GlueUpdateHardeningStack extends cdk.Stack {
new cdk.CfnOutput(this, 'JobName', { value: job.name! });
new cdk.CfnOutput(this, 'WorkflowName', { value: `${this.stackName}-workflow`.toLowerCase() });
new cdk.CfnOutput(this, 'CrawlerName', { value: `${this.stackName}-crawler`.toLowerCase() });
+ new cdk.CfnOutput(this, 'CrawlerTableName', { value: crawlerTable.tableName });
new cdk.CfnOutput(this, 'TriggerName', { value: `${this.stackName}-trigger`.toLowerCase() });
}
}
diff --git a/tests/integration/glue-update-hardening/verify.sh b/tests/integration/glue-update-hardening/verify.sh
index 447b38124..e91290cc6 100755
--- a/tests/integration/glue-update-hardening/verify.sh
+++ b/tests/integration/glue-update-hardening/verify.sh
@@ -12,6 +12,12 @@
# is exercised here).
# 4. Workflow Tags from a MAP shape reaching AWS — asserted via
# `aws glue get-tags` on the workflow ARN.
+# 5. Crawler `Targets.DynamoDBTargets[].ScanAll` / `.ScanRate` reaching AWS
+# (issue #1391). CFn spells them PascalCase; the SDK `DynamoDBTarget` is a
+# lowercase island (`scanAll` / `scanRate`), and the SDK v3 serializer
+# drops unknown members, so the scan tuning silently never reached AWS
+# while the target itself (matched by `Path`) survived. Asserted via
+# `aws glue get-crawler` on BOTH the base deploy and the UPDATE re-deploy.
#
# Required env vars:
# STATE_BUCKET — cdkd state bucket (e.g. cdkd-state-{accountId})
@@ -132,8 +138,15 @@ CRAWLER_NAME=$(echo "${STATE}" | jq -r '.outputs.CrawlerName // empty')
[ -z "${CRAWLER_NAME}" ] && CRAWLER_NAME="${CRAWLER_NAME_FALLBACK}"
TRIGGER_NAME=$(echo "${STATE}" | jq -r '.outputs.TriggerName // empty')
[ -z "${TRIGGER_NAME}" ] && TRIGGER_NAME="${TRIGGER_NAME_FALLBACK}"
+# The crawler's DynamoDB target table is CDK-autonamed, so there is no
+# deterministic fallback — the output is the only source.
+CRAWLER_TABLE_NAME=$(echo "${STATE}" | jq -r '.outputs.CrawlerTableName // empty')
+if [ -z "${CRAWLER_TABLE_NAME}" ]; then
+ echo "FAIL: state has no CrawlerTableName output after deploy" >&2
+ exit 1
+fi
-echo " Using job '${JOB_NAME}', workflow '${WORKFLOW_NAME}', crawler '${CRAWLER_NAME}', trigger '${TRIGGER_NAME}'"
+echo " Using job '${JOB_NAME}', workflow '${WORKFLOW_NAME}', crawler '${CRAWLER_NAME}', trigger '${TRIGGER_NAME}', crawler table '${CRAWLER_TABLE_NAME}'"
# --- Assertion 1: Job numeric props reached AWS as NUMBERS ------------
# The provider's numeric-coercion fix sends real numbers to the Glue SDK.
@@ -181,13 +194,65 @@ if [ "${ENV_TAG}" != "integ" ] || [ "${TEAM_TAG}" != "data-platform" ]; then
fi
echo " OK: Workflow MAP-shape tags reached AWS (env=integ, team=data-platform)"
-# --- Sanity: crawler + trigger exist ----------------------------------
-if aws glue get-crawler --name "${CRAWLER_NAME}" --region "${REGION}" >/dev/null 2>&1; then
- echo " OK: crawler ${CRAWLER_NAME} exists"
-else
- echo "FAIL: crawler ${CRAWLER_NAME} missing" >&2
+# --- Assertion 3: Crawler DynamoDB scan tuning reached AWS (#1391) -----
+# The SDK `DynamoDBTarget` spells the scan tuning `scanAll` / `scanRate`
+# (lowercase) while CFn spells it `ScanAll` / `ScanRate`; the SDK v3 serializer
+# DROPS unknown members, so without the rename the target still reaches AWS
+# (matched by `Path`) but the tuning is silently lost. The fixture sends
+# NON-DEFAULT values (`ScanAll: false`, `ScanRate: 0.9`) precisely so an
+# AWS-side default cannot satisfy this assertion: `ScanAll` defaults to true
+# when unset, and `ScanRate` is stored as null when unset.
+assert_ddb_scan_tuning() { # usage: assert_ddb_scan_tuning
+ local json="$1" want_all="$2" want_rate="$3" phase="$4"
+ local target got_all got_rate rate_ok
+ target=$(printf '%s' "${json}" | jq -c '.Crawler.Targets.DynamoDBTargets[0]')
+ if [ -z "${target}" ] || [ "${target}" = "null" ]; then
+ echo "FAIL: ${phase}: Crawler has no DynamoDBTargets entry at all" >&2
+ printf '%s\n' "${json}" >&2
+ exit 1
+ fi
+ # `has()` rather than jq's `//` alternative operator: `//` treats a
+ # legitimate `false` as absent, which is exactly the base-phase ScanAll
+ # value. The PascalCase branch is a diagnostic fallback — if AWS ever starts
+ # echoing the CFn spelling we want a value mismatch, not a bogus "absent".
+ got_all=$(printf '%s' "${target}" | jq -r 'if has("scanAll") then (.scanAll|tostring) elif has("ScanAll") then (.ScanAll|tostring) else "" end')
+ got_rate=$(printf '%s' "${target}" | jq -r 'if has("scanRate") then (.scanRate|tostring) elif has("ScanRate") then (.ScanRate|tostring) else "" end')
+ if [ "${got_all}" != "${want_all}" ]; then
+ echo "FAIL: ${phase}: DynamoDBTargets[0] scanAll is '${got_all}', expected '${want_all}' — CFn ScanAll never reached AWS (issue #1391)" >&2
+ printf '%s\n' "${target}" >&2
+ exit 1
+ fi
+ if [ "${got_rate}" = "" ]; then
+ echo "FAIL: ${phase}: DynamoDBTargets[0] scanRate is absent, expected ${want_rate} — CFn ScanRate never reached AWS (issue #1391)" >&2
+ printf '%s\n' "${target}" >&2
+ exit 1
+ fi
+ # AWS echoes ScanRate as a JSON double, so compare numerically with a
+ # tolerance instead of string-matching a float rendering.
+ rate_ok=$(jq -rn --argjson got "${got_rate}" --argjson want "${want_rate}" \
+ 'if ($got > ($want - 0.0001) and $got < ($want + 0.0001)) then "yes" else "no" end')
+ if [ "${rate_ok}" != "yes" ]; then
+ echo "FAIL: ${phase}: DynamoDBTargets[0] scanRate is ${got_rate}, expected ${want_rate} (issue #1391)" >&2
+ printf '%s\n' "${target}" >&2
+ exit 1
+ fi
+ echo " OK: ${phase}: DynamoDBTargets[0] scanAll=${got_all} scanRate=${got_rate} reached AWS"
+}
+
+CRAWLER_JSON=$(aws glue get-crawler --name "${CRAWLER_NAME}" --region "${REGION}" --output json)
+echo " OK: crawler ${CRAWLER_NAME} exists"
+# `Path` is the member that survived even BEFORE the #1391 fix (it is
+# PascalCase in the SDK too), so asserting it separately keeps the two halves
+# of the bug distinguishable in a failure report: target present, tuning lost.
+DDB_TARGET_PATH=$(printf '%s' "${CRAWLER_JSON}" | jq -r '.Crawler.Targets.DynamoDBTargets[0].Path // ""')
+if [ "${DDB_TARGET_PATH}" != "${CRAWLER_TABLE_NAME}" ]; then
+ echo "FAIL: DynamoDBTargets[0].Path is '${DDB_TARGET_PATH}', expected '${CRAWLER_TABLE_NAME}'" >&2
exit 1
fi
+echo " OK: DynamoDBTargets[0].Path == ${CRAWLER_TABLE_NAME}"
+assert_ddb_scan_tuning "${CRAWLER_JSON}" 'false' '0.9' 'create'
+
+# --- Sanity: trigger exists -------------------------------------------
if aws glue get-trigger --name "${TRIGGER_NAME}" --region "${REGION}" >/dev/null 2>&1; then
echo " OK: trigger ${TRIGGER_NAME} exists"
else
@@ -202,7 +267,7 @@ fi
# would (a) skip the update test on a plain `bash verify.sh` run and (b) make
# Phase 1's base-shape deploy synth the updated values, so the env must be
# controlled per-phase, not globally.
-echo "==> Phase 2: re-deploy with CDKD_TEST_UPDATE=true (trigger desc + job timeout)"
+echo "==> Phase 2: re-deploy with CDKD_TEST_UPDATE=true (trigger desc + job timeout + crawler scan tuning)"
CDKD_TEST_UPDATE=true node "${LOCAL_DIST}" deploy "${STACK}" \
--state-bucket "${STATE_BUCKET}" \
--region "${REGION}" \
@@ -215,6 +280,15 @@ if [ "${NEW_TIMEOUT}" != "90" ]; then
fi
echo " OK: Job.Timeout updated to 90 (number)"
+# The UPDATE path has its own CFn -> SDK rename call site (UpdateCrawler), so
+# re-assert the scan tuning against the second, distinct pair.
+CRAWLER_JSON=$(aws glue get-crawler --name "${CRAWLER_NAME}" --region "${REGION}" --output json)
+# NOTE: scanRate carries the regression signal for THIS phase. `true` is AWS's
+# own default for scanAll, so that half of the assertion would also pass if the
+# update dropped the tuning entirely — only the 1.2 discriminates. (The create
+# phase asserts the drop-proof pair, false/0.9, and fails first anyway.)
+assert_ddb_scan_tuning "${CRAWLER_JSON}" 'true' '1.2' 'update'
+
# --- Phase 3: destroy -------------------------------------------------
echo "==> Phase 3: destroy"
node "${LOCAL_DIST}" destroy "${STACK}" \
@@ -238,8 +312,28 @@ for chk in \
done
echo " OK: all Glue resources are gone"
+# DeleteTable is async and the provider does not wait, so the table is normally
+# still DELETING moments after destroy returns. Accept GONE or DELETING; only a
+# live state (ACTIVE / UPDATING) means the delete never happened. Same shape as
+# dynamodb-gsi-update/verify.sh. (No sleep: DeleteTable transitions the table to
+# DELETING synchronously, so one check right after destroy is sufficient.)
+if gone_probe aws dynamodb describe-table --table-name "${CRAWLER_TABLE_NAME}" --region "${REGION}"; then
+ ddb_status="GONE"
+elif ! ddb_status="$(aws dynamodb describe-table --table-name "${CRAWLER_TABLE_NAME}" --region "${REGION}" \
+ --query 'Table.TableStatus' --output text 2>&1)"; then
+ # TOCTOU: the table can vanish between gone_probe and this requery.
+ printf '%s' "${ddb_status}" | grep -qiE 'not ?found|no ?such|does ?not ?exist|non ?existent|\(404' \
+ && ddb_status="GONE" \
+ || { echo "FAIL: describe-table requery undetermined: ${ddb_status}" >&2; exit 1; }
+fi
+if [ "${ddb_status}" != "GONE" ] && [ "${ddb_status}" != "DELETING" ]; then
+ echo "FAIL: DynamoDB crawler-target table ${CRAWLER_TABLE_NAME} still exists (status ${ddb_status}) after destroy" >&2
+ exit 1
+fi
+echo " OK: DynamoDB crawler-target table is gone (status: ${ddb_status})"
+
assert_gone "state file s3://${STATE_BUCKET}/${STATE_KEY} still exists after destroy" aws s3api head-object --bucket "${STATE_BUCKET}" --key "${STATE_KEY}"
echo " OK: state file is gone"
echo ""
-echo "==> glue-update-hardening test passed (numeric coercion + MAP tags + clean destroy)"
+echo "==> glue-update-hardening test passed (numeric coercion + MAP tags + DynamoDB scan tuning + clean destroy)"
diff --git a/tests/unit/deployment/retryable-errors.test.ts b/tests/unit/deployment/retryable-errors.test.ts
index b273279e8..4f40f7485 100644
--- a/tests/unit/deployment/retryable-errors.test.ts
+++ b/tests/unit/deployment/retryable-errors.test.ts
@@ -66,6 +66,20 @@ describe('isRetryableTransientError', () => {
'Service is unable to assume provided role. Please verify role\'s TrustPolicy.',
'Glue assume-role IAM propagation',
],
+ // Second AWS wording of the SAME Glue race, observed 2026-08-09 on the
+ // same fixture: `the role ` instead of `provided role`, which the
+ // anchor above does not match.
+ [
+ "Failed to create Glue Crawler EventsCrawler: com.amazonaws.services.glue.model.AccessDeniedException: You need to enable AWS Security Token Service for this region. Service is unable to assume the role arn:aws:iam::111122223333:role/Stack-GlueRole to access null. Please verify the role's TrustPolicy.",
+ 'Glue assume-role IAM propagation (the-role wording)',
+ ],
+ // Third wording: Glue assumed the fresh role and the resulting session's
+ // token is not valid yet. Anchored on the Java-SDK `(Service:` trailer so
+ // only a SERVICE-wrapped error matches.
+ [
+ 'Failed to create Glue Crawler EventsCrawler: The security token included in the request is invalid. (Service: AmazonDynamoDBv2; Status Code: 400; Error Code: UnrecognizedClientException; Request ID: abc; Proxy: null)',
+ 'Glue assumed-session token propagation',
+ ],
// Step Functions same-stack role IAM-propagation race: CreateStateMachine
// is issued before the just-created role's trust policy propagates to
// Step Functions' assume layer (surfaced by a bug-hunt sweep on an
@@ -195,6 +209,16 @@ describe('isRetryableTransientError', () => {
expect(isRetryableTransientError(new Error(message), message)).toBe(false);
});
+ it('does not retry on cdkd OWN expired credentials (no service-wrapped trailer)', () => {
+ // The load-bearing guard for the assumed-session-token pattern: the JS
+ // SDK reports the developer's own expired SSO session with the SAME
+ // sentence but NO Java-SDK `(Service: ...)` trailer. Retrying it would
+ // burn ~48s before surfacing a condition that will never resolve.
+ const message =
+ 'UnrecognizedClientException: The security token included in the request is invalid.';
+ expect(isRetryableTransientError(new Error(message), message)).toBe(false);
+ });
+
it('does not retry on a non-transient EventSourceMapping not-found error', () => {
// Guard against over-broadening: NotFound must NOT become retryable.
const message = 'Failed to delete event source mapping abc-123: ResourceNotFoundException';
@@ -514,6 +538,11 @@ describe('isIamPropagationError', () => {
],
['The role defined for the function cannot be assumed by Lambda.', 'Lambda exec role'],
['Service is unable to assume provided role. Please verify role TrustPolicy', 'Glue'],
+ ['Service is unable to assume the role arn:aws:iam::1:role/r to access null.', 'Glue the-role'],
+ [
+ 'The security token included in the request is invalid. (Service: AmazonDynamoDBv2; Error Code: UnrecognizedClientException)',
+ 'Glue assumed-session token',
+ ],
['User: arn:aws:iam::1:user/x is not authorized to perform: sts:AssumeRole', 'authz'],
['Invalid principal in policy', 'S3 bucket policy'],
['Invalid parameter: Policy Error: PrincipalNotFound', 'SNS topic policy'],
diff --git a/tests/unit/provisioning/glue-crawler-roundtrip.test.ts b/tests/unit/provisioning/glue-crawler-roundtrip.test.ts
index 56fe18a29..5fb9995cb 100644
--- a/tests/unit/provisioning/glue-crawler-roundtrip.test.ts
+++ b/tests/unit/provisioning/glue-crawler-roundtrip.test.ts
@@ -112,6 +112,61 @@ describe('GlueCrawlerProvider', () => {
expect(call![0].input).toMatchObject({ Schedule: 'cron(0 0 * * ? *)' });
});
+ it('create() lower-cases DynamoDBTargets ScanAll / ScanRate for the SDK (#1391)', async () => {
+ // The SDK's DynamoDBTarget is a lowercase island: `Path` is PascalCase but
+ // the scan-tuning members are `scanAll` / `scanRate`. Forwarding the CFn
+ // spelling silently dropped both (the target itself survived via `Path`).
+ await provider.create('L', 'AWS::Glue::Crawler', {
+ Name: 'my-crawler',
+ Role: 'arn:aws:iam::123456789012:role/GlueCrawlerRole',
+ Targets: {
+ DynamoDBTargets: [
+ { Path: 'my-table', ScanAll: true, ScanRate: 0.5 },
+ { Path: 'other-table' },
+ ],
+ // Sibling sub-types spell every member exactly as CFn does — including
+ // MongoDBTarget's own PascalCase `ScanAll` — so they pass through.
+ S3Targets: [{ Path: 's3://my-bucket/data' }],
+ MongoDBTargets: [{ ConnectionName: 'mongo', Path: 'db/coll', ScanAll: true }],
+ },
+ });
+
+ const call = mockSend.mock.calls.find((c) => c[0] instanceof CreateCrawlerCommand);
+ expect(call![0].input.Targets).toEqual({
+ DynamoDBTargets: [{ Path: 'my-table', scanAll: true, scanRate: 0.5 }, { Path: 'other-table' }],
+ S3Targets: [{ Path: 's3://my-bucket/data' }],
+ MongoDBTargets: [{ ConnectionName: 'mongo', Path: 'db/coll', ScanAll: true }],
+ });
+ });
+
+ it('create() coerces a stringly-typed ScanRate to a number (#1391)', async () => {
+ // CFn is stringly typed, so a template can carry `ScanRate: "0.9"`. The SDK
+ // models it as a double and the serializer forwards a string verbatim, so
+ // the conversion — now the wire boundary for Targets — has to coerce.
+ await provider.create('L', 'AWS::Glue::Crawler', {
+ Name: 'my-crawler',
+ Role: 'arn:aws:iam::123456789012:role/GlueCrawlerRole',
+ Targets: {
+ DynamoDBTargets: [
+ { Path: 'my-table', ScanRate: '0.9', ScanAll: 'false' },
+ // Non-numeric input passes through so AWS surfaces the real
+ // validation error instead of cdkd mangling it.
+ { Path: 'other-table', ScanRate: 'not-a-number', ScanAll: 'true' },
+ ],
+ },
+ });
+
+ const call = mockSend.mock.calls.find((c) => c[0] instanceof CreateCrawlerCommand);
+ expect(call![0].input.Targets).toEqual({
+ DynamoDBTargets: [
+ // `ScanAll: 'false'` MUST become the boolean false — the raw string is
+ // truthy, so forwarding it would silently invert the setting.
+ { Path: 'my-table', scanRate: 0.9, scanAll: false },
+ { Path: 'other-table', scanRate: 'not-a-number', scanAll: true },
+ ],
+ });
+ });
+
it('create() fails when Role is missing', async () => {
await expect(
provider.create('L', 'AWS::Glue::Crawler', {
@@ -157,6 +212,23 @@ describe('GlueCrawlerProvider', () => {
});
});
+ it('update() lower-cases DynamoDBTargets ScanAll / ScanRate for the SDK (#1391)', async () => {
+ await provider.update(
+ 'L',
+ 'my-crawler',
+ 'AWS::Glue::Crawler',
+ {
+ Targets: { DynamoDBTargets: [{ Path: 'my-table', ScanAll: false, ScanRate: 1.5 }] },
+ },
+ {}
+ );
+
+ const call = mockSend.mock.calls.find((c) => c[0] instanceof UpdateCrawlerCommand);
+ expect(call![0].input.Targets).toEqual({
+ DynamoDBTargets: [{ Path: 'my-table', scanAll: false, scanRate: 1.5 }],
+ });
+ });
+
it('update() reconciles Tag diff via TagResource + UntagResource when tags change', async () => {
await provider.update(
'L',
@@ -302,6 +374,36 @@ describe('GlueCrawlerProvider', () => {
});
});
+ it('readCurrentState() reverse-maps SDK DynamoDBTargets scanAll / scanRate to the CFn spelling (#1391)', async () => {
+ mockSend.mockImplementation((cmd) => {
+ if (cmd instanceof GetCrawlerCommand) {
+ return Promise.resolve({
+ Crawler: {
+ Name: 'my-crawler',
+ Targets: {
+ DynamoDBTargets: [{ Path: 'my-table', scanAll: true, scanRate: 0.5 }],
+ S3Targets: [{ Path: 's3://my-bucket/data' }],
+ },
+ },
+ });
+ }
+ if (cmd instanceof GetTagsCommand) {
+ return Promise.resolve({ Tags: {} });
+ }
+ return Promise.resolve({});
+ });
+
+ const result = await provider.readCurrentState('my-crawler', 'L', 'AWS::Glue::Crawler');
+ // Without the reverse map the state-recorded PascalCase keys would read as
+ // removed and the SDK's lowercase keys as added — phantom drift on every run.
+ expect(result).toMatchObject({
+ Targets: {
+ DynamoDBTargets: [{ Path: 'my-table', ScanAll: true, ScanRate: 0.5 }],
+ S3Targets: [{ Path: 's3://my-bucket/data' }],
+ },
+ });
+ });
+
it('readCurrentState() returns undefined when crawler does not exist', async () => {
const { EntityNotFoundException } = await import('@aws-sdk/client-glue');
mockSend.mockRejectedValueOnce(
diff --git a/tests/unit/provisioning/glue-provider-roundtrip.test.ts b/tests/unit/provisioning/glue-provider-roundtrip.test.ts
index 74ddf5b2b..ec98b8609 100644
--- a/tests/unit/provisioning/glue-provider-roundtrip.test.ts
+++ b/tests/unit/provisioning/glue-provider-roundtrip.test.ts
@@ -293,6 +293,66 @@ describe('GlueProvider read-update round-trip', () => {
expect(input.TableInput.Name).toBe('events_iceberg');
});
+ it('AWS::Glue::Table — create() renames IcebergInput.IcebergTableInput to the SDK CreateIcebergTableInput (#1390)', async () => {
+ // CFn spells the nested table spec `IcebergTableInput`; the SDK's
+ // `IcebergInput` member is `CreateIcebergTableInput`. The SDK serializer
+ // drops unknown members, so without the rename the whole Iceberg table
+ // spec vanished while CreateTable still reported success.
+ mockSend.mockResolvedValueOnce({});
+
+ const icebergTableInput = {
+ Location: 's3://b/iceberg/events/',
+ Schema: {
+ Fields: [{ Id: 1, Name: 'event_id', Type: 'string', Required: true }],
+ IdentifierFieldIds: [1],
+ },
+ PartitionSpec: {
+ Fields: [{ SourceId: 1, Transform: 'identity', Name: 'event_id' }],
+ },
+ Properties: { 'write.format.default': 'parquet' },
+ };
+
+ await provider.create('L', 'AWS::Glue::Table', {
+ DatabaseName: 'mydb',
+ OpenTableFormatInput: {
+ IcebergInput: {
+ MetadataOperation: 'CREATE',
+ Version: '2',
+ IcebergTableInput: icebergTableInput,
+ },
+ },
+ TableInput: { Name: 'events_iceberg', TableType: 'EXTERNAL_TABLE' },
+ });
+
+ const createCall = mockSend.mock.calls.find((c) => c[0] instanceof CreateTableCommand);
+ const input = createCall![0].input as { OpenTableFormatInput: Record };
+ expect(input.OpenTableFormatInput).toEqual({
+ IcebergInput: {
+ MetadataOperation: 'CREATE',
+ Version: '2',
+ // Renamed key; the nested members match CFn 1:1 and stay untouched.
+ CreateIcebergTableInput: icebergTableInput,
+ },
+ });
+ expect('IcebergTableInput' in (input.OpenTableFormatInput['IcebergInput'] as object)).toBe(
+ false
+ );
+ });
+
+ it('AWS::Glue::Table — create() leaves an IcebergInput without IcebergTableInput untouched', async () => {
+ mockSend.mockResolvedValueOnce({});
+
+ await provider.create('L', 'AWS::Glue::Table', {
+ DatabaseName: 'mydb',
+ OpenTableFormatInput: { IcebergInput: { MetadataOperation: 'CREATE' } },
+ TableInput: { Name: 'events_iceberg' },
+ });
+
+ const createCall = mockSend.mock.calls.find((c) => c[0] instanceof CreateTableCommand);
+ const input = createCall![0].input as { OpenTableFormatInput: Record };
+ expect(input.OpenTableFormatInput).toEqual({ IcebergInput: { MetadataOperation: 'CREATE' } });
+ });
+
it('AWS::Glue::Table — create() omits OpenTableFormatInput when absent (omit-when-absent)', async () => {
mockSend.mockResolvedValueOnce({});