@friggframework/devtools 2.0.0--canary.623.b39b42d.0 → 2.0.0--canary.625.950ba82.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.
Files changed (39) hide show
  1. package/frigg-cli/__tests__/unit/commands/generate-iam.test.js +97 -0
  2. package/frigg-cli/deploy-command/index.js +44 -0
  3. package/frigg-cli/generate-command/index.js +2 -1
  4. package/frigg-cli/generate-iam-command.js +2 -1
  5. package/frigg-cli/index.js +16 -1
  6. package/frigg-cli/ssm-command/index.js +270 -0
  7. package/frigg-cli/ssm-command/index.test.js +301 -0
  8. package/infrastructure/__tests__/helpers/test-utils.js +3 -5
  9. package/infrastructure/__tests__/scoped-environment.test.js +126 -0
  10. package/infrastructure/domains/admin-scripts/admin-script-builder.js +17 -3
  11. package/infrastructure/domains/admin-scripts/admin-script-builder.test.js +45 -0
  12. package/infrastructure/domains/database/migration-builder.js +46 -9
  13. package/infrastructure/domains/database/migration-builder.test.js +101 -0
  14. package/infrastructure/domains/integration/integration-builder.js +65 -9
  15. package/infrastructure/domains/integration/integration-builder.test.js +95 -0
  16. package/infrastructure/domains/networking/vpc-builder.js +57 -7
  17. package/infrastructure/domains/networking/vpc-builder.test.js +41 -0
  18. package/infrastructure/domains/networking/vpc-resolver.js +12 -3
  19. package/infrastructure/domains/networking/vpc-resolver.test.js +40 -0
  20. package/infrastructure/domains/parameters/offload-utils.js +175 -0
  21. package/infrastructure/domains/parameters/offload-utils.test.js +193 -0
  22. package/infrastructure/domains/parameters/ssm-builder.js +60 -14
  23. package/infrastructure/domains/parameters/ssm-builder.test.js +145 -1
  24. package/infrastructure/domains/scheduler/scheduler-builder.js +44 -8
  25. package/infrastructure/domains/scheduler/scheduler-builder.test.js +118 -0
  26. package/infrastructure/domains/security/iam-generator.js +33 -1
  27. package/infrastructure/domains/security/iam-generator.test.js +73 -0
  28. package/infrastructure/domains/security/templates/frigg-deployment-iam-stack.yaml +15 -0
  29. package/infrastructure/domains/security/templates/iam-policy-full.json +7 -2
  30. package/infrastructure/domains/shared/builder-orchestrator.js +14 -0
  31. package/infrastructure/domains/shared/builder-orchestrator.test.js +45 -0
  32. package/infrastructure/domains/shared/environment-builder.js +43 -7
  33. package/infrastructure/domains/shared/environment-builder.test.js +97 -1
  34. package/infrastructure/domains/shared/function-environments.js +97 -0
  35. package/infrastructure/domains/shared/function-environments.test.js +146 -0
  36. package/infrastructure/infrastructure-composer.js +5 -0
  37. package/infrastructure/infrastructure-composer.test.js +69 -14
  38. package/infrastructure/integration.test.js +3 -5
  39. package/package.json +8 -7
@@ -318,5 +318,106 @@ describe('MigrationBuilder', () => {
318
318
  );
319
319
  });
320
320
  });
