Skip to content

Commit 48e9c6d

Browse files
committed
fix(emr): rename ConfigurationProperties / StepProperties to the SDK Properties member
CFn spells the EMR application-configuration bag `Configuration.ConfigurationProperties` and the step bag `HadoopJarStepConfig.StepProperties`, while both SDK members are named `Properties`. The EMR providers cast the CFn blobs straight to the SDK types, and the AWS SDK v3 serializer drops unknown members - so every EMR application configuration (spark-defaults / hive-site / yarn-site ...) silently vanished and the cluster came up unconfigured while cdkd reported the deploy as successful. Step properties were dropped the same way. The value shapes already match (Record<string,string> for the former, KeyValue[] for the latter - verified against the live CFn registry schema), so both directions are pure key renames. - new shared helper src/provisioning/emr-configuration.ts: toSdkConfigurations (recursive - Configurations nests into itself), toSdkStepConfigs, toSdkInstanceTypeConfigs - wired at every forwarding site: EMRClusterProvider top-level Configurations / Steps, per-instance-group Configurations, per-fleet InstanceTypeConfigs; EMRInstanceGroupConfigProvider create; EMRInstanceFleetConfigProvider create + ModifyInstanceFleet update - no inverse needed: Configurations / Steps / InstanceTypeConfigs are all in EMRClusterProvider.getDriftUnknownPaths and neither instance provider implements readCurrentState - unit tests for the helper plus per-provider wiring tests, the four provider-level ones verified to fail without the fix - emr-cluster fixture gains top-level + NESTED Configurations, a per-master-group Configurations block and a step with StepProperties; emr-instance-configs gains Configurations on the standalone TASK group. Both verify.sh files read the values back from AWS through a new SDK-based list_instance_groups_json helper (aws emr list-instance-groups is CLI-customized and unusable non-interactively) Closes #1383
1 parent 8f3a8dc commit 48e9c6d

13 files changed

Lines changed: 560 additions & 19 deletions

