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({});