321
+
322
+ describe('scoped environment (lambda.scopedEnvironment)', () => {
323
+ beforeEach(() => {
324
+ delete process.env.FRIGG_SKIP_AWS_DISCOVERY;
325
+ });
326
+
327
+ it('scopes migration vars to the migration functions, keeping DB_TYPE global', async () => {
328
+ const result = await builder.build(
329
+ { lambda: { scopedEnvironment: true } },
330
+ {}
331
+ );
332
+
333
+ expect(result.environment.S3_BUCKET_NAME).toBeUndefined();
334
+ expect(result.environment.MIGRATION_STATUS_BUCKET).toBeUndefined();
335
+ expect(result.environment.DB_MIGRATION_QUEUE_URL).toBeUndefined();
336
+ expect(result.environment.DB_TYPE).toBe('postgresql');
337
+
338
+ for (const fnName of ['dbMigrationRouter', 'dbMigrationWorker']) {
339
+ expect(result.functionEnvironments[fnName]).toMatchObject({
340
+ S3_BUCKET_NAME: { Ref: 'FriggMigrationStatusBucket' },
341
+ MIGRATION_STATUS_BUCKET: {
342
+ Ref: 'FriggMigrationStatusBucket',
343
+ },
344
+ DB_MIGRATION_QUEUE_URL: { Ref: 'DbMigrationQueue' },
345
+ });
346
+ }
347
+ });
348
+
349
+ it('broadcasts app-wide when the flag is off', async () => {
350
+ const result = await builder.build({}, {});
351
+
352
+ expect(result.environment.S3_BUCKET_NAME).toEqual({
353
+ Ref: 'FriggMigrationStatusBucket',
354
+ });
355
+ expect(result.environment.DB_MIGRATION_QUEUE_URL).toEqual({
356
+ Ref: 'DbMigrationQueue',
357
+ });
358
+ expect(result.functionEnvironments).toBeUndefined();
359
+ });
360
+ });
361
+
362
+ describe('scoped environment (external migration resources)', () => {
363
+ // managementMode='managed' + vpcIsolation='shared' resolves both
364
+ // resources to EXTERNAL when discovered, driving the external path.
365
+ const externalDiscovery = {
366
+ migrationStatusBucket: 'external-migration-bucket',
367
+ migrationQueueUrl:
368
+ 'https://sqs.us-east-1.amazonaws.com/123456789012/external-migration-queue',
369
+ };
370
+
371
+ beforeEach(() => {
372
+ delete process.env.FRIGG_SKIP_AWS_DISCOVERY;
373
+ });
374
+
375
+ it('scopes external migration vars to the migration functions, keeping DB_TYPE global', async () => {
376
+ const result = await builder.build(
377
+ {
378
+ managementMode: 'managed',
379
+ vpcIsolation: 'shared',
380
+ lambda: { scopedEnvironment: true },
381
+ },
382
+ externalDiscovery
383
+ );
384
+
385
+ expect(result.environment.S3_BUCKET_NAME).toBeUndefined();
386
+ expect(result.environment.MIGRATION_STATUS_BUCKET).toBeUndefined();
387
+ expect(result.environment.DB_MIGRATION_QUEUE_URL).toBeUndefined();
388
+ expect(result.environment.DB_TYPE).toBe('postgresql');
389
+
390
+ for (const fnName of ['dbMigrationRouter', 'dbMigrationWorker']) {
391
+ expect(result.functionEnvironments[fnName]).toMatchObject({
392
+ S3_BUCKET_NAME: externalDiscovery.migrationStatusBucket,
393
+ MIGRATION_STATUS_BUCKET:
394
+ externalDiscovery.migrationStatusBucket,
395
+ DB_MIGRATION_QUEUE_URL: externalDiscovery.migrationQueueUrl,
396
+ });
397
+ }
398
+ });
399
+
400
+ it('broadcasts external migration vars app-wide when the flag is off', async () => {
401
+ const result = await builder.build(
402
+ {
403
+ managementMode: 'managed',
404
+ vpcIsolation: 'shared',
405
+ },
406
+ externalDiscovery
407
+ );
408
+
409
+ expect(result.environment.S3_BUCKET_NAME).toBe(
410
+ externalDiscovery.migrationStatusBucket
411
+ );
412
+ expect(result.environment.MIGRATION_STATUS_BUCKET).toBe(
413
+ externalDiscovery.migrationStatusBucket
414
+ );
415
+ expect(result.environment.DB_MIGRATION_QUEUE_URL).toBe(
416
+ externalDiscovery.migrationQueueUrl
417
+ );
418
+ expect(result.environment.DB_TYPE).toBe('postgresql');
419
+ expect(result.functionEnvironments).toBeUndefined();
420
+ });
421
+ });
321
422
  });
322
423
 
@@ -24,6 +24,11 @@ const {
24
24
  createEmptyDiscoveryResult,
25
25
  ResourceOwnership,
26
26
  } = require('../shared/types');
27
+ const {
28
+ isScopedEnvironmentActive,
29
+ getIntegrationFunctionNames,
30
+ getAdminFunctionNames,
31
+ } = require('../shared/function-environments');
27
32
 
