Skip to content

Commit 88468e4

Browse files
committed
fix(glue): rename the Crawler DynamoDB scan members and the Iceberg table input to their SDK spellings
Both providers forwarded a CFn blob raw where the SDK model diverges, and the AWS SDK v3 serializer drops unknown members, so the values silently never reached AWS while the call reported success. Crawler (#1391): the SDK DynamoDBTarget is a lowercase island in an otherwise PascalCase model -- Path is PascalCase but the scan-tuning members are scanAll and scanRate, while CFn spells them ScanAll / ScanRate. The target itself survived (matched by Path); only the scan tuning was lost. Converted on create and update, with the inverse applied in readCurrentState so drift no longer reports a phantom ScanRate removal plus scanRate addition. The other seven CrawlerTargets sub-types were audited against the SDK model and match CFn key-for-key (MongoDBTarget.ScanAll IS PascalCase) -- recorded in a comment so the audit is not repeated. Table (#1390): CFn OpenTableFormatInput.IcebergInput.IcebergTableInput is the SDK's IcebergInput.CreateIcebergTableInput, so the entire Iceberg table spec (Location / Schema / PartitionSpec / WriteOrder / Properties) was discarded. The renamed object's own members match CFn 1:1, so a single key rename suffices. The stale "maps 1:1 to the SDK type (same PascalCase)" comment is corrected, as is the vague UpdateTable claim next to it: UpdateTableRequest has no OpenTableFormatInput member at all; it carries the different, update-only UpdateOpenTableFormatInput shape that CFn does not model. Closes #1391 Closes #1390
1 parent 98dc56b commit 88468e4

3 files changed

Lines changed: 255 additions & 9 deletions

File tree

src/provisioning/providers/glue-provider.ts

Lines changed: 121 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -370,13 +370,16 @@ export class GlueProvider implements ResourceProvider {
370370

371371
// `OpenTableFormatInput` (Apache Iceberg) is a top-level `CreateTableCommand`
372372
// param — a SIBLING of `TableInput`, NOT nested inside it. The CFn shape
373-
// (`{ IcebergInput: { MetadataOperation, Version } }`) maps 1:1 to the SDK
374-
// `OpenTableFormatInput` type (same PascalCase). Omit when absent.
373+
// matches the SDK `OpenTableFormatInput` type key-for-key EXCEPT for
374+
// `IcebergInput.IcebergTableInput`, which cdkd renames — see
375+
// {@link toSdkOpenTableFormatInput}. Omit when absent.
375376
// Iceberg's `MetadataOperation: 'CREATE'` is a create-time directive, so it
376-
// is intentionally wired on create only — `UpdateTableCommandInput` does not
377-
// accept `OpenTableFormatInput` (verified against @aws-sdk/client-glue).
377+
// is intentionally wired on create only — `UpdateTableCommandInput` has no
378+
// `OpenTableFormatInput` member at all (it carries the different,
379+
// update-only `UpdateOpenTableFormatInput` shape, which CFn does not model;
380+
// verified against @aws-sdk/client-glue `UpdateTableRequest`).
378381
const openTableFormatInput = properties['OpenTableFormatInput'] as
379-
| OpenTableFormatInput
382+
| Record<string, unknown>
380383
| undefined;
381384

382385
try {
@@ -386,7 +389,7 @@ export class GlueProvider implements ResourceProvider {
386389
DatabaseName: databaseName,
387390
TableInput: this.buildTableInput(tableInput, tableName),
388391
...(openTableFormatInput !== undefined && {
389-
OpenTableFormatInput: openTableFormatInput,
392+
OpenTableFormatInput: toSdkOpenTableFormatInput(openTableFormatInput),
390393
}),
391394
})
392395
);
@@ -1434,6 +1437,39 @@ function sleep(ms: number): Promise<void> {
14341437
return new Promise((resolve) => setTimeout(resolve, ms));
14351438
}
14361439

1440+
/**
1441+
* Convert the CFn `AWS::Glue::Table.OpenTableFormatInput` blob to the SDK
1442+
* `OpenTableFormatInput` shape.
1443+
*
1444+
* Every key matches the SDK model except one: CFn's
1445+
* `IcebergInput.IcebergTableInput` is the SDK's
1446+
* `IcebergInput.CreateIcebergTableInput` (@aws-sdk/client-glue `models_1.d.ts`
1447+
* `IcebergInput`). The AWS SDK v3 serializer drops unknown members, so leaving
1448+
* the CFn spelling in place silently discarded the ENTIRE Iceberg table spec
1449+
* (`Location` / `Schema` / `PartitionSpec` / `WriteOrder` / `Properties`) while
1450+
* `CreateTable` still reported success. The renamed object's own members match
1451+
* CFn 1:1, so this is a single key rename.
1452+
*
1453+
* Non-object inputs (an unresolved intrinsic) pass through untouched so AWS
1454+
* surfaces the real validation error.
1455+
*/
1456+
function toSdkOpenTableFormatInput(input: Record<string, unknown>): OpenTableFormatInput {
1457+
const iceberg = input['IcebergInput'];
1458+
if (
1459+
typeof iceberg !== 'object' ||
1460+
iceberg === null ||
1461+
Array.isArray(iceberg) ||
1462+
!('IcebergTableInput' in iceberg)
1463+
) {
1464+
return input as OpenTableFormatInput;
1465+
}
1466+
const { IcebergTableInput: icebergTableInput, ...rest } = iceberg as Record<string, unknown>;
1467+
return {
1468+
...input,
1469+
IcebergInput: { ...rest, CreateIcebergTableInput: icebergTableInput },
1470+
} as OpenTableFormatInput;
1471+
}
1472+
14371473
/**
14381474
* Build the SDK `EncryptionConfiguration` from the CFn-shape input
14391475
* (`AWS::Glue::SecurityConfiguration.EncryptionConfiguration`). Each
@@ -2127,7 +2163,7 @@ export class GlueCrawlerProvider implements ResourceProvider {
21272163
new CreateCrawlerCommand({
21282164
Name: name,
21292165
Role: role,
2130-
Targets: targets as CrawlerTargets,
2166+
Targets: toSdkCrawlerTargets(targets),
21312167
...buildCrawlerCommonFields(properties),
21322168
...(tags && { Tags: tags }),
21332169
})
@@ -2159,7 +2195,7 @@ export class GlueCrawlerProvider implements ResourceProvider {
21592195
Name: physicalId,
21602196
...(properties['Role'] !== undefined && { Role: properties['Role'] as string }),
21612197
...(properties['Targets'] !== undefined && {
2162-
Targets: properties['Targets'] as CrawlerTargets,
2198+
Targets: toSdkCrawlerTargets(properties['Targets'] as Record<string, unknown>),
21632199
}),
21642200
...buildCrawlerCommonFields(properties),
21652201
};
@@ -2303,7 +2339,11 @@ export class GlueCrawlerProvider implements ResourceProvider {
23032339
const result: Record<string, unknown> = {
23042340
Name: crawler.Name ?? physicalId,
23052341
Role: crawler.Role ?? '',
2306-
Targets: crawler.Targets ? pickDefined(crawler.Targets as Record<string, unknown>) : {},
2342+
// SDK `DynamoDBTarget.{scanAll,scanRate}` -> the CFn `ScanAll` / `ScanRate`
2343+
// spelling recorded in state, so drift compares like with like.
2344+
Targets: crawler.Targets
2345+
? toCfnCrawlerTargets(pickDefined(crawler.Targets as Record<string, unknown>))
2346+
: {},
23072347
DatabaseName: crawler.DatabaseName ?? '',
23082348
Description: crawler.Description ?? '',
23092349
// CFn `Schedule` is the structured wrapper; reverse-map from the
@@ -2441,6 +2481,78 @@ function buildCrawlerCommonFields(p: Record<string, unknown>): Record<string, un
24412481
return r;
24422482
}
24432483

2484+
/**
2485+
* CFn -> SDK key renames for `Targets.DynamoDBTargets[]`.
2486+
*
2487+
* The SDK's `DynamoDBTarget` is a lowercase island in an otherwise-PascalCase
2488+
* model: `Path` is PascalCase but the scan-tuning members are `scanAll` /
2489+
* `scanRate` (@aws-sdk/client-glue `models_0.d.ts` `DynamoDBTarget`), while CFn
2490+
* spells them `ScanAll` / `ScanRate`. The AWS SDK v3 serializer drops unknown
2491+
* members, so forwarding the CFn spelling silently loses the scan tuning while
2492+
* the target itself (matched by `Path`) still reaches AWS. Every other
2493+
* `CrawlerTargets` sub-type (`S3Target` / `JdbcTarget` / `MongoDBTarget` —
2494+
* whose own `ScanAll` IS PascalCase — `CatalogTarget` / `DeltaTarget` /
2495+
* `IcebergTarget` / `HudiTarget`) spells every member exactly as CFn does.
2496+
*/
2497+
const CFN_TO_SDK_DYNAMODB_TARGET_KEYS: Record<string, string> = {
2498+
ScanAll: 'scanAll',
2499+
ScanRate: 'scanRate',
2500+
};
2501+
2502+
const SDK_TO_CFN_DYNAMODB_TARGET_KEYS: Record<string, string> = {
2503+
scanAll: 'ScanAll',
2504+
scanRate: 'ScanRate',
2505+
};
2506+
2507+
/**
2508+
* Shallow-rename an object's keys per `renames`, leaving unlisted keys — and
2509+
* non-object values (an unresolved intrinsic) — untouched.
2510+
*/
2511+
function renameRecordKeys(entry: unknown, renames: Record<string, string>): unknown {
2512+
if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) return entry;
2513+
const out: Record<string, unknown> = {};
2514+
for (const [k, v] of Object.entries(entry as Record<string, unknown>)) {
2515+
out[renames[k] ?? k] = v;
2516+
}
2517+
return out;
2518+
}
2519+
2520+
/**
2521+
* Apply `renames` to every element of `targets[key]` when that entry is an
2522+
* array, returning the original object untouched otherwise so a non-array value
2523+
* reaches AWS verbatim and surfaces the real validation error.
2524+
*/
2525+
function renameCrawlerTargetList(
2526+
targets: Record<string, unknown>,
2527+
key: string,
2528+
renames: Record<string, string>
2529+
): Record<string, unknown> {
2530+
const list = targets[key];
2531+
if (!Array.isArray(list)) return targets;
2532+
return { ...targets, [key]: list.map((entry) => renameRecordKeys(entry, renames)) };
2533+
}
2534+
2535+
/**
2536+
* Convert the CFn `AWS::Glue::Crawler.Targets` blob to the SDK `CrawlerTargets`
2537+
* shape — see {@link CFN_TO_SDK_DYNAMODB_TARGET_KEYS} for the one divergence.
2538+
*/
2539+
function toSdkCrawlerTargets(targets: Record<string, unknown>): CrawlerTargets {
2540+
return renameCrawlerTargetList(
2541+
targets,
2542+
'DynamoDBTargets',
2543+
CFN_TO_SDK_DYNAMODB_TARGET_KEYS
2544+
) as CrawlerTargets;
2545+
}
2546+
2547+
/**
2548+
* Inverse of {@link toSdkCrawlerTargets}: re-shape a `GetCrawler` `Targets`
2549+
* blob back into the CFn spelling so `cdkd drift` compares like with like
2550+
* instead of reporting a phantom `ScanRate` removal + `scanRate` addition.
2551+
*/
2552+
function toCfnCrawlerTargets(targets: Record<string, unknown>): Record<string, unknown> {
2553+
return renameCrawlerTargetList(targets, 'DynamoDBTargets', SDK_TO_CFN_DYNAMODB_TARGET_KEYS);
2554+
}
2555+
24442556
/**
24452557
* SDK Provider for `AWS::Glue::Connection`.
24462558
*

tests/unit/provisioning/glue-crawler-roundtrip.test.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,33 @@ describe('GlueCrawlerProvider', () => {
112112
expect(call![0].input).toMatchObject({ Schedule: 'cron(0 0 * * ? *)' });
113113
});
114114

115+
it('create() lower-cases DynamoDBTargets ScanAll / ScanRate for the SDK (#1391)', async () => {
116+
// The SDK's DynamoDBTarget is a lowercase island: `Path` is PascalCase but
117+
// the scan-tuning members are `scanAll` / `scanRate`. Forwarding the CFn
118+
// spelling silently dropped both (the target itself survived via `Path`).
119+
await provider.create('L', 'AWS::Glue::Crawler', {
120+
Name: 'my-crawler',
121+
Role: 'arn:aws:iam::123456789012:role/GlueCrawlerRole',
122+
Targets: {
123+
DynamoDBTargets: [
124+
{ Path: 'my-table', ScanAll: true, ScanRate: 0.5 },
125+
{ Path: 'other-table' },
126+
],
127+
// Sibling sub-types spell every member exactly as CFn does — including
128+
// MongoDBTarget's own PascalCase `ScanAll` — so they pass through.
129+
S3Targets: [{ Path: 's3://my-bucket/data' }],
130+
MongoDBTargets: [{ ConnectionName: 'mongo', Path: 'db/coll', ScanAll: true }],
131+
},
132+
});
133+
134+
const call = mockSend.mock.calls.find((c) => c[0] instanceof CreateCrawlerCommand);
135+
expect(call![0].input.Targets).toEqual({
136+
DynamoDBTargets: [{ Path: 'my-table', scanAll: true, scanRate: 0.5 }, { Path: 'other-table' }],
137+
S3Targets: [{ Path: 's3://my-bucket/data' }],
138+
MongoDBTargets: [{ ConnectionName: 'mongo', Path: 'db/coll', ScanAll: true }],
139+
});
140+
});
141+
115142
it('create() fails when Role is missing', async () => {
116143
await expect(
117144
provider.create('L', 'AWS::Glue::Crawler', {
@@ -157,6 +184,23 @@ describe('GlueCrawlerProvider', () => {
157184
});
158185
});
159186

187+
it('update() lower-cases DynamoDBTargets ScanAll / ScanRate for the SDK (#1391)', async () => {
188+
await provider.update(
189+
'L',
190+
'my-crawler',
191+
'AWS::Glue::Crawler',
192+
{
193+
Targets: { DynamoDBTargets: [{ Path: 'my-table', ScanAll: false, ScanRate: 1.5 }] },
194+
},
195+
{}
196+
);
197+
198+
const call = mockSend.mock.calls.find((c) => c[0] instanceof UpdateCrawlerCommand);
199+
expect(call![0].input.Targets).toEqual({
200+
DynamoDBTargets: [{ Path: 'my-table', scanAll: false, scanRate: 1.5 }],
201+
});
202+
});
203+
160204
it('update() reconciles Tag diff via TagResource + UntagResource when tags change', async () => {
161205
await provider.update(
162206
'L',
@@ -302,6 +346,36 @@ describe('GlueCrawlerProvider', () => {
302346
});
303347
});
304348

349+
it('readCurrentState() reverse-maps SDK DynamoDBTargets scanAll / scanRate to the CFn spelling (#1391)', async () => {
350+
mockSend.mockImplementation((cmd) => {
351+
if (cmd instanceof GetCrawlerCommand) {
352+
return Promise.resolve({
353+
Crawler: {
354+
Name: 'my-crawler',
355+
Targets: {
356+
DynamoDBTargets: [{ Path: 'my-table', scanAll: true, scanRate: 0.5 }],
357+
S3Targets: [{ Path: 's3://my-bucket/data' }],
358+
},
359+
},
360+
});
361+
}
362+
if (cmd instanceof GetTagsCommand) {
363+
return Promise.resolve({ Tags: {} });
364+
}
365+
return Promise.resolve({});
366+
});
367+
368+
const result = await provider.readCurrentState('my-crawler', 'L', 'AWS::Glue::Crawler');
369+
// Without the reverse map the state-recorded PascalCase keys would read as
370+
// removed and the SDK's lowercase keys as added — phantom drift on every run.
371+
expect(result).toMatchObject({
372+
Targets: {
373+
DynamoDBTargets: [{ Path: 'my-table', ScanAll: true, ScanRate: 0.5 }],
374+
S3Targets: [{ Path: 's3://my-bucket/data' }],
375+
},
376+
});
377+
});
378+
305379
it('readCurrentState() returns undefined when crawler does not exist', async () => {
306380
const { EntityNotFoundException } = await import('@aws-sdk/client-glue');
307381
mockSend.mockRejectedValueOnce(

tests/unit/provisioning/glue-provider-roundtrip.test.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,66 @@ describe('GlueProvider read-update round-trip', () => {
293293
expect(input.TableInput.Name).toBe('events_iceberg');
294294
});
295295

296+
it('AWS::Glue::Table — create() renames IcebergInput.IcebergTableInput to the SDK CreateIcebergTableInput (#1390)', async () => {
297+
// CFn spells the nested table spec `IcebergTableInput`; the SDK's
298+
// `IcebergInput` member is `CreateIcebergTableInput`. The SDK serializer
299+
// drops unknown members, so without the rename the whole Iceberg table
300+
// spec vanished while CreateTable still reported success.
301+
mockSend.mockResolvedValueOnce({});
302+
303+
const icebergTableInput = {
304+
Location: 's3://b/iceberg/events/',
305+
Schema: {
306+
Fields: [{ Id: 1, Name: 'event_id', Type: 'string', Required: true }],
307+
IdentifierFieldIds: [1],
308+
},
309+
PartitionSpec: {
310+
Fields: [{ SourceId: 1, Transform: 'identity', Name: 'event_id' }],
311+
},
312+
Properties: { 'write.format.default': 'parquet' },
313+
};
314+
315+
await provider.create('L', 'AWS::Glue::Table', {
316+
DatabaseName: 'mydb',
317+
OpenTableFormatInput: {
318+
IcebergInput: {
319+
MetadataOperation: 'CREATE',
320+
Version: '2',
321+
IcebergTableInput: icebergTableInput,
322+
},
323+
},
324+
TableInput: { Name: 'events_iceberg', TableType: 'EXTERNAL_TABLE' },
325+
});
326+
327+
const createCall = mockSend.mock.calls.find((c) => c[0] instanceof CreateTableCommand);
328+
const input = createCall![0].input as { OpenTableFormatInput: Record<string, unknown> };
329+
expect(input.OpenTableFormatInput).toEqual({
330+
IcebergInput: {
331+
MetadataOperation: 'CREATE',
332+
Version: '2',
333+
// Renamed key; the nested members match CFn 1:1 and stay untouched.
334+
CreateIcebergTableInput: icebergTableInput,
335+
},
336+
});
337+
expect('IcebergTableInput' in (input.OpenTableFormatInput['IcebergInput'] as object)).toBe(
338+
false
339+
);
340+
});
341+
342+
it('AWS::Glue::Table — create() leaves an IcebergInput without IcebergTableInput untouched', async () => {
343+
mockSend.mockResolvedValueOnce({});
344+
345+
await provider.create('L', 'AWS::Glue::Table', {
346+
DatabaseName: 'mydb',
347+
OpenTableFormatInput: { IcebergInput: { MetadataOperation: 'CREATE' } },
348+
TableInput: { Name: 'events_iceberg' },
349+
});
350+
351+
const createCall = mockSend.mock.calls.find((c) => c[0] instanceof CreateTableCommand);
352+
const input = createCall![0].input as { OpenTableFormatInput: Record<string, unknown> };
353+
expect(input.OpenTableFormatInput).toEqual({ IcebergInput: { MetadataOperation: 'CREATE' } });
354+
});
355+
296356
it('AWS::Glue::Table — create() omits OpenTableFormatInput when absent (omit-when-absent)', async () => {
297357
mockSend.mockResolvedValueOnce({});
298358

0 commit comments

Comments
 (0)