@friggframework/devtools 2.0.0--canary.461.6b7bf79.0 → 2.0.0--canary.461.77c8d12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -989,7 +989,7 @@ class VpcBuilder extends InfrastructureBuilder {
989
989
  }
990
990
 
991
991
  // VPC Endpoint Security Group (only if KMS, Secrets Manager, or SQS are not stack-managed and missing)
992
- const needsSecurityGroup =
992
+ const needsSecurityGroup =
993
993
  (!stackManagedEndpoints.kms && !existingEndpoints.kms && appDefinition.encryption?.fieldLevelEncryptionMethod === 'kms') ||
994
994
  (!stackManagedEndpoints.secretsManager && !existingEndpoints.secretsManager) ||
995
995
  (!stackManagedEndpoints.sqs && !existingEndpoints.sqs);
@@ -14,8 +14,10 @@
14
14
  */
15
15
 
16
16
  class CloudFormationDiscovery {
17
- constructor(provider) {
17
+ constructor(provider, config = {}) {
18
18
  this.provider = provider;
19
+ this.serviceName = config.serviceName;
20
+ this.stage = config.stage;
19
21
  }
20
22
 
21
23
  /**
@@ -41,9 +43,8 @@ class CloudFormationDiscovery {
41
43
  }
42
44
 
43
45
  // Extract from resources (now async to query AWS for details)
44
- if (resources && resources.length > 0) {
45
- await this._extractFromResources(resources, discovered);
46
- }
46
+ // Always call this even if resources is empty, as it may query AWS for resources
47
+ await this._extractFromResources(resources || [], discovered);
47
48
 
48
49
  return discovered;
49
50
  } catch (error) {
@@ -108,9 +109,15 @@ class CloudFormationDiscovery {
108
109
  * @param {Object} discovered - Object to populate with discovered resources
109
110
  */
110
111
  async _extractFromResources(resources, discovered) {
112
+ console.log(` DEBUG: Processing ${resources.length} CloudFormation resources...`);
111
113
  for (const resource of resources) {
112
114
  const { LogicalResourceId, PhysicalResourceId, ResourceType } = resource;
113
115
 
116
+ // Debug Aurora detection
117
+ if (LogicalResourceId.includes('Aurora')) {
118
+ console.log(` DEBUG: Found Aurora resource: ${LogicalResourceId} (${ResourceType})`);
119
+ }
120
+
114
121
  // Security Group - use to get VPC ID
115
122
  if (LogicalResourceId === 'FriggLambdaSecurityGroup' && ResourceType === 'AWS::EC2::SecurityGroup') {
116
123
  discovered.securityGroupId = PhysicalResourceId;
@@ -141,21 +148,21 @@ class CloudFormationDiscovery {
141
148
  if (LogicalResourceId === 'FriggAuroraCluster' && ResourceType === 'AWS::RDS::DBCluster') {
142
149
  discovered.auroraClusterId = PhysicalResourceId;
143
150
  console.log(` ✓ Found Aurora cluster in stack: ${PhysicalResourceId}`);
144
-
151
+
145
152
  // Query RDS to get cluster endpoint
146
153
  if (this.provider && !discovered.auroraClusterEndpoint) {
147
154
  try {
148
155
  console.log(` Querying RDS to get Aurora endpoint...`);
149
156
  const { DescribeDBClustersCommand } = require('@aws-sdk/client-rds');
150
157
  const { RDSClient } = require('@aws-sdk/client-rds');
151
-
158
+
152
159
  const rdsClient = new RDSClient({ region: this.provider.region });
153
160
  const clusterDetails = await rdsClient.send(
154
161
  new DescribeDBClustersCommand({
155
162
  DBClusterIdentifier: PhysicalResourceId
156
163
  })
157
164
  );
158
-
165
+
159
166
  if (clusterDetails.DBClusters && clusterDetails.DBClusters.length > 0) {
160
167
  const cluster = clusterDetails.DBClusters[0];
161
168
  discovered.auroraClusterEndpoint = cluster.Endpoint;
@@ -194,6 +201,30 @@ class CloudFormationDiscovery {
194
201
  }
195
202
  }
196
203
 
204
+ // KMS Key Alias - query to get the actual key ARN
205
+ if (LogicalResourceId === 'FriggKMSKeyAlias' && ResourceType === 'AWS::KMS::Alias') {
206
+ discovered.kmsKeyAlias = PhysicalResourceId;
207
+ console.log(` ✓ Found KMS key alias in stack: ${PhysicalResourceId}`);
208
+
209
+ // Query KMS to get the key ARN that this alias points to
210
+ // Always query even if key is already set, to ensure consistency
211
+ if (this.provider && this.provider.describeKmsKey) {
212
+ try {
213
+ console.log(` Querying KMS to get key ARN from alias...`);
214
+ const keyMetadata = await this.provider.describeKmsKey(PhysicalResourceId);
215
+
216
+ if (keyMetadata) {
217
+ discovered.defaultKmsKeyId = keyMetadata.Arn;
218
+ console.log(` ✓ Extracted KMS key ARN from alias: ${discovered.defaultKmsKeyId}`);
219
+ } else {
220
+ console.warn(` ⚠️ KMS key query returned no metadata`);
221
+ }
222
+ } catch (error) {
223
+ console.warn(` ⚠️ Could not get key ARN from alias: ${error.message}`);
224
+ }
225
+ }
226
+ }
227
+
197
228
  // Subnets
198
229
  if (LogicalResourceId === 'FriggPrivateSubnet1' && ResourceType === 'AWS::EC2::Subnet') {
199
230
  discovered.privateSubnetId1 = PhysicalResourceId;
@@ -235,6 +266,79 @@ class CloudFormationDiscovery {
235
266
  discovered.sqsVpcEndpointId = PhysicalResourceId;
236
267
  }
237
268
  }
269
+
270
+ // If we have a VPC ID but no subnet IDs, query EC2 for Frigg-managed subnets
271
+ if (discovered.defaultVpcId && this.provider &&
272
+ !discovered.privateSubnetId1 && !discovered.publicSubnetId1) {
273
+ try {
274
+ console.log(' Querying EC2 for Frigg-managed subnets...');
275
+ const { DescribeSubnetsCommand } = require('@aws-sdk/client-ec2');
276
+ const subnetResponse = await this.provider.getEC2Client().send(
277
+ new DescribeSubnetsCommand({
278
+ Filters: [
279
+ { Name: 'vpc-id', Values: [discovered.defaultVpcId] },
280
+ { Name: 'tag:ManagedBy', Values: ['Frigg'] },
281
+ ],
282
+ })
283
+ );
284
+
285
+ if (subnetResponse.Subnets && subnetResponse.Subnets.length > 0) {
286
+ // Extract subnet IDs by logical ID from tags
287
+ const subnets = subnetResponse.Subnets.map(subnet => ({
288
+ subnetId: subnet.SubnetId,
289
+ logicalId: subnet.Tags?.find(t => t.Key === 'aws:cloudformation:logical-id')?.Value,
290
+ isPublic: subnet.MapPublicIpOnLaunch,
291
+ }));
292
+
293
+ // Find private subnets
294
+ const privateSubnets = subnets.filter(s => !s.isPublic).sort((a, b) =>
295
+ a.logicalId?.localeCompare(b.logicalId) || 0
296
+ );
297
+ if (privateSubnets.length >= 1) {
298
+ discovered.privateSubnetId1 = privateSubnets[0].subnetId;
299
+ }
300
+ if (privateSubnets.length >= 2) {
301
+ discovered.privateSubnetId2 = privateSubnets[1].subnetId;
302
+ }
303
+
304
+ // Find public subnets
305
+ const publicSubnets = subnets.filter(s => s.isPublic).sort((a, b) =>
306
+ a.logicalId?.localeCompare(b.logicalId) || 0
307
+ );
308
+ if (publicSubnets.length >= 1) {
309
+ discovered.publicSubnetId1 = publicSubnets[0].subnetId;
310
+ }
311
+ if (publicSubnets.length >= 2) {
312
+ discovered.publicSubnetId2 = publicSubnets[1].subnetId;
313
+ }
314
+
315
+ console.log(` ✓ Found ${subnets.length} Frigg-managed subnets via EC2 query`);
316
+ }
317
+ } catch (error) {
318
+ console.warn(` ⚠️ Could not query EC2 for subnets: ${error.message}`);
319
+ }
320
+ }
321
+
322
+ // Check for KMS key alias via AWS API if not found in stack resources
323
+ // This handles cases where the alias was created outside CloudFormation
324
+ if (!discovered.defaultKmsKeyId && !discovered.kmsKeyAlias &&
325
+ this.provider && this.provider.describeKmsKey && this.serviceName && this.stage) {
326
+ try {
327
+ const aliasName = `alias/${this.serviceName}-${this.stage}-frigg-kms`;
328
+ console.log(` Querying KMS for alias: ${aliasName}...`);
329
+
330
+ const keyMetadata = await this.provider.describeKmsKey(aliasName);
331
+
332
+ if (keyMetadata) {
333
+ discovered.defaultKmsKeyId = keyMetadata.Arn;
334
+ discovered.kmsKeyAlias = aliasName;
335
+ console.log(` ✓ Found KMS key via alias query: ${discovered.defaultKmsKeyId}`);
336
+ }
337
+ } catch (error) {
338
+ // Alias not found - this is expected if no KMS key exists yet
339
+ console.log(` ℹ No KMS key alias found via AWS API`);
340
+ }
341
+ }
238
342
  }
239
343
  }
240
344
 
@@ -307,6 +307,235 @@ describe('CloudFormationDiscovery', () => {
307
307
 
308
308
  expect(result).toEqual({});
309
309
  });
310
+
311
+ it('should query EC2 for subnets when VPC found but no subnet resources in stack', async () => {
312
+ const mockStack = {
313
+ StackName: 'test-stack',
314
+ Outputs: [],
315
+ };
316
+
317
+ const mockResources = [
318
+ {
319
+ LogicalResourceId: 'FriggLambdaSecurityGroup',
320
+ PhysicalResourceId: 'sg-123',
321
+ ResourceType: 'AWS::EC2::SecurityGroup',
322
+ },
323
+ ];
324
+
325
+ const mockEC2Client = {
326
+ send: jest.fn(),
327
+ };
328
+
329
+ mockProvider.describeStack.mockResolvedValue(mockStack);
330
+ mockProvider.listStackResources.mockResolvedValue(mockResources);
331
+ mockProvider.getEC2Client = jest.fn().mockReturnValue(mockEC2Client);
332
+
333
+ // Mock security group query for VPC ID
334
+ mockEC2Client.send.mockResolvedValueOnce({
335
+ SecurityGroups: [{ VpcId: 'vpc-123' }],
336
+ });
337
+
338
+ // Mock subnet query
339
+ mockEC2Client.send.mockResolvedValueOnce({
340
+ Subnets: [
341
+ {
342
+ SubnetId: 'subnet-private-1',
343
+ MapPublicIpOnLaunch: false,
344
+ Tags: [
345
+ { Key: 'ManagedBy', Value: 'Frigg' },
346
+ { Key: 'aws:cloudformation:logical-id', Value: 'FriggPrivateSubnet1' },
347
+ ],
348
+ },
349
+ {
350
+ SubnetId: 'subnet-private-2',
351
+ MapPublicIpOnLaunch: false,
352
+ Tags: [
353
+ { Key: 'ManagedBy', Value: 'Frigg' },
354
+ { Key: 'aws:cloudformation:logical-id', Value: 'FriggPrivateSubnet2' },
355
+ ],
356
+ },
357
+ ],
358
+ });
359
+
360
+ const result = await cfDiscovery.discoverFromStack('test-stack');
361
+
362
+ expect(result.privateSubnetId1).toBe('subnet-private-1');
363
+ expect(result.privateSubnetId2).toBe('subnet-private-2');
364
+ expect(mockEC2Client.send).toHaveBeenCalledTimes(2);
365
+ });
366
+
367
+ it('should handle EC2 subnet query errors gracefully', async () => {
368
+ const mockStack = {
369
+ StackName: 'test-stack',
370
+ Outputs: [],
371
+ };
372
+
373
+ const mockResources = [
374
+ {
375
+ LogicalResourceId: 'FriggLambdaSecurityGroup',
376
+ PhysicalResourceId: 'sg-123',
377
+ ResourceType: 'AWS::EC2::SecurityGroup',
378
+ },
379
+ ];
380
+
381
+ const mockEC2Client = {
382
+ send: jest.fn(),
383
+ };
384
+
385
+ mockProvider.describeStack.mockResolvedValue(mockStack);
386
+ mockProvider.listStackResources.mockResolvedValue(mockResources);
387
+ mockProvider.getEC2Client = jest.fn().mockReturnValue(mockEC2Client);
388
+
389
+ // Mock security group query for VPC ID
390
+ mockEC2Client.send.mockResolvedValueOnce({
391
+ SecurityGroups: [{ VpcId: 'vpc-123' }],
392
+ });
393
+
394
+ // Mock subnet query failure
395
+ mockEC2Client.send.mockRejectedValueOnce(new Error('EC2 API Error'));
396
+
397
+ const result = await cfDiscovery.discoverFromStack('test-stack');
398
+
399
+ expect(result.defaultVpcId).toBe('vpc-123');
400
+ expect(result.privateSubnetId1).toBeUndefined();
401
+ });
402
+
403
+ it('should extract KMS key alias from stack resources and query for key ARN', async () => {
404
+ const mockStack = {
405
+ StackName: 'test-stack',
406
+ Outputs: [],
407
+ };
408
+
409
+ const mockResources = [
410
+ {
411
+ LogicalResourceId: 'FriggKMSKeyAlias',
412
+ PhysicalResourceId: 'alias/test-service-dev-frigg-kms',
413
+ ResourceType: 'AWS::KMS::Alias',
414
+ },
415
+ ];
416
+
417
+ mockProvider.describeStack.mockResolvedValue(mockStack);
418
+ mockProvider.listStackResources.mockResolvedValue(mockResources);
419
+ mockProvider.describeKmsKey = jest.fn().mockResolvedValue({
420
+ KeyId: 'abc-123',
421
+ Arn: 'arn:aws:kms:us-east-1:123456789:key/abc-123',
422
+ });
423
+
424
+ const result = await cfDiscovery.discoverFromStack('test-stack');
425
+
426
+ expect(result.defaultKmsKeyId).toBe('arn:aws:kms:us-east-1:123456789:key/abc-123');
427
+ expect(result.kmsKeyAlias).toBe('alias/test-service-dev-frigg-kms');
428
+ expect(mockProvider.describeKmsKey).toHaveBeenCalledWith('alias/test-service-dev-frigg-kms');
429
+ });
430
+
431
+ it('should query AWS API for KMS alias when serviceName and stage are provided', async () => {
432
+ const mockStack = {
433
+ StackName: 'test-stack',
434
+ Outputs: [],
435
+ };
436
+
437
+ const mockResources = [];
438
+
439
+ mockProvider.describeStack.mockResolvedValue(mockStack);
440
+ mockProvider.listStackResources.mockResolvedValue(mockResources);
441
+ mockProvider.region = 'us-east-1';
442
+ mockProvider.describeKmsKey = jest.fn().mockResolvedValue({
443
+ KeyId: 'abc-123',
444
+ Arn: 'arn:aws:kms:us-east-1:123456789:key/abc-123',
445
+ });
446
+
447
+ // Pass serviceName and stage to discover alias
448
+ cfDiscovery.serviceName = 'test-service';
449
+ cfDiscovery.stage = 'dev';
450
+
451
+ const result = await cfDiscovery.discoverFromStack('test-stack');
452
+
453
+ expect(result.defaultKmsKeyId).toBe('arn:aws:kms:us-east-1:123456789:key/abc-123');
454
+ expect(result.kmsKeyAlias).toBe('alias/test-service-dev-frigg-kms');
455
+ expect(mockProvider.describeKmsKey).toHaveBeenCalledWith('alias/test-service-dev-frigg-kms');
456
+ });
457
+
458
+ it('should handle KMS alias not found gracefully', async () => {
459
+ const mockStack = {
460
+ StackName: 'test-stack',
461
+ Outputs: [],
462
+ };
463
+
464
+ const mockResources = [];
465
+
466
+ mockProvider.describeStack.mockResolvedValue(mockStack);
467
+ mockProvider.listStackResources.mockResolvedValue(mockResources);
468
+ mockProvider.region = 'us-east-1';
469
+ mockProvider.describeKmsKey = jest.fn().mockRejectedValue(
470
+ new Error('Alias/test-service-dev-frigg-kms is not found')
471
+ );
472
+
473
+ cfDiscovery.serviceName = 'test-service';
474
+ cfDiscovery.stage = 'dev';
475
+
476
+ const result = await cfDiscovery.discoverFromStack('test-stack');
477
+
478
+ expect(result.defaultKmsKeyId).toBeUndefined();
479
+ expect(result.kmsKeyAlias).toBeUndefined();
480
+ });
481
+
482
+ it('should prefer KMS key from stack resources over alias query', async () => {
483
+ const mockStack = {
484
+ StackName: 'test-stack',
485
+ Outputs: [],
486
+ };
487
+
488
+ const mockResources = [
489
+ {
490
+ LogicalResourceId: 'FriggKMSKey',
491
+ PhysicalResourceId: 'arn:aws:kms:us-east-1:123456789:key/xyz-789',
492
+ ResourceType: 'AWS::KMS::Key',
493
+ },
494
+ ];
495
+
496
+ mockProvider.describeStack.mockResolvedValue(mockStack);
497
+ mockProvider.listStackResources.mockResolvedValue(mockResources);
498
+ mockProvider.describeKmsKey = jest.fn();
499
+
500
+ const result = await cfDiscovery.discoverFromStack('test-stack');
501
+
502
+ // Should use the key from stack resources, not query for alias
503
+ expect(result.defaultKmsKeyId).toBe('arn:aws:kms:us-east-1:123456789:key/xyz-789');
504
+ expect(mockProvider.describeKmsKey).not.toHaveBeenCalled();
505
+ });
506
+
507
+ it('should use KMS alias from stack resources even if key is also present', async () => {
508
+ const mockStack = {
509
+ StackName: 'test-stack',
510
+ Outputs: [],
511
+ };
512
+
513
+ const mockResources = [
514
+ {
515
+ LogicalResourceId: 'FriggKMSKey',
516
+ PhysicalResourceId: 'arn:aws:kms:us-east-1:123456789:key/xyz-789',
517
+ ResourceType: 'AWS::KMS::Key',
518
+ },
519
+ {
520
+ LogicalResourceId: 'FriggKMSKeyAlias',
521
+ PhysicalResourceId: 'alias/test-service-dev-frigg-kms',
522
+ ResourceType: 'AWS::KMS::Alias',
523
+ },
524
+ ];
525
+
526
+ mockProvider.describeStack.mockResolvedValue(mockStack);
527
+ mockProvider.listStackResources.mockResolvedValue(mockResources);
528
+ mockProvider.describeKmsKey = jest.fn().mockResolvedValue({
529
+ KeyId: 'xyz-789',
530
+ Arn: 'arn:aws:kms:us-east-1:123456789:key/xyz-789',
531
+ });
532
+
533
+ const result = await cfDiscovery.discoverFromStack('test-stack');
534
+
535
+ expect(result.defaultKmsKeyId).toBe('arn:aws:kms:us-east-1:123456789:key/xyz-789');
536
+ expect(result.kmsKeyAlias).toBe('alias/test-service-dev-frigg-kms');
537
+ expect(mockProvider.describeKmsKey).toHaveBeenCalledWith('alias/test-service-dev-frigg-kms');
538
+ });
310
539
  });
311
540
  });
312
541
 
@@ -469,9 +469,29 @@ class AWSProviderAdapter extends CloudProviderAdapter {
469
469
  return result;
470
470
  }
471
471
 
472
+ /**
473
+ * Describe KMS key by key ID or alias
474
+ *
475
+ * @param {string} keyIdOrAlias - Key ID or alias name
476
+ * @returns {Promise<Object>} Key metadata
477
+ */
478
+ async describeKmsKey(keyIdOrAlias) {
479
+ const kms = this.getKMSClient();
480
+
481
+ try {
482
+ const response = await kms.send(new DescribeKeyCommand({
483
+ KeyId: keyIdOrAlias,
484
+ }));
485
+
486
+ return response.KeyMetadata;
487
+ } catch (error) {
488
+ throw new Error(`Failed to describe KMS key ${keyIdOrAlias}: ${error.message}`);
489
+ }
490
+ }
491
+
472
492
  /**
473
493
  * Describe CloudFormation stack
474
- *
494
+ *
475
495
  * @param {string} stackName - Name of the CloudFormation stack
476
496
  * @returns {Promise<Object>} Stack details including outputs
477
497
  */
@@ -73,9 +73,10 @@ async function gatherDiscoveredResources(appDefinition) {
73
73
  // Build discovery configuration
74
74
  const stage = process.env.SLS_STAGE || 'dev';
75
75
  const stackName = `${appDefinition.name || 'create-frigg-app'}-${stage}`;
76
+ const serviceName = appDefinition.name || 'create-frigg-app';
76
77
 
77
78
  // Try CloudFormation-first discovery
78
- const cfDiscovery = new CloudFormationDiscovery(provider);
79
+ const cfDiscovery = new CloudFormationDiscovery(provider, { serviceName, stage });
79
80
  const stackResources = await cfDiscovery.discoverFromStack(stackName);
80
81
 
81
82
  // Validate CF discovery results - only use if contains useful data
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@friggframework/devtools",
3
3
  "prettier": "@friggframework/prettier-config",
4
- "version": "2.0.0--canary.461.6b7bf79.0",
4
+ "version": "2.0.0--canary.461.77c8d12.0",
5
5
  "dependencies": {
6
6
  "@aws-sdk/client-ec2": "^3.835.0",
7
7
  "@aws-sdk/client-kms": "^3.835.0",
@@ -11,8 +11,8 @@
11
11
  "@babel/eslint-parser": "^7.18.9",
12
12
  "@babel/parser": "^7.25.3",
13
13
  "@babel/traverse": "^7.25.3",
14
- "@friggframework/schemas": "2.0.0--canary.461.6b7bf79.0",
15
- "@friggframework/test": "2.0.0--canary.461.6b7bf79.0",
14
+ "@friggframework/schemas": "2.0.0--canary.461.77c8d12.0",
15
+ "@friggframework/test": "2.0.0--canary.461.77c8d12.0",
16
16
  "@hapi/boom": "^10.0.1",
17
17
  "@inquirer/prompts": "^5.3.8",
18
18
  "axios": "^1.7.2",
@@ -34,8 +34,8 @@
34
34
  "serverless-http": "^2.7.0"
35
35
  },
36
36
  "devDependencies": {
37
- "@friggframework/eslint-config": "2.0.0--canary.461.6b7bf79.0",
38
- "@friggframework/prettier-config": "2.0.0--canary.461.6b7bf79.0",
37
+ "@friggframework/eslint-config": "2.0.0--canary.461.77c8d12.0",
38
+ "@friggframework/prettier-config": "2.0.0--canary.461.77c8d12.0",
39
39
  "aws-sdk-client-mock": "^4.1.0",
40
40
  "aws-sdk-client-mock-jest": "^4.1.0",
41
41
  "jest": "^30.1.3",
@@ -70,5 +70,5 @@
70
70
  "publishConfig": {
71
71
  "access": "public"
72
72
  },
73
- "gitHead": "6b7bf790ee79e49375d3064a3791808798b80a26"
73
+ "gitHead": "77c8d12f6a33bb2d66e19b43b6d96ee27d6f5e06"
74
74
  }