28
33
  class IntegrationBuilder extends InfrastructureBuilder {
29
34
  constructor() {
@@ -218,13 +223,18 @@ class IntegrationBuilder extends InfrastructureBuilder {
218
223
  console.log(
219
224
  ` ✓ Creating ${integrationName}Queue in stack`
220
225
  );
221
- this.createIntegrationQueue(integrationName, result);
226
+ this.createIntegrationQueue(
227
+ integrationName,
228
+ result,
229
+ appDefinition
230
+ );
222
231
  } else {
223
232
  console.log(` ✓ Using external ${integrationName}Queue`);
224
233
  this.useExternalIntegrationQueue(
225
234
  integrationName,
226
235
  queueDecision,
227
- result
236
+ result,
237
+ appDefinition
228
238
  );
229
239
  }
230
240
  }
@@ -544,7 +554,7 @@ class IntegrationBuilder extends InfrastructureBuilder {
544
554
  /**
545
555
  * Create integration-specific SQS queue CloudFormation resource
546
556
  */
547
- createIntegrationQueue(integrationName, result) {
557
+ createIntegrationQueue(integrationName, result, appDefinition) {
548
558
  const queueReference = `${this.capitalizeFirst(integrationName)}Queue`;
549
559
  const queueName = `\${self:service}--\${self:provider.stage}-${queueReference}`;
550
560
 
@@ -564,9 +574,12 @@ class IntegrationBuilder extends InfrastructureBuilder {
564
574
  };
565
575
 
566
576
  // Add queue URL to environment
567
- result.environment[`${integrationName.toUpperCase()}_QUEUE_URL`] = {
568
- Ref: queueReference,
569
- };
577
+ this.setQueueUrlEnvironment(
578
+ integrationName,
579
+ { Ref: queueReference },
580
+ result,
581
+ appDefinition
582
+ );
570
583
 
571
584
  // Add queue name to custom section
572
585
  result.custom[queueReference] = queueName;
@@ -577,14 +590,57 @@ class IntegrationBuilder extends InfrastructureBuilder {
577
590
  /**
578
591
  * Use external integration queue
579
592
  */
580
- useExternalIntegrationQueue(integrationName, decision, result) {
593
+ useExternalIntegrationQueue(
594
+ integrationName,
595
+ decision,
596
+ result,
597
+ appDefinition
598
+ ) {
581
599
  // Add queue URL to environment for Lambda functions
582
- result.environment[`${integrationName.toUpperCase()}_QUEUE_URL`] =
583
- decision.physicalId;
600
+ this.setQueueUrlEnvironment(
601
+ integrationName,
602
+ decision.physicalId,
603
+ result,
604
+ appDefinition
605
+ );
584
606
 
585
607
  console.log(` ✓ Using external queue: ${decision.physicalId}`);
586
608
  }
587
609
 
610
+ /**
611
+ * Broadcast the queue URL app-wide, or — with lambda.scopedEnvironment —
612
+ * scope it to the functions that can actually enqueue: the shared auth
613
+ * router (dispatches integration actions synchronously), the admin-script
614
+ * functions (instantiate arbitrary integrations), and the owning
615
+ * integration's own functions.
616
+ */
617
+ setQueueUrlEnvironment(integrationName, value, result, appDefinition) {
618
+ const key = `${integrationName.toUpperCase()}_QUEUE_URL`;
619
+
620
+ if (!isScopedEnvironmentActive(appDefinition)) {
621
+ result.environment[key] = value;
622
+ return;
623
+ }
624
+
625
+ const integration = appDefinition.integrations.find(
626
+ (entry) => entry.Definition.name === integrationName
627
+ );
628
+ const targets = [
629
+ 'auth',
630
+ ...getAdminFunctionNames(appDefinition),
631
+ ...getIntegrationFunctionNames(integration),
632
+ ];
633
+
634
+ result.functionEnvironments = result.functionEnvironments || {};
635
+ for (const fnName of targets) {
636
+ result.functionEnvironments[fnName] = {
637
+ ...result.functionEnvironments[fnName],
638
+ [key]: value,
639
+ };
640
+ }
641
+ console.log(` ✓ Scoped ${key} to: ${targets.join(', ')}`);
642
+ }
643
+
588
644
  /**
589
645
  * Capitalize first letter of string (e.g., 'slack' -> 'Slack')
590
646
  */
@@ -935,5 +935,100 @@ describe('IntegrationBuilder', () => {
935
935
  );
936
936
  });
937
937
  });
938
+
939
+ describe('scoped environment (lambda.scopedEnvironment)', () => {
940
+ const originalSkipDiscovery = process.env.FRIGG_SKIP_AWS_DISCOVERY;
941
+
942
+ beforeEach(() => {
943
+ delete process.env.FRIGG_SKIP_AWS_DISCOVERY;
944
+ });
945
+
946
+ afterEach(() => {
947
+ if (originalSkipDiscovery === undefined) {
948
+ delete process.env.FRIGG_SKIP_AWS_DISCOVERY;
949
+ } else {
950
+ process.env.FRIGG_SKIP_AWS_DISCOVERY = originalSkipDiscovery;
951
+ }
952
+ });
953
+
954
+ const scopedApp = {
955
+ lambda: { scopedEnvironment: true },
956
+ adminScripts: [{ Definition: { name: 'fix-things' } }],
957
+ integrations: [
958
+ { Definition: { name: 'hubspot', webhooks: true } },
959
+ { Definition: { name: 'slack' } },
960
+ ],
961
+ };
962
+
963
+ it('scopes queue URLs to auth, admin functions, and the owning integration only', async () => {
964
+ const result = await integrationBuilder.build(scopedApp, {});
965
+
966
+ expect(result.environment.HUBSPOT_QUEUE_URL).toBeUndefined();
967
+ expect(result.environment.SLACK_QUEUE_URL).toBeUndefined();
968
+
969
+ const scoped = result.functionEnvironments;
970
+ // auth and admin functions can enqueue to any integration
971
+ expect(scoped.auth).toEqual({
972
+ HUBSPOT_QUEUE_URL: { Ref: 'HubspotQueue' },
973
+ SLACK_QUEUE_URL: { Ref: 'SlackQueue' },
974
+ });
975
+ expect(scoped.adminScriptRouter.HUBSPOT_QUEUE_URL).toBeDefined();
976
+ expect(scoped.adminScriptExecutor.SLACK_QUEUE_URL).toBeDefined();
977
+
978
+ // owning integration's full function set
979
+ expect(scoped.hubspot.HUBSPOT_QUEUE_URL).toBeDefined();
980
+ expect(scoped.hubspotWebhook.HUBSPOT_QUEUE_URL).toBeDefined();
981
+ expect(scoped.hubspotQueueWorker.HUBSPOT_QUEUE_URL).toBeDefined();
982
+
983
+ // cross-integration isolation
984
+ expect(scoped.hubspotQueueWorker.SLACK_QUEUE_URL).toBeUndefined();
985
+ expect(scoped.slackQueueWorker.HUBSPOT_QUEUE_URL).toBeUndefined();
986
+ });
987
+
988
+ it('targets extension handler functions too', async () => {
989
+ const withExtension = {
990
+ lambda: { scopedEnvironment: true },
991
+ integrations: [
992
+ {
993
+ Definition: {
994
+ name: 'hubspot',
995
+ extensions: {
996
+ 'my-ext': {
997
+ extension: {
998
+ routes: [{ path: '/x', method: 'GET' }],
999
+ },
1000
+ },
1001
+ },
1002
+ },
1003
+ },
1004
+ ],
1005
+ };
1006
+ const result = await integrationBuilder.build(withExtension, {});
1007
+
1008
+ expect(
1009
+ result.functionEnvironments.hubspot__myext.HUBSPOT_QUEUE_URL
1010
+ ).toBeDefined();
1011
+ });
1012
+
1013
+ it('broadcasts app-wide when the flag is off', async () => {
1014
+ const result = await integrationBuilder.build(
1015
+ { integrations: scopedApp.integrations },
1016
+ {}
1017
+ );
1018
+
1019
+ expect(result.environment.HUBSPOT_QUEUE_URL).toEqual({
1020
+ Ref: 'HubspotQueue',
1021
+ });
1022
+ expect(result.functionEnvironments).toBeUndefined();
1023
+ });
1024
+
1025
+ it('broadcasts in local mode even with the flag on', async () => {
1026
+ process.env.FRIGG_SKIP_AWS_DISCOVERY = 'true';
1027
+ const result = await integrationBuilder.build(scopedApp, {});
1028
+
1029
+ expect(result.environment.HUBSPOT_QUEUE_URL).toBeDefined();
1030
+ expect(result.functionEnvironments).toBeUndefined();
1031
+ });
1032
+ });
938
1033
  });
939
1034
 
@@ -22,6 +22,7 @@ const { InfrastructureBuilder, ValidationResult } = require('../shared/base-buil
22
22
  const VpcResourceResolver = require('./vpc-resolver');
23
23
  const { createEmptyDiscoveryResult } = require('../shared/types/discovery-result');
24
24
  const { ResourceOwnership } = require('../shared/types/resource-ownership');
25
+ const { isSsmOffloadActive } = require('../parameters/offload-utils');
25
26
 
26
27
  class VpcBuilder extends InfrastructureBuilder {
27
28
  constructor() {
@@ -177,6 +178,9 @@ class VpcBuilder extends InfrastructureBuilder {
177
178
  } else if (logicalId === 'FriggSQSVPCEndpoint' || logicalId === 'VPCEndpointSQS') {
178
179
  resourceType = 'AWS::EC2::VPCEndpoint';
179
180
  physicalId = flatDiscovery.sqsVpcEndpointId;
181
+ } else if (logicalId === 'FriggSSMVPCEndpoint' || logicalId === 'VPCEndpointSSM') {
182
+ resourceType = 'AWS::EC2::VPCEndpoint';
183
+ physicalId = flatDiscovery.ssmVpcEndpointId;
180
184
  } else if (logicalId === 'FriggNATRoute' || logicalId === 'FriggPrivateRoute') {
181
185
  resourceType = 'AWS::EC2::Route';
182
186
  physicalId = flatDiscovery.natRoute;
@@ -296,6 +300,15 @@ class VpcBuilder extends InfrastructureBuilder {
296
300
  properties: { ServiceName: 'sqs' }
297
301
  });
298
302
  }
303
+
304
+ if (flatDiscovery.ssmVpcEndpointId && typeof flatDiscovery.ssmVpcEndpointId === 'string') {
305
+ discovery.external.push({
306
+ physicalId: flatDiscovery.ssmVpcEndpointId,
307
+ resourceType: 'AWS::EC2::VPCEndpoint',
308
+ source: 'aws-discovery',
309
+ properties: { ServiceName: 'ssm' }
310
+ });
311
+ }
299
312
  }
300
313
 
301
314
  // Add flat discovery properties directly to discovery object for resolver access
@@ -1005,7 +1018,7 @@ class VpcBuilder extends InfrastructureBuilder {
1005
1018
  }
1006
1019
 
1007
1020
  // Create security group for interface endpoints if needed
1008
- const needsInterfaceEndpoints = endpointsToCreate.some(type => ['kms', 'secretsManager', 'sqs'].includes(type));
1021
+ const needsInterfaceEndpoints = endpointsToCreate.some(type => ['kms', 'secretsManager', 'sqs', 'ssm'].includes(type));
1009
1022
  if (needsInterfaceEndpoints) {
1010
1023
  // Determine source security group for ingress rule
1011
1024
  let sourceSgId;
@@ -1080,6 +1093,20 @@ class VpcBuilder extends InfrastructureBuilder {
1080
1093
  };
1081
1094
  }
1082
1095
 
1096
+ if (endpointsToCreate.includes('ssm')) {
1097
+ result.resources.FriggSSMVPCEndpoint = {
1098
+ Type: 'AWS::EC2::VPCEndpoint',
1099
+ Properties: {
1100
+ VpcId: vpcId,
1101
+ ServiceName: 'com.amazonaws.${self:provider.region}.ssm',
1102
+ VpcEndpointType: 'Interface',
1103
+ SubnetIds: result.vpcConfig.subnetIds,
1104
+ SecurityGroupIds: [{ Ref: 'FriggVPCEndpointSecurityGroup' }],
1105
+ PrivateDnsEnabled: true,
1106
+ },
1107
+ };
1108
+ }
1109
+
1083
1110
  console.log(` ✅ VPC Endpoint resources added to template`);
1084
1111
  }
1085
1112
 
@@ -1157,7 +1184,8 @@ class VpcBuilder extends InfrastructureBuilder {
1157
1184
  dynamodb: existingLogicalIds.includes('VPCEndpointDynamoDB') ? 'VPCEndpointDynamoDB' : 'FriggDynamoDBVPCEndpoint',
1158
1185
  kms: existingLogicalIds.includes('VPCEndpointKMS') ? 'VPCEndpointKMS' : 'FriggKMSVPCEndpoint',
1159
1186
  secretsManager: existingLogicalIds.includes('VPCEndpointSecretsManager') ? 'VPCEndpointSecretsManager' : 'FriggSecretsManagerVPCEndpoint',
1160
- sqs: existingLogicalIds.includes('VPCEndpointSQS') ? 'VPCEndpointSQS' : 'FriggSQSVPCEndpoint'
1187
+ sqs: existingLogicalIds.includes('VPCEndpointSQS') ? 'VPCEndpointSQS' : 'FriggSQSVPCEndpoint',
1188
+ ssm: existingLogicalIds.includes('VPCEndpointSSM') ? 'VPCEndpointSSM' : 'FriggSSMVPCEndpoint'
1161
1189
  };
1162
1190
 
1163
1191
  Object.entries(decisions).forEach(([type, decision]) => {
@@ -1186,11 +1214,12 @@ class VpcBuilder extends InfrastructureBuilder {
1186
1214
  }
1187
1215
  };
1188
1216
  } else {
1189
- // Interface endpoints (KMS, Secrets Manager, SQS)
1217
+ // Interface endpoints (KMS, Secrets Manager, SQS, SSM)
1190
1218
  const serviceMap = {
1191
1219
  kms: 'kms',
1192
1220
  secretsManager: 'secretsmanager',
1193
- sqs: 'sqs'
1221
+ sqs: 'sqs',
1222
+ ssm: 'ssm'
1194
1223
  };
1195
1224
 
1196
1225
  result.resources[logicalId] = {
@@ -1209,7 +1238,7 @@ class VpcBuilder extends InfrastructureBuilder {
1209
1238
  });
1210
1239
 
1211
1240
  // If any interface endpoints exist, ensure security group is in template
1212
- const hasInterfaceEndpoints = ['kms', 'secretsManager', 'sqs'].some(
1241
+ const hasInterfaceEndpoints = ['kms', 'secretsManager', 'sqs', 'ssm'].some(
1213
1242
  type => decisions[type]?.ownership === ResourceOwnership.STACK && decisions[type]?.physicalId
1214
1243
  );
1215
1244
 
@@ -1887,8 +1916,11 @@ class VpcBuilder extends InfrastructureBuilder {
1887
1916
  kms: discoveredResources.kmsVpcEndpointId && typeof discoveredResources.kmsVpcEndpointId === 'string',
1888
1917
  secretsManager: discoveredResources.secretsManagerVpcEndpointId && typeof discoveredResources.secretsManagerVpcEndpointId === 'string',
1889
1918
  sqs: discoveredResources.sqsVpcEndpointId && typeof discoveredResources.sqsVpcEndpointId === 'string',
1919
+ ssm: discoveredResources.ssmVpcEndpointId && typeof discoveredResources.ssmVpcEndpointId === 'string',
1890
1920
  };
1891
1921
 
1922
+ const needsSsm = isSsmOffloadActive(appDefinition);
1923
+
1892
1924
  // Build list of what needs creation (not stack-managed, not existing elsewhere)
1893
1925
  const missing = [];
1894
1926
  if (!stackManagedEndpoints.s3 && !existingEndpoints.s3) missing.push('S3');
@@ -1897,6 +1929,7 @@ class VpcBuilder extends InfrastructureBuilder {
1897
1929
  if (!stackManagedEndpoints.secretsManager && !existingEndpoints.secretsManager) missing.push('Secrets Manager');
1898
1930
  // SQS endpoint needed for job queues and async processing
1899
1931
  if (!stackManagedEndpoints.sqs && !existingEndpoints.sqs) missing.push('SQS');
1932
+ if (!stackManagedEndpoints.ssm && !existingEndpoints.ssm && needsSsm) missing.push('SSM');
1900
1933
 
1901
1934
  // Log reused stack-managed endpoints
1902
1935
  const reused = [];
@@ -1905,6 +1938,7 @@ class VpcBuilder extends InfrastructureBuilder {
1905
1938
  if (stackManagedEndpoints.kms) reused.push('KMS');
1906
1939
  if (stackManagedEndpoints.secretsManager) reused.push('Secrets Manager');
1907
1940
  if (stackManagedEndpoints.sqs) reused.push('SQS');
1941
+ if (stackManagedEndpoints.ssm) reused.push('SSM');
1908
1942
 
1909
1943
  if (reused.length > 0) {
1910
1944
  console.log(` ✓ Reusing stack-managed VPC endpoints: ${reused.join(', ')}`);
@@ -1968,11 +2002,12 @@ class VpcBuilder extends InfrastructureBuilder {
1968
2002
  };
1969
2003
  }
1970
2004
 
1971
- // VPC Endpoint Security Group (only if KMS, Secrets Manager, or SQS are not stack-managed and missing)
2005
+ // VPC Endpoint Security Group (only if KMS, Secrets Manager, SQS, or SSM are not stack-managed and missing)
1972
2006
  const needsSecurityGroup =
1973
2007
  (!stackManagedEndpoints.kms && !existingEndpoints.kms && appDefinition.encryption?.fieldLevelEncryptionMethod === 'kms') ||
1974
2008
  (!stackManagedEndpoints.secretsManager && !existingEndpoints.secretsManager) ||
1975
- (!stackManagedEndpoints.sqs && !existingEndpoints.sqs);
2009
+ (!stackManagedEndpoints.sqs && !existingEndpoints.sqs) ||
2010
+ (!stackManagedEndpoints.ssm && !existingEndpoints.ssm && needsSsm);
1976
2011
 
1977
2012
  if (needsSecurityGroup) {
1978
2013
  result.resources.FriggVPCEndpointSecurityGroup = {
@@ -2043,6 +2078,21 @@ class VpcBuilder extends InfrastructureBuilder {
2043
2078
  };
2044
2079
  }
2045
2080
 
2081
+ // SSM Interface Endpoint (only if not stack-managed, missing, AND SSM offload is active)
2082
+ if (!stackManagedEndpoints.ssm && !existingEndpoints.ssm && needsSsm) {
2083
+ result.resources.FriggSSMVPCEndpoint = {
2084
+ Type: 'AWS::EC2::VPCEndpoint',
2085
+ Properties: {
2086
+ VpcId: vpcId,
2087
+ ServiceName: 'com.amazonaws.${self:provider.region}.ssm',
2088
+ VpcEndpointType: 'Interface',
2089
+ SubnetIds: result.vpcConfig.subnetIds,
2090
+ SecurityGroupIds: [{ Ref: 'FriggVPCEndpointSecurityGroup' }],
2091
+ PrivateDnsEnabled: true,
2092
+ },
2093
+ };
2094
+ }
2095
+
2046
2096
  console.log(` ✅ Created ${missing.length} VPC endpoint(s): ${missing.join(', ')}`);
2047
2097
  }
2048
2098
  }
@@ -990,6 +990,47 @@ describe('VpcBuilder', () => {
990
990
  });
991
991
  });
992
992
 
993
+ describe('SSM VPC Endpoint', () => {
994
+ const originalSkipDiscovery = process.env.FRIGG_SKIP_AWS_DISCOVERY;
995
+
996
+ afterEach(() => {
997
+ if (originalSkipDiscovery === undefined) {
998
+ delete process.env.FRIGG_SKIP_AWS_DISCOVERY;
999
+ } else {
1000
+ process.env.FRIGG_SKIP_AWS_DISCOVERY = originalSkipDiscovery;
1001
+ }
1002
+ });
1003
+
1004
+ it('should create SSM endpoint when offload is active and VPC is enabled', async () => {
1005
+ delete process.env.FRIGG_SKIP_AWS_DISCOVERY;
1006
+ const appDefinition = {
1007
+ vpc: { enable: true, management: 'create-new' },
1008
+ ssm: { enable: true },
1009
+ environment: { FOO: 'ssm' },
1010
+ };
1011
+
1012
+ const result = await vpcBuilder.build(appDefinition, {});
1013
+
1014
+ expect(result.resources.FriggSSMVPCEndpoint).toBeDefined();
1015
+ expect(result.resources.FriggSSMVPCEndpoint.Type).toBe('AWS::EC2::VPCEndpoint');
1016
+ expect(result.resources.FriggSSMVPCEndpoint.Properties.VpcEndpointType).toBe('Interface');
1017
+ expect(result.resources.FriggSSMVPCEndpoint.Properties.ServiceName).toBe('com.amazonaws.${self:provider.region}.ssm');
1018
+ expect(result.resources.FriggSSMVPCEndpoint.Properties.PrivateDnsEnabled).toBe(true);
1019
+ });
1020
+
1021
+ it('should not create SSM endpoint when offload is not active', async () => {
1022
+ delete process.env.FRIGG_SKIP_AWS_DISCOVERY;
1023
+ const appDefinition = {
1024
+ vpc: { enable: true, management: 'create-new' },
1025
+ ssm: { enable: true },
1026
+ };
1027
+
1028
+ const result = await vpcBuilder.build(appDefinition, {});
1029
+
1030
+ expect(result.resources.FriggSSMVPCEndpoint).toBeUndefined();
1031
+ });
1032
+ });
1033
+
993
1034
  describe('Self-healing', () => {
994
1035
  it('should create missing subnets when selfHeal is enabled', async () => {
995
1036
  const appDefinition = {
@@ -13,6 +13,7 @@
13
13
 
14
14
  const BaseResourceResolver = require('../shared/base-resolver');
15
15
  const { ResourceOwnership } = require('../shared/types');
16
+ const { isSsmOffloadActive } = require('../parameters/offload-utils');
16
17
 
17
18
  class VpcResourceResolver extends BaseResourceResolver {
18
19
  /**
@@ -381,7 +382,8 @@ class VpcResourceResolver extends BaseResourceResolver {
381
382
  dynamodb: { ownership: null, reason: 'VPC Endpoints disabled' },
382
383
  kms: { ownership: null, reason: 'VPC Endpoints disabled' },
383
384
  secretsManager: { ownership: null, reason: 'VPC Endpoints disabled' },
384
- sqs: { ownership: null, reason: 'VPC Endpoints disabled' }
385
+ sqs: { ownership: null, reason: 'VPC Endpoints disabled' },
386
+ ssm: { ownership: null, reason: 'VPC Endpoints disabled' }
385
387
  };
386
388
  }
387
389
 
@@ -389,6 +391,9 @@ class VpcResourceResolver extends BaseResourceResolver {
389
391
  const encryptionMethod = appDefinition.encryption?.fieldLevelEncryptionMethod;
390
392
  const needsKms = encryptionMethod === 'kms';
391
393
 
394
+ // SSM endpoint only needed when parameter offload is active
395
+ const needsSsm = isSsmOffloadActive(appDefinition);
396
+
392
397
  // DynamoDB endpoint only needed if using DynamoDB (not MongoDB or PostgreSQL)
393
398
  // Currently framework only supports MongoDB (via Prisma) and PostgreSQL (via Aurora)
394
399
  // If not using DynamoDB, skip the endpoint (CloudFormation will delete if it exists)
@@ -403,7 +408,10 @@ class VpcResourceResolver extends BaseResourceResolver {
403
408
  ? this._resolveEndpoint('FriggKMSVPCEndpoint', 'kms', userIntent, appDefinition, discovery)
404
409
  : { ownership: null, reason: 'KMS endpoint not needed (encryption method is not KMS)' },
405
410
  secretsManager: this._resolveEndpoint('FriggSecretsManagerVPCEndpoint', 'secretsManager', userIntent, appDefinition, discovery),
406
- sqs: this._resolveEndpoint('FriggSQSVPCEndpoint', 'sqs', userIntent, appDefinition, discovery)
411
+ sqs: this._resolveEndpoint('FriggSQSVPCEndpoint', 'sqs', userIntent, appDefinition, discovery),
412
+ ssm: needsSsm
413
+ ? this._resolveEndpoint('FriggSSMVPCEndpoint', 'ssm', userIntent, appDefinition, discovery)
414
+ : { ownership: null, reason: 'SSM endpoint not needed (SSM offload not active)' }
407
415
  };
408
416
 
409
417
  return endpoints;
@@ -421,7 +429,8 @@ class VpcResourceResolver extends BaseResourceResolver {
421
429
  'FriggDynamoDBVPCEndpoint': 'VPCEndpointDynamoDB',
422
430
  'FriggKMSVPCEndpoint': 'VPCEndpointKMS',
423
431
  'FriggSecretsManagerVPCEndpoint': 'VPCEndpointSecretsManager',
424
- 'FriggSQSVPCEndpoint': 'VPCEndpointSQS'
432
+ 'FriggSQSVPCEndpoint': 'VPCEndpointSQS',
433
+ 'FriggSSMVPCEndpoint': 'VPCEndpointSSM'
425
434
  };
426
435
  const oldLogicalId = oldLogicalIdMap[logicalId];
427
436
 
@@ -567,6 +567,46 @@ describe('VpcResourceResolver', () => {
567
567
  expect(decisions.kms.ownership).toBeNull();
568
568
  expect(decisions.secretsManager.ownership).toBeNull();
569
569
  expect(decisions.sqs.ownership).toBeNull();
570
+ expect(decisions.ssm.ownership).toBeNull();
571
+ });
572
+
573
+ describe('SSM endpoint', () => {
574
+ const originalSkipDiscovery = process.env.FRIGG_SKIP_AWS_DISCOVERY;
575
+
576
+ afterEach(() => {
577
+ if (originalSkipDiscovery === undefined) {
578
+ delete process.env.FRIGG_SKIP_AWS_DISCOVERY;
579
+ } else {
580
+ process.env.FRIGG_SKIP_AWS_DISCOVERY = originalSkipDiscovery;
581
+ }
582
+ });
583
+
584
+ it('should create SSM endpoint when offload is active', () => {
585
+ delete process.env.FRIGG_SKIP_AWS_DISCOVERY;
586
+ const appDefinition = {
587
+ vpc: { ownership: { vpcEndpoints: 'auto' } },
588
+ ssm: { enable: true },
589
+ environment: { FOO: 'ssm' }
590
+ };
591
+ const discovery = { stackManaged: [], external: [], fromCloudFormation: false };
592
+
593
+ const decisions = resolver.resolveVpcEndpoints(appDefinition, discovery);
594
+
595
+ expect(decisions.ssm.ownership).toBe('stack');
596
+ });
597
+
598
+ it('should not create SSM endpoint when offload is not active', () => {
599
+ delete process.env.FRIGG_SKIP_AWS_DISCOVERY;
600
+ const appDefinition = {
601
+ vpc: { ownership: { vpcEndpoints: 'auto' } },
602
+ ssm: { enable: true }
603
+ };
604
+ const discovery = { stackManaged: [], external: [], fromCloudFormation: false };
605
+
606
+ const decisions = resolver.resolveVpcEndpoints(appDefinition, discovery);
607
+
608
+ expect(decisions.ssm.ownership).toBeNull();
609
+ });
570
610
  });
571
611
 
572
612
  it('should resolve to EXTERNAL with user-provided endpoint IDs', () => {