.claude/rules/code-layout.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ paths:
7777
- **src/provisioning/register-providers.ts** - Shared provider registration (called from deploy.ts and destroy.ts)
7878
- **src/provisioning/data-delete-intent.ts** - Shared destroy data-guard intent helpers (issue #1340): `hasCdkAutoDeleteTag(properties, tagKey)` / `isTruthyCfnBoolean(value)` plus the CDK tag-key constants `S3_AUTO_DELETE_OBJECTS_TAG` (`aws-cdk:auto-delete-objects`, stamped by `autoDeleteObjects: true`) and `ECR_AUTO_DELETE_IMAGES_TAG` (`aws-cdk:auto-delete-images`). Consumed by `S3BucketProvider.delete` (auto-empty of a non-empty bucket only with the tag / `DeleteContext.forceDataDelete`), `S3DirectoryBucketProvider.delete` (issue #1344 — same gate; no CDK opt-in sugar exists for directory buckets, so plain destroy of a non-empty one fails with a manual-empty remediation), and `ECRProvider.delete` (`force: true` only with `EmptyOnDelete: true`, the tag, or `forceDataDelete`) — without an opt-in the AWS not-empty error surfaces like CloudFormation DELETE_FAILED. `DeleteContext.forceDataDelete` (src/provisioning/region-check.ts) is set ONLY by the deploy engine's replacement/recreate delete sites under `--force-stateful-recreation`. See the "Destroy data guards" section in docs/cli-reference.md and the DeleteContext contract note in .claude/rules/providers.md.
7979
- **src/provisioning/final-snapshot.ts** - `DeletionPolicy` / `UpdateReplacePolicy: Snapshot` support (issues #1352 / #1353 / #1354): `ATOMIC_FINAL_SNAPSHOT_TYPES` (RDS DBInstance / DBCluster, Neptune / DocDB clusters, ElastiCache CacheCluster — the delete call sites generate `buildFinalSnapshotIdentifier(physicalId, resourceType)` and thread it via `DeleteContext.finalSnapshotIdentifier`; each provider flips its delete from `SkipFinalSnapshot: true` to the API's atomic final-snapshot form; ONLY on the SDK route — a cc-api-routed atomic type is refused and `CloudControlProvider.delete` fail-closes on the field), `PRE_DELETE_SNAPSHOT_TYPES` + `createPreDeleteFinalSnapshot` dispatcher (all CC-routed: `AWS::EC2::Volume` via EC2 `CreateSnapshot` tagged `cdkd:final-snapshot-of`; `AWS::Redshift::Cluster` via `CreateClusterSnapshot`; `AWS::ElastiCache::ReplicationGroup` via ElastiCache `CreateSnapshot` — each waited to ready, idempotent reuse via the tag / the `finalSnapshotNamePrefix` name prefix across delete re-runs), `unsupportedFinalSnapshotError` / `ccRoutedFinalSnapshotError` refusals, and (issue #1366) `finalSnapshotMechanism(type, route)` / `refusesFinalSnapshot(type, route)` — the mechanism matrix as a PURE function, so the executor that ACTS on it and the `cdkd rollback` plan preview that DESCRIBES it read one source (issue #1368 extends that to the preview's STATE effect: a refused Snapshot delete no longer unwinds the record, since the next-older segment is classified against it). The two type sets are DISJOINT by construction — `finalSnapshotMechanism` tests the atomic set first, so a type in both would silently take the atomic arm and never reach the pre-delete snapshot; pinned in `final-snapshot.test.ts` alongside the union-equals-the-CFn-documented-list fence (re-homed there from the deleted `supportsFinalSnapshot` predicate, #1368). Consumed by the deploy engine (`prepareFinalSnapshotForDelete` — the shared gate for the DELETE branch AND the four replacement / recreate delete sites), `destroy-runner.ts`, and `rollback-executor.ts` — the latter twice: `rollbackFinalSnapshotId` for the delete-of-the-NEW-resource under `UpdateReplacePolicy` (honors only the atomic SDK-routed shape, plain-deletes otherwise — scope decision on #1354), and `prepareCreateRollbackFinalSnapshot` for a rolled-back CREATE under `DeletionPolicy` (the FULL matrix, refusing what it cannot snapshot — issue #1358). The engine's clients come from `DeployEngineOptions.finalSnapshotClients` (stack-region-pinned `AwsClients`, structurally a `PreDeleteSnapshotClients`), threaded on to `RollbackExecutorContext.finalSnapshotClients`; `--skip-final-snapshot` (deploy / destroy / state destroy / rollback, `skipFinalSnapshotOption` in `src/cli/options.ts` — deliberately NOT in the shared `destroyOptions` array `cdkd orphan` consumes) is the explicit data-loss opt-out.
80+
- **src/provisioning/emr-configuration.ts** - Shared CFn -> SDK shape converters for the `AWS::EMR::*` nested config blobs whose CFn key spelling diverges from `@aws-sdk/client-emr` (issue #1383): `toSdkConfigurations` (renames `Configuration.ConfigurationProperties` -> the SDK's `Properties` at EVERY `Configurations` nesting level), `toSdkStepConfigs` (`HadoopJarStepConfig.StepProperties` -> `Properties`), and `toSdkInstanceTypeConfigs` (per-instance-type nested `Configurations`). Both are pure key renames — the VALUE shapes already match (`Record<string,string>` / `KeyValue[]`, verified against the live CFn registry schema) — but the AWS SDK v3 serializer drops unknown members, so before the conversion every EMR application configuration (spark-defaults / hive-site / yarn-site ...) silently vanished while cdkd reported success. Consumed by `EMRClusterProvider` (top-level `Configurations` / `Steps`, per-group `Configurations`, per-fleet `InstanceTypeConfigs`), `EMRInstanceGroupConfigProvider` (create), and `EMRInstanceFleetConfigProvider` (create + the `ModifyInstanceFleet` update). No inverse is needed: `Configurations` / `Steps` / `InstanceTypeConfigs` are all declared in `EMRClusterProvider.getDriftUnknownPaths` and neither instance provider implements `readCurrentState`. Non-object / non-array inputs (an unresolved intrinsic) pass through untouched so AWS surfaces the real validation error. The `AWS::EMR::*` types are NOT yet in `NESTED_KEY_TARGETS` (`scripts/gen-nested-key-coverage.ts`) — critic target expansion is tracked in issue #1393.
8081
- **src/provisioning/ec2-termination-protection.ts** - Shared `--remove-protection` helper for `AWS::EC2::Instance`: `disableInstanceApiTermination()` (flip `DisableApiTermination` off, idempotent, errors swallowed at debug), `isTerminationProtectionPropagationError()` (matches the "may not be terminated. Modify its disableApiTermination" 400 from both `TerminateInstances` and the Cloud Control `DeleteResource` wrapper), and `TERMINATION_PROTECTION_MAX_ATTEMPTS`. Used by `EC2Provider.deleteInstance` (SDK path) and `CloudControlProvider.delete` (CC-API path — an instance routes through Cloud Control whenever its template trips the #614 silent-drop routing) so `--remove-protection` works regardless of which delete path the instance takes; the modify WRITE lags the delete READ, so both callers flip-off + retry the delete to close the propagation window. ALSO used by `ASGProvider.delete` (issue #796): an `AWS::AutoScaling::AutoScalingGroup` whose launch template sets `DisableApiTermination: true` launches instances that survive the group's `DeleteAutoScalingGroup(ForceDelete: true)` (ASG-level DeletionProtection + ForceDelete governs only the group + scale-in protection, not EC2-level termination protection), so under `--remove-protection` the provider enumerates the group's current instances and flips each one's `DisableApiTermination` off before the force delete — the ASG's own async terminate loop then absorbs the modify-WRITE propagation lag, so no per-instance delete retry is needed there. An ASG can ALSO route via Cloud Control when its template sets a silent-drop property such as `AvailabilityZoneIds` (#614 routing) — Cloud Control's `DeleteResource` cannot `ForceDelete` a protected ASG or clear its protection, so `CloudControlProvider.delete` detects `removeProtection === true && resourceType === 'AWS::AutoScaling::AutoScalingGroup'` and delegates to `new ASGProvider().delete(...)` (the single source of truth for protected-ASG deletion), keeping the SDK and CC routing paths behaviourally identical (issue #798; CDK's L2 emits `availabilityZones` names not `AvailabilityZoneIds`, so this CC path only fires for hand-written L1 / imported templates).
8182
- **src/provisioning/unsupported-types.ts** + **unsupported-types.generated.ts** - Pre-flight unsupported-type rejection. The `.generated.ts` ships the provider-coverage Tier 3 set (`ProvisioningType: NON_PROVISIONABLE`) into the runtime, codegen'd from `docs/_generated/provider-coverage.json` by `scripts/gen-unsupported-types.ts` (`vp run gen:unsupported-types`; CI fails on drift). The hand-written `.ts` adds `isNonProvisionable()` + `unsupportedTypeIssueUrl()`; both are consulted by `CloudControlProvider.isSupportedResourceType` (rejects Tier 3) and `ProviderRegistry.validateResourceTypes` (per-type error + issue link). The `--allow-unsupported-types` escape hatch routes named types through Cloud Control via `ProviderRegistry.allowUnsupportedTypes()`.
8283
- **src/provisioning/property-coverage.ts** + **property-coverage.generated.ts** - Pre-flight property-level rejection (parallel to unsupported-types but at top-level CFn property granularity). The `.generated.ts` ships per-Tier-1-type `{ handled, silentDrop }` records, codegen'd from `tests/fixtures/cfn-schemas/*.json` + each SDK provider's `handledProperties` / `unhandledByDesign` declarations by `scripts/gen-property-coverage.ts` (`vp run gen:property-coverage`; CI fails on drift; the codegen parses provider sources via the TypeScript Compiler API so no `dist/` bootstrap is needed). The hand-written `.ts` adds `getPropertyCoverage()` + `findSilentDropProperties()` + `unsupportedPropertyIssueUrl()`; all are consulted by `ProviderRegistry.validateResourceProperties` (per-resource per-property error + 1-click GitHub issue link + dedup'd re-run command). The `--allow-unsupported-properties` escape hatch (deploy only) routes named `<Type>:<Prop>` entries past the reject via `ProviderRegistry.allowUnsupportedProperties()`. Tier 2 (Cloud Control) types are intentionally NOT in the generated map — CC forwards the full property map to AWS, so no write-side silent drop is possible.
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import type { Configuration, InstanceTypeConfig, StepConfig } from '@aws-sdk/client-emr';
2+
3+
/**
4+
* CFn -> SDK shape converters for the two `AWS::EMR::*` nested blobs whose CFn
5+
* key spelling diverges from the `@aws-sdk/client-emr` model (issue #1383).
6+
*
7+
* | CFn key | SDK member |
8+
* | ------------------------------------------------ | ------------------------------ |
9+
* | `Configuration.ConfigurationProperties` | `Configuration.Properties` |
10+
* | `HadoopJarStepConfig.StepProperties` | `HadoopJarStepConfig.Properties` |
11+
*
12+
* Both are plain renames — the VALUE shapes already match (`Record<string,string>`
13+
* for the former, `KeyValue[]` for the latter, verified against the live CFn
14+
* registry schema on 2026-08-09). The AWS SDK v3 serializer drops unknown
15+
* members, so before this conversion every EMR application configuration
16+
* (spark-defaults / hive-site / yarn-site ...) silently vanished while cdkd
17+
* reported the deploy as successful.
18+
*
19+
* `Configurations` nests into itself, so the rename is applied at EVERY level.
20+
* Non-object / non-array inputs (an unresolved intrinsic, a malformed template)
21+
* pass through untouched so this layer never turns a bad template into a
22+
* confusing crash — AWS surfaces the real validation error instead.
23+
*/
24+
25+
function isRecord(value: unknown): value is Record<string, unknown> {
26+
return typeof value === 'object' && value !== null && !Array.isArray(value);
27+
}
28+
29+
function toSdkConfiguration(raw: unknown): Configuration {
30+
if (!isRecord(raw)) return raw as Configuration;
31+
32+
const { ConfigurationProperties, Configurations, ...rest } = raw;
33+
return {
34+
...rest,
35+
...(ConfigurationProperties !== undefined ? { Properties: ConfigurationProperties } : {}),
36+
...(Configurations !== undefined
37+
? { Configurations: toSdkConfigurations(Configurations) }
38+
: {}),
39+
} as Configuration;
40+
}
41+
42+
/**
43+
* CFn `Configurations` list -> SDK `Configuration[]`, renaming
44+
* `ConfigurationProperties` -> `Properties` at every nesting level.
45+
*/
46+
export function toSdkConfigurations(value: unknown): Configuration[] | undefined {
47+
if (value === undefined) return undefined;
48+
if (!Array.isArray(value)) return value as Configuration[];
49+
return value.map(toSdkConfiguration);
50+
}
51+
52+
/**
53+
* CFn `Steps` list -> SDK `StepConfig[]`, renaming
54+
* `HadoopJarStep.StepProperties` -> `HadoopJarStep.Properties`.
55+
*/
56+
export function toSdkStepConfigs(value: unknown): StepConfig[] | undefined {
57+
if (value === undefined) return undefined;
58+
if (!Array.isArray(value)) return value as StepConfig[];
59+
60+
return value.map((step) => {
61+
if (!isRecord(step)) return step as StepConfig;
62+
const hadoopJarStep = step['HadoopJarStep'];
63+
if (!isRecord(hadoopJarStep) || hadoopJarStep['StepProperties'] === undefined) {
64+
return step as unknown as StepConfig;
65+
}
66+
const { StepProperties, ...restHadoopJarStep } = hadoopJarStep;
67+
return {
68+
...step,
69+
HadoopJarStep: { ...restHadoopJarStep, Properties: StepProperties },
70+
} as unknown as StepConfig;
71+
});
72+
}
73+
74+
/**
75+
* CFn `InstanceTypeConfigs` list -> SDK `InstanceTypeConfig[]`, converting each
76+
* element's nested per-instance-type `Configurations`.
77+
*/
78+
export function toSdkInstanceTypeConfigs(value: unknown): InstanceTypeConfig[] | undefined {
79+
if (value === undefined) return undefined;
80+
if (!Array.isArray(value)) return value as InstanceTypeConfig[];
81+
82+
return value.map((instanceTypeConfig) => {
83+
if (!isRecord(instanceTypeConfig) || instanceTypeConfig['Configurations'] === undefined) {
84+
return instanceTypeConfig as InstanceTypeConfig;
85+
}
86+
return {
87+
...instanceTypeConfig,
88+
Configurations: toSdkConfigurations(instanceTypeConfig['Configurations']),
89+
} as InstanceTypeConfig;
90+
});
91+
}

src/provisioning/providers/emr-cluster-provider.ts

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,11 @@ import { getLogger } from '../../utils/logger.js';
3232
import { ProvisioningError, ResourceUpdateNotSupportedError } from '../../utils/error-handler.js';
3333
import { assertRegionMatch, type DeleteContext } from '../region-check.js';
3434
import { normalizeAwsTagsToCfn, resolveExplicitPhysicalId } from '../import-helpers.js';
35+
import {
36+
toSdkConfigurations,
37+
toSdkInstanceTypeConfigs,
38+
toSdkStepConfigs,
39+
} from '../emr-configuration.js';
3540
import type {
3641
ResourceProvider,
3742
ResourceCreateResult,
@@ -258,13 +263,11 @@ export class EMRClusterProvider implements ResourceProvider {
258263
Applications: properties['Applications'] as
259264
| import('@aws-sdk/client-emr').Application[]
260265
| undefined,
261-
Configurations: properties['Configurations'] as
262-
| import('@aws-sdk/client-emr').Configuration[]
263-
| undefined,
266+
Configurations: toSdkConfigurations(properties['Configurations']),
264267
BootstrapActions: properties['BootstrapActions'] as
265268
| import('@aws-sdk/client-emr').BootstrapActionConfig[]
266269
| undefined,
267-
Steps: properties['Steps'] as import('@aws-sdk/client-emr').StepConfig[] | undefined,
270+
Steps: toSdkStepConfigs(properties['Steps']),
268271
KerberosAttributes: properties['KerberosAttributes'] as
269272
| import('@aws-sdk/client-emr').KerberosAttributes
270273
| undefined,
@@ -413,9 +416,7 @@ export class EMRClusterProvider implements ResourceProvider {
413416
Name: raw['Name'] as string | undefined,
414417
Market: raw['Market'] as import('@aws-sdk/client-emr').MarketType | undefined,
415418
BidPrice: raw['BidPrice'] as string | undefined,
416-
Configurations: raw['Configurations'] as
417-
| import('@aws-sdk/client-emr').Configuration[]
418-
| undefined,
419+
Configurations: toSdkConfigurations(raw['Configurations']),
419420
EbsConfiguration: raw['EbsConfiguration'] as
420421
| import('@aws-sdk/client-emr').EbsConfiguration
421422
| undefined,
@@ -435,9 +436,7 @@ export class EMRClusterProvider implements ResourceProvider {
435436
Name: raw['Name'] as string | undefined,
436437
TargetOnDemandCapacity: toNumber(raw['TargetOnDemandCapacity']),
437438
TargetSpotCapacity: toNumber(raw['TargetSpotCapacity']),
438-
InstanceTypeConfigs: raw['InstanceTypeConfigs'] as
439-
| import('@aws-sdk/client-emr').InstanceTypeConfig[]
440-
| undefined,
439+
InstanceTypeConfigs: toSdkInstanceTypeConfigs(raw['InstanceTypeConfigs']),
441440
LaunchSpecifications: raw['LaunchSpecifications'] as
442441
| import('@aws-sdk/client-emr').InstanceFleetProvisioningSpecifications
443442
| undefined,

src/provisioning/providers/emr-instance-fleet-config-provider.ts

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
import { getLogger } from '../../utils/logger.js';
1414
import { ProvisioningError, ResourceUpdateNotSupportedError } from '../../utils/error-handler.js';
1515
import { assertRegionMatch, type DeleteContext } from '../region-check.js';
16+
import { toSdkInstanceTypeConfigs } from '../emr-configuration.js';
1617
import type {
1718
ResourceProvider,
1819
ResourceCreateResult,
@@ -218,9 +219,7 @@ export class EMRInstanceFleetConfigProvider implements ResourceProvider {
218219
Name: properties['Name'] as string | undefined,
219220
TargetOnDemandCapacity: toNumber(properties['TargetOnDemandCapacity']),
220221
TargetSpotCapacity: toNumber(properties['TargetSpotCapacity']),
221-
InstanceTypeConfigs: properties['InstanceTypeConfigs'] as
222-
| import('@aws-sdk/client-emr').InstanceTypeConfig[]
223-
| undefined,
222+
InstanceTypeConfigs: toSdkInstanceTypeConfigs(properties['InstanceTypeConfigs']),
224223
LaunchSpecifications: properties['LaunchSpecifications'] as
225224
| import('@aws-sdk/client-emr').InstanceFleetProvisioningSpecifications
226225
| undefined,
@@ -288,9 +287,7 @@ export class EMRInstanceFleetConfigProvider implements ResourceProvider {
288287
ResizeSpecifications: properties['ResizeSpecifications'] as
289288
| import('@aws-sdk/client-emr').InstanceFleetResizingSpecifications
290289
| undefined,
291-
InstanceTypeConfigs: properties['InstanceTypeConfigs'] as
292-
| import('@aws-sdk/client-emr').InstanceTypeConfig[]
293-
| undefined,
290+
InstanceTypeConfigs: toSdkInstanceTypeConfigs(properties['InstanceTypeConfigs']),
294291
};
295292
await this.getClient().send(
296293
new ModifyInstanceFleetCommand({ ClusterId: clusterId, InstanceFleet: modify })

src/provisioning/providers/emr-instance-group-config-provider.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
import { getLogger } from '../../utils/logger.js';
1515
import { ProvisioningError, ResourceUpdateNotSupportedError } from '../../utils/error-handler.js';
1616
import { assertRegionMatch, type DeleteContext } from '../region-check.js';
17+
import { toSdkConfigurations } from '../emr-configuration.js';
1718
import type {
1819
ResourceProvider,
1920
ResourceCreateResult,
@@ -220,9 +221,7 @@ export class EMRInstanceGroupConfigProvider implements ResourceProvider {
220221
Name: properties['Name'] as string | undefined,
221222
Market: properties['Market'] as import('@aws-sdk/client-emr').MarketType | undefined,
222223
BidPrice: properties['BidPrice'] as string | undefined,
223-
Configurations: properties['Configurations'] as
224-
| import('@aws-sdk/client-emr').Configuration[]
225-
| undefined,
224+
Configurations: toSdkConfigurations(properties['Configurations']),
226225
EbsConfiguration: properties['EbsConfiguration'] as
227226
| import('@aws-sdk/client-emr').EbsConfiguration
228227
| undefined,

0 commit comments

Comments
 (0)