@fjall/components-infrastructure 6.0.0 → 7.0.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 (29) hide show
  1. package/dist/lib/patterns/aws/clickhouseDatabase.js +4 -0
  2. package/dist/lib/resources/aws/compute/ec2.d.ts +31 -14
  3. package/dist/lib/resources/aws/compute/ec2.js +34 -10
  4. package/dist/lib/resources/aws/compute/ecsCapacityConfig.d.ts +56 -16
  5. package/dist/lib/resources/aws/compute/ecsCapacityConfig.js +226 -64
  6. package/dist/lib/resources/aws/compute/ecsConstants.d.ts +15 -0
  7. package/dist/lib/resources/aws/compute/ecsConstants.js +15 -0
  8. package/dist/lib/resources/aws/compute/ecsServiceFactory.d.ts +16 -6
  9. package/dist/lib/resources/aws/compute/ecsServiceFactory.js +68 -19
  10. package/dist/lib/resources/aws/compute/ecsTypes.d.ts +33 -4
  11. package/dist/lib/resources/aws/compute/persistentDataVolume.d.ts +15 -5
  12. package/dist/lib/resources/aws/compute/persistentDataVolume.js +9 -6
  13. package/dist/lib/utils/capacityIdentityContext.d.ts +20 -0
  14. package/dist/lib/utils/capacityIdentityContext.js +43 -0
  15. package/dist/lib/utils/manifestWriter.d.ts +19 -2
  16. package/dist/lib/utils/manifestWriter.js +35 -0
  17. package/package.json +4 -5
  18. package/dist/lib/config/aws/identityCenterMembership.d.ts +0 -11
  19. package/dist/lib/config/aws/identityCenterMembership.js +0 -61
  20. package/dist/lib/layers/layers/secrets-resolver/bin/resolve-secrets +0 -30
  21. package/dist/lib/layers/layers/secrets-resolver/bin/resolve-secrets.mjs +0 -212
  22. package/dist/lib/patterns/aws/buildkite/alarms.d.ts +0 -25
  23. package/dist/lib/patterns/aws/buildkite/alarms.js +0 -78
  24. package/dist/lib/patterns/aws/domainDelegation.d.ts +0 -8
  25. package/dist/lib/patterns/aws/domainDelegation.js +0 -45
  26. package/dist/lib/patterns/aws/domainFactory.d.ts +0 -23
  27. package/dist/lib/patterns/aws/domainFactory.js +0 -61
  28. package/dist/lib/utils/addSuffixToEmail.d.ts +0 -1
  29. package/dist/lib/utils/addSuffixToEmail.js +0 -3
@@ -22,7 +22,7 @@ import type { ManagedDomainBinding, ManagedDomainExports } from "../../../utils/
22
22
  import type { ITopic } from "aws-cdk-lib/aws-sns";
23
23
  import type { ILogGroup } from "aws-cdk-lib/aws-logs";
24
24
  import type { EcsServiceAlarmThresholds, LogPatternAlarmSpec } from "../monitoring/index.js";
25
- import { type Ec2InstancePersistentDataVolumeConfig } from "./ec2.js";
25
+ import { type Ec2InstancePersistentDataVolumeConfig, type Ec2InstanceUpdatePolicyConfig } from "./ec2.js";
26
26
  export declare enum Protocol {
27
27
  HTTP = 0,
28
28
  HTTPS = 1
@@ -94,6 +94,26 @@ export type { EcsCapacityProvider };
94
94
  * Only used when capacityProvider is "EC2".
95
95
  */
96
96
  export interface Ec2CapacityConfig {
97
+ /**
98
+ * Capacity SLOT — the stable, operator-chosen identity for this ASG within
99
+ * its cluster. Lowercase kebab (`CAPACITY_SLOT_PATTERN` from `@fjall/util`),
100
+ * max 64 chars. Default: `"primary"`. Every CloudFormation identity surface
101
+ * (logical-ID prefixes, physical service name, launch-template name)
102
+ * derives from the slot's ANCHOR (`toPascalCase(slot)`), never from the
103
+ * hardware config — changing `instanceType` or any other property updates
104
+ * the ASG in place instead of renaming (replacing) it. Services declaring
105
+ * the same slot share its ASG and must agree on every property field
106
+ * (`assertSharedAsgConfigMatches`).
107
+ */
108
+ slot?: string;
109
+ /**
110
+ * Explicit identity-anchor pin. When present it wins over both the
111
+ * slot-derived anchor AND any `fjall:capacityIdentity` context pin. Must
112
+ * match `CAPACITY_ANCHOR_PATTERN` (alphanumeric, letter first). Primarily
113
+ * for freezing a deployed pre-6.0 config-derived identity; new stacks
114
+ * should rely on the slot derivation.
115
+ */
116
+ identityAnchor?: string;
97
117
  /** EC2 instance type. Default: "t4g.micro" */
98
118
  instanceType?: string;
99
119
  /** AMI hardware type. Default: "ARM" (Graviton - better cost/performance) */
@@ -151,9 +171,9 @@ export interface Ec2CapacityConfig {
151
171
  * Pairs the EC2 capacity ASG with a standalone EBS data volume that
152
172
  * re-attaches across instance refreshes. Forwarded to `Ec2Instance` which
153
173
  * locates and detaches the volume via TERMINATING/LAUNCHING lifecycle
154
- * hooks. Implies a singleton service — do not share an ASG across
155
- * services when this is set (`getEc2ConfigKey` adds a discriminator to
156
- * keep them apart).
174
+ * hooks. Implies a singleton service — a second service declaring the
175
+ * same slot is rejected at synth (`assertSharedAsgConfigMatches`); give
176
+ * it its own slot instead.
157
177
  */
158
178
  persistentDataVolume?: Ec2InstancePersistentDataVolumeConfig;
159
179
  /**
@@ -163,6 +183,15 @@ export interface Ec2CapacityConfig {
163
183
  * Empty-string keys or values are rejected at the resources layer.
164
184
  */
165
185
  tags?: Record<string, string>;
186
+ /**
187
+ * ASG `UpdatePolicy` passthrough, forwarded to `Ec2Instance` verbatim.
188
+ * Absent → `instanceRefresh` with `minHealthyPercentage 0 /
189
+ * maxHealthyPercentage 100` when the slot owns a `persistentDataVolume`
190
+ * (single-attach EBS forbids surge), `100 / 200` otherwise (surge-then
191
+ * -shrink, no capacity dip). A property field, not identity — changing it
192
+ * never renames the ASG.
193
+ */
194
+ updatePolicy?: Ec2InstanceUpdatePolicyConfig;
166
195
  }
167
196
  /**
168
197
  * Explicit Route53 latency routing policy for the cluster's alias records
@@ -19,6 +19,16 @@ export declare const PERSISTENT_DATA_VOLUME_TAG_STACK_ID = "fjall:StackId";
19
19
  export interface PersistentDataVolumeProps {
20
20
  /** ASG whose EC2_INSTANCE_LAUNCHING transitions trigger the re-attach. */
21
21
  autoScalingGroup: AutoScalingGroup;
22
+ /**
23
+ * Stable owner identifier — becomes the volume's `fjall:OwnerLogicalId`
24
+ * tag and the Lambdas' `OWNER_LOGICAL_ID` env. An explicit prop (not
25
+ * `this.node.path`) so the re-attach identity survives construct-tree
26
+ * refactors: renaming or reparenting an ancestor must never strand the
27
+ * deployed volume. Must remain byte-stable for the stack's whole life;
28
+ * for a stack deployed before the identity-anchor era, pass the deployed
29
+ * tag value verbatim (the pin's `legacyOwnerId`).
30
+ */
31
+ stableOwnerId: string;
22
32
  /** Size in GiB. */
23
33
  sizeGb: number;
24
34
  /** Device path the bootstrap script expects (e.g. /dev/xvdf). */
@@ -56,7 +66,7 @@ export interface PersistentDataVolumeProps {
56
66
  *
57
67
  * Volume tagging: three tags carry the re-attach identity —
58
68
  * `fjall:Lifecycle = data-volume` (constant; gates IAM scoping)
59
- * `fjall:OwnerLogicalId = <this.node.path>` (per-construct, stable across
69
+ * `fjall:OwnerLogicalId = <props.stableOwnerId>` (per-slot, stable across
60
70
  * instance refreshes in one stack so the Lambda re-finds the same volume)
61
71
  * `fjall:StackId = <Aws.STACK_ID>` (per-stack-creation discriminator;
62
72
  * CloudFormation issues a fresh StackId for every CREATE, so orphans
@@ -89,10 +99,10 @@ export declare class PersistentDataVolume extends Construct {
89
99
  * Identifier used as the `fjall:OwnerLogicalId` tag on the volume and the
90
100
  * `OWNER_LOGICAL_ID` env on the Lambda. Consumers MUST pass this exact
91
101
  * value to the TERMINATING handler so it locates the same volume for
92
- * detach. Derived from `this.node.path`; renaming or reparenting any
93
- * ancestor changes the value, which breaks the re-attach chain (Lambda
94
- * filter no longer matches, new instance fails its launch hook). Rename
95
- * across an existing deployment is a manual snapshot-restore step.
102
+ * detach. Carries `props.stableOwnerId` verbatim; changing it across an
103
+ * existing deployment breaks the re-attach chain (Lambda filter no longer
104
+ * matches, new instance fails its launch hook) — recovery is a manual
105
+ * snapshot-restore step.
96
106
  */
97
107
  readonly ownerLogicalId: string;
98
108
  readonly attachFailureAlarm: Alarm;
@@ -46,7 +46,7 @@ const ALARM_THRESHOLD = 1;
46
46
  *
47
47
  * Volume tagging: three tags carry the re-attach identity —
48
48
  * `fjall:Lifecycle = data-volume` (constant; gates IAM scoping)
49
- * `fjall:OwnerLogicalId = <this.node.path>` (per-construct, stable across
49
+ * `fjall:OwnerLogicalId = <props.stableOwnerId>` (per-slot, stable across
50
50
  * instance refreshes in one stack so the Lambda re-finds the same volume)
51
51
  * `fjall:StackId = <Aws.STACK_ID>` (per-stack-creation discriminator;
52
52
  * CloudFormation issues a fresh StackId for every CREATE, so orphans
@@ -79,17 +79,17 @@ export class PersistentDataVolume extends Construct {
79
79
  * Identifier used as the `fjall:OwnerLogicalId` tag on the volume and the
80
80
  * `OWNER_LOGICAL_ID` env on the Lambda. Consumers MUST pass this exact
81
81
  * value to the TERMINATING handler so it locates the same volume for
82
- * detach. Derived from `this.node.path`; renaming or reparenting any
83
- * ancestor changes the value, which breaks the re-attach chain (Lambda
84
- * filter no longer matches, new instance fails its launch hook). Rename
85
- * across an existing deployment is a manual snapshot-restore step.
82
+ * detach. Carries `props.stableOwnerId` verbatim; changing it across an
83
+ * existing deployment breaks the re-attach chain (Lambda filter no longer
84
+ * matches, new instance fails its launch hook) — recovery is a manual
85
+ * snapshot-restore step.
86
86
  */
87
87
  ownerLogicalId;
88
88
  attachFailureAlarm;
89
89
  constructor(scope, id, props) {
90
90
  super(scope, id);
91
91
  validatePersistentDataVolumeProps(props);
92
- this.ownerLogicalId = this.node.path;
92
+ this.ownerLogicalId = props.stableOwnerId;
93
93
  this.volume = new Volume(this, "Volume", {
94
94
  availabilityZone: props.availabilityZone,
95
95
  size: Size.gibibytes(props.sizeGb),
@@ -236,6 +236,9 @@ function validatePersistentDataVolumeProps(props) {
236
236
  if (props.deviceName === "") {
237
237
  throw new Error("PersistentDataVolume.deviceName must be non-empty");
238
238
  }
239
+ if (props.stableOwnerId === "") {
240
+ throw new Error("PersistentDataVolume.stableOwnerId must be non-empty");
241
+ }
239
242
  if (props.availabilityZone === "") {
240
243
  throw new Error("PersistentDataVolume.availabilityZone must be non-empty");
241
244
  }
@@ -0,0 +1,20 @@
1
+ import type { Construct } from "constructs";
2
+ import { type CapacityIdentityPin, type CapacityIdentityWire } from "@fjall/util";
3
+ /**
4
+ * Reader for the `fjall:capacityIdentity` CDK context the deploy engine
5
+ * injects (already projected to the current deploy target — see
6
+ * `@fjall/util § capacityIdentity` for the wire contract).
7
+ *
8
+ * Malformed context is a LOUD synth error, never a silent skip: a pin that
9
+ * fails to apply would synth the slot unpinned, and the resulting template
10
+ * renames (replaces) every stateful resource in the slot. The deploy-time
11
+ * rename gate would catch that downstream, but the failure must name the
12
+ * real cause — the malformed pin — not surface as a mystery rename.
13
+ */
14
+ export declare function readCapacityIdentityPins(scope: Construct): CapacityIdentityWire | undefined;
15
+ /**
16
+ * The pin for one `{ appName, slot }`, or undefined when the app/slot is
17
+ * unpinned. `appName` must be the manifest appName — the same value the pin
18
+ * writer records.
19
+ */
20
+ export declare function lookupCapacityIdentityPin(scope: Construct, appName: string, slot: string): CapacityIdentityPin | undefined;
@@ -0,0 +1,43 @@
1
+ import { CAPACITY_IDENTITY_CONTEXT_KEY, CapacityIdentityWireSchema } from "@fjall/util";
2
+ /**
3
+ * Reader for the `fjall:capacityIdentity` CDK context the deploy engine
4
+ * injects (already projected to the current deploy target — see
5
+ * `@fjall/util § capacityIdentity` for the wire contract).
6
+ *
7
+ * Malformed context is a LOUD synth error, never a silent skip: a pin that
8
+ * fails to apply would synth the slot unpinned, and the resulting template
9
+ * renames (replaces) every stateful resource in the slot. The deploy-time
10
+ * rename gate would catch that downstream, but the failure must name the
11
+ * real cause — the malformed pin — not surface as a mystery rename.
12
+ */
13
+ export function readCapacityIdentityPins(scope) {
14
+ const raw = scope.node.tryGetContext(CAPACITY_IDENTITY_CONTEXT_KEY);
15
+ if (raw === undefined)
16
+ return undefined;
17
+ let value = raw;
18
+ if (typeof raw === "string") {
19
+ // `-c key=value` always arrives as a string; tests may set the object form.
20
+ try {
21
+ value = JSON.parse(raw);
22
+ }
23
+ catch (error) {
24
+ throw new Error(`${CAPACITY_IDENTITY_CONTEXT_KEY} context is not valid JSON: ` +
25
+ `${error instanceof Error ? error.message : String(error)}`, { cause: error });
26
+ }
27
+ }
28
+ const parsed = CapacityIdentityWireSchema.safeParse(value);
29
+ if (!parsed.success) {
30
+ throw new Error(`${CAPACITY_IDENTITY_CONTEXT_KEY} context is malformed — refusing to ` +
31
+ `synth with a pin that cannot apply (an unpinned synth would rename ` +
32
+ `every stateful resource in the slot): ${parsed.error.message}`);
33
+ }
34
+ return parsed.data;
35
+ }
36
+ /**
37
+ * The pin for one `{ appName, slot }`, or undefined when the app/slot is
38
+ * unpinned. `appName` must be the manifest appName — the same value the pin
39
+ * writer records.
40
+ */
41
+ export function lookupCapacityIdentityPin(scope, appName, slot) {
42
+ return readCapacityIdentityPins(scope)?.[appName]?.[slot];
43
+ }
@@ -15,8 +15,8 @@
15
15
  */
16
16
  import type { CloudAssembly } from "aws-cdk-lib/cx-api";
17
17
  import { type ResourceCategory } from "@fjall/util/resourceCategorisation";
18
- import { type ManifestService, type ManifestPattern, type ManifestEcr, type ManifestLambda, type ManifestStackHash, type FjallManifest } from "@fjall/util/manifest/schemas";
19
- export type { ManifestService, ManifestPattern, ManifestEcr, ManifestLambda, ManifestStackHash, FjallManifest };
18
+ import { type ManifestService, type ManifestPattern, type ManifestEcr, type ManifestLambda, type ManifestStackHash, type ManifestCapacityAlias, type FjallManifest } from "@fjall/util/manifest/schemas";
19
+ export type { ManifestService, ManifestPattern, ManifestEcr, ManifestLambda, ManifestStackHash, ManifestCapacityAlias, FjallManifest };
20
20
  /**
21
21
  * Collected manifest data during synthesis.
22
22
  * Stores all data that will be written to the manifest file.
@@ -24,6 +24,7 @@ export type { ManifestService, ManifestPattern, ManifestEcr, ManifestLambda, Man
24
24
  export declare class ManifestCollector {
25
25
  private services;
26
26
  private lambdas;
27
+ private capacityIdentityAliases;
27
28
  private pattern?;
28
29
  private ecr?;
29
30
  private appName;
@@ -46,6 +47,18 @@ export declare class ManifestCollector {
46
47
  * Add a Lambda function configuration.
47
48
  */
48
49
  addLambda(lambda: ManifestLambda): void;
50
+ /**
51
+ * Record a capacity-identity alias for one EC2 capacity slot. Slots are
52
+ * app-scoped — pins and rename detection route by `{appName, slot}` — so
53
+ * two clusters in one app declaring the same slot would silently share one
54
+ * identity (the same pin stamped on both ASGs, one cluster's alias
55
+ * dropped). Cross-cluster collision is therefore a hard synth error, per
56
+ * the design's same-slot-drift doctrine (§7 decision 3). A same-cluster
57
+ * duplicate cannot occur (slot re-entry early-returns in
58
+ * `getOrCreateAsgCapacityProvider` before alias emission) and is kept
59
+ * first-wins for safety.
60
+ */
61
+ addCapacityIdentityAlias(alias: ManifestCapacityAlias): void;
49
62
  /**
50
63
  * Set the pattern configuration.
51
64
  */
@@ -62,6 +75,10 @@ export declare class ManifestCollector {
62
75
  * Get collected Lambda functions.
63
76
  */
64
77
  getLambdas(): ManifestLambda[];
78
+ /**
79
+ * Get collected capacity-identity aliases.
80
+ */
81
+ getCapacityIdentityAliases(): ManifestCapacityAlias[];
65
82
  /**
66
83
  * Get pattern configuration.
67
84
  */
@@ -28,6 +28,7 @@ import { FjallLogger } from "./validationLogger.js";
28
28
  export class ManifestCollector {
29
29
  services = [];
30
30
  lambdas = [];
31
+ capacityIdentityAliases = [];
31
32
  pattern;
32
33
  ecr;
33
34
  appName;
@@ -64,6 +65,32 @@ export class ManifestCollector {
64
65
  this.lambdas.push(lambda);
65
66
  }
66
67
  }
68
+ /**
69
+ * Record a capacity-identity alias for one EC2 capacity slot. Slots are
70
+ * app-scoped — pins and rename detection route by `{appName, slot}` — so
71
+ * two clusters in one app declaring the same slot would silently share one
72
+ * identity (the same pin stamped on both ASGs, one cluster's alias
73
+ * dropped). Cross-cluster collision is therefore a hard synth error, per
74
+ * the design's same-slot-drift doctrine (§7 decision 3). A same-cluster
75
+ * duplicate cannot occur (slot re-entry early-returns in
76
+ * `getOrCreateAsgCapacityProvider` before alias emission) and is kept
77
+ * first-wins for safety.
78
+ */
79
+ addCapacityIdentityAlias(alias) {
80
+ const existing = this.capacityIdentityAliases.find((a) => a.appName === alias.appName && a.slot === alias.slot);
81
+ if (existing === undefined) {
82
+ this.capacityIdentityAliases.push(alias);
83
+ return;
84
+ }
85
+ if (existing.clusterName === alias.clusterName)
86
+ return;
87
+ throw new Error(`Capacity slot '${alias.slot}' is declared by two clusters in app ` +
88
+ `'${alias.appName}' ('${existing.clusterName}' and ` +
89
+ `'${alias.clusterName}'). A slot names ONE auto-scaling group per ` +
90
+ `app — capacity-identity pins and rename detection route by ` +
91
+ `{app, slot} and cannot tell the clusters apart. Give one cluster ` +
92
+ `a distinct ec2Config.slot.`);
93
+ }
67
94
  /**
68
95
  * Set the pattern configuration.
69
96
  */
@@ -88,6 +115,12 @@ export class ManifestCollector {
88
115
  getLambdas() {
89
116
  return this.lambdas;
90
117
  }
118
+ /**
119
+ * Get collected capacity-identity aliases.
120
+ */
121
+ getCapacityIdentityAliases() {
122
+ return this.capacityIdentityAliases;
123
+ }
91
124
  /**
92
125
  * Get pattern configuration.
93
126
  */
@@ -177,6 +210,7 @@ export function writeManifest(assembly, collector) {
177
210
  "engineCompat block; the deploy engine will proceed without a " +
178
211
  "version-compatibility gate.");
179
212
  }
213
+ const identityAliases = collector.getCapacityIdentityAliases();
180
214
  const manifest = {
181
215
  version: MANIFEST_SCHEMA_VERSION,
182
216
  generatedAt: new Date().toISOString(),
@@ -185,6 +219,7 @@ export function writeManifest(assembly, collector) {
185
219
  lambdas: collector.getLambdas(),
186
220
  stacks,
187
221
  ...(engineCompat !== undefined ? { engineCompat } : {}),
222
+ ...(identityAliases.length > 0 ? { identityAliases } : {}),
188
223
  ...(constructMap.size > 0
189
224
  ? { resourceMap: constructMapToRecord(constructMap) }
190
225
  : {})
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fjall/components-infrastructure",
3
- "version": "6.0.0",
3
+ "version": "7.0.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/fjall-tech/fjall.git",
@@ -74,8 +74,8 @@
74
74
  },
75
75
  "dependencies": {
76
76
  "@aws-sdk/client-organizations": "^3.1098.0",
77
- "@fjall/generator": "^6.0.0",
78
- "@fjall/util": "^6.0.0",
77
+ "@fjall/generator": "^7.0.0",
78
+ "@fjall/util": "^7.0.0",
79
79
  "constructs": "^10.7.2"
80
80
  },
81
81
  "overrides": {
@@ -88,6 +88,5 @@
88
88
  },
89
89
  "engines": {
90
90
  "node": ">=18.0.0"
91
- },
92
- "gitHead": "7620fc7c93bbef1046580d6568546afe3cd450a7"
91
+ }
93
92
  }
@@ -1,11 +0,0 @@
1
- import { NestedStack, type NestedStackProps } from "aws-cdk-lib";
2
- import { type Construct } from "constructs";
3
- export interface IdentityCenterMembershipProps extends NestedStackProps {
4
- identityStoreId: string;
5
- groupId: string;
6
- groupMembers: string[];
7
- }
8
- export declare function validateIdentityCenterMembershipProps(props: IdentityCenterMembershipProps): void;
9
- export declare class IdentityCenterMembership extends NestedStack {
10
- constructor(scope: Construct, id: string, props: IdentityCenterMembershipProps);
11
- }
@@ -1,61 +0,0 @@
1
- import * as customResources from "aws-cdk-lib/custom-resources";
2
- import { NestedStack } from "aws-cdk-lib";
3
- import { AwsCustomResource } from "../../resources/aws/utilities/awsCustomResource.js";
4
- import { GroupMembership } from "../../resources/aws/iam/identityCenter/groupMembership.js";
5
- import { stripAndCamelCase } from "../../utils/stripAndCamelCase.js";
6
- const IDENTITY_STORE_SERVICE = "identityStore";
7
- export function validateIdentityCenterMembershipProps(props) {
8
- if (!props.identityStoreId) {
9
- throw new Error("IdentityCenterMembership requires identityStoreId from the parent IdentityCenter construct.");
10
- }
11
- if (!props.groupId) {
12
- throw new Error("IdentityCenterMembership requires groupId from a Group construct.");
13
- }
14
- if (props.groupMembers.length === 0) {
15
- throw new Error("IdentityCenterMembership requires at least one member email. Empty groups should be skipped at the caller.");
16
- }
17
- const seen = new Set();
18
- for (const member of props.groupMembers) {
19
- if (!member.includes("@")) {
20
- throw new Error(`IdentityCenterMembership: "${member}" is not a valid email — Identity Center memberships look up users by UserName (email)`);
21
- }
22
- if (seen.has(member)) {
23
- throw new Error(`Duplicate member "${member}" in IdentityCenterMembership groupMembers.`);
24
- }
25
- seen.add(member);
26
- }
27
- }
28
- export class IdentityCenterMembership extends NestedStack {
29
- constructor(scope, id, props) {
30
- super(scope, id, props);
31
- validateIdentityCenterMembershipProps(props);
32
- for (const member of props.groupMembers) {
33
- const [localPart] = member.split("@");
34
- const suffix = stripAndCamelCase(localPart ?? member);
35
- const listUsersCall = {
36
- service: IDENTITY_STORE_SERVICE,
37
- action: "listUsers",
38
- parameters: {
39
- IdentityStoreId: props.identityStoreId,
40
- Filters: [
41
- {
42
- AttributePath: "UserName",
43
- AttributeValue: member
44
- }
45
- ]
46
- },
47
- physicalResourceId: customResources.PhysicalResourceId.of(`listUsers${suffix}`)
48
- };
49
- const userLookup = new AwsCustomResource(this, `User${suffix}`, {
50
- onCreate: listUsersCall,
51
- onUpdate: listUsersCall
52
- });
53
- const userId = userLookup.getResponseField("Users.0.UserId");
54
- new GroupMembership(this, `Membership${suffix}`, {
55
- identityStoreId: props.identityStoreId,
56
- groupId: props.groupId,
57
- userId
58
- });
59
- }
60
- }
61
- }
@@ -1,30 +0,0 @@
1
- #!/bin/bash
2
- # Fjall Secrets Resolver Wrapper
3
- #
4
- # Invoked by AWS_LAMBDA_EXEC_WRAPPER before the Lambda runtime starts.
5
- # Calls the Node.js resolver to fetch secrets from the AWS Parameters and
6
- # Secrets Extension, then execs into the original runtime bootstrap.
7
- #
8
- # The resolver outputs `export KEY='value'` lines which we eval to inject
9
- # secrets as environment variables visible to the handler.
10
-
11
- set -euo pipefail
12
-
13
- # Only run resolver if secrets are configured
14
- if [ -n "${SSM_SECRET_NAMES:-}" ] || env | grep -q '_SECRET_ARN='; then
15
- RESOLVER_OUTPUT=$(/var/lang/bin/node /opt/bin/resolve-secrets.mjs)
16
- if [ -n "$RESOLVER_OUTPUT" ]; then
17
- # Validate each line matches `export NAME='...'` before eval to prevent
18
- # accidental code execution if the resolver ever emits unexpected output
19
- while IFS= read -r line; do
20
- if [[ "$line" =~ ^export\ [a-zA-Z_][a-zA-Z0-9_]*= ]]; then
21
- eval "$line"
22
- else
23
- echo "[fjall-resolver] Unexpected output from resolver: ${line:0:80}" >&2
24
- exit 1
25
- fi
26
- done <<< "$RESOLVER_OUTPUT"
27
- fi
28
- fi
29
-
30
- exec "$@"
@@ -1,212 +0,0 @@
1
- /**
2
- * Fjall Secrets Resolver — runs before Lambda handler via AWS_LAMBDA_EXEC_WRAPPER.
3
- *
4
- * Resolves secrets from two sources using the AWS Parameters and Secrets Extension
5
- * HTTP cache at localhost:2773:
6
- *
7
- * 1. SSM Parameter Store (user-managed secrets via `fjall secrets set`)
8
- * Reads SSM_SECRETS_PATH + SSM_SECRET_NAMES, fetches each parameter,
9
- * exports as environment variables.
10
- *
11
- * 2. Secrets Manager (CDK-managed secrets, e.g. database credentials)
12
- * Scans for *_SECRET_ARN env vars, fetches each secret,
13
- * optionally extracts a JSON field via the matching *_SECRET_FIELD var,
14
- * exports as the prefix (e.g. DATABASE_PASSWORD_SECRET_ARN → DATABASE_PASSWORD).
15
- *
16
- * Outputs `export KEY='value'` lines to stdout. The bash wrapper evals this output
17
- * and then execs into the Lambda runtime.
18
- *
19
- * The Extension may not be ready immediately during INIT phase, so all HTTP
20
- * calls use a retry loop with exponential backoff.
21
- */
22
-
23
- const EXTENSION_PORT = process.env.PARAMETERS_SECRETS_EXTENSION_HTTP_PORT || "2773";
24
- const EXTENSION_URL = `http://localhost:${EXTENSION_PORT}`;
25
- const MAX_RETRIES = 5;
26
- const INITIAL_DELAY_MS = 100;
27
- /** Per-request timeout — generous for localhost; total budget bounded by Lambda INIT timeout */
28
- const REQUEST_TIMEOUT_MS = 2000;
29
-
30
- /**
31
- * Fetch from the Extension HTTP API with retry and exponential backoff.
32
- * The Extension starts during INIT phase 1 (Extension init) and the wrapper
33
- * runs during INIT phase 2 (Runtime init), so it is usually ready — but
34
- * a retry loop handles the timing edge case.
35
- */
36
- async function fetchWithRetry(path) {
37
- let delay = INITIAL_DELAY_MS;
38
- let lastError;
39
-
40
- for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
41
- try {
42
- const response = await fetch(`${EXTENSION_URL}${path}`, {
43
- headers: {
44
- "X-Aws-Parameters-Secrets-Token": process.env.AWS_SESSION_TOKEN,
45
- },
46
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
47
- });
48
- if (response.ok) {
49
- return await response.json();
50
- }
51
- // 4xx errors (e.g. secret not found) — don't retry
52
- if (response.status >= 400 && response.status < 500) {
53
- const body = await response.text().catch(() => "");
54
- const err = new Error(`Extension returned ${response.status}: ${body}`);
55
- err.nonRetriable = true;
56
- throw err;
57
- }
58
- // 5xx — retriable
59
- const body = await response.text().catch(() => "");
60
- lastError = new Error(`Extension returned ${response.status}: ${body}`);
61
- } catch (err) {
62
- if (err.nonRetriable) {
63
- throw err;
64
- }
65
- lastError = err;
66
- }
67
-
68
- // Retry with backoff (both 5xx and network errors)
69
- if (attempt < MAX_RETRIES - 1) {
70
- await new Promise((r) => setTimeout(r, delay));
71
- delay *= 2;
72
- }
73
- }
74
-
75
- throw new Error(
76
- `Failed to reach Extension after ${MAX_RETRIES} attempts: ${lastError?.message}`,
77
- );
78
- }
79
-
80
- /**
81
- * Escape a value for safe inclusion in a shell `export KEY='value'` statement.
82
- * Single-quotes are the safest quoting mechanism — only embedded single-quotes
83
- * need escaping via the close-reopen pattern: ' → '\''
84
- */
85
- function shellEscape(value) {
86
- return "'" + value.replace(/'/g, "'\\''") + "'";
87
- }
88
-
89
- // POSIX env var names: letters, digits, underscores; must not start with a digit
90
- const VALID_ENV_NAME = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
91
-
92
- function assertValidEnvName(name, source) {
93
- if (!VALID_ENV_NAME.test(name)) {
94
- process.stderr.write(
95
- `[fjall-resolver] Invalid env var name '${name}' from ${source}. ` +
96
- `Names must match [a-zA-Z_][a-zA-Z0-9_]* (no dots or hyphens).\n`,
97
- );
98
- process.exit(1);
99
- }
100
- }
101
-
102
- /**
103
- * Resolve SSM Parameter Store secrets.
104
- * Reads SSM_SECRETS_PATH and SSM_SECRET_NAMES, fetches each SecureString parameter.
105
- */
106
- async function resolveSsmSecrets() {
107
- const basePath = process.env.SSM_SECRETS_PATH;
108
- const secretNames = process.env.SSM_SECRET_NAMES;
109
-
110
- if (!basePath || !secretNames) return [];
111
-
112
- const names = secretNames.split(",").filter(Boolean);
113
- const exports = [];
114
-
115
- for (const name of names) {
116
- assertValidEnvName(name, "SSM_SECRET_NAMES");
117
- const paramPath = `${basePath}/${name}`;
118
- const encodedPath = encodeURIComponent(paramPath);
119
-
120
- try {
121
- const data = await fetchWithRetry(
122
- `/systemsmanager/parameters/get?name=${encodedPath}&withDecryption=true`,
123
- );
124
- const value = data?.Parameter?.Value;
125
- if (value !== undefined && value !== null) {
126
- exports.push(`export ${name}=${shellEscape(value)}`);
127
- }
128
- } catch (err) {
129
- const msg = err instanceof Error ? err.message : String(err);
130
- process.stderr.write(
131
- `[fjall-resolver] Failed to resolve SSM parameter ${paramPath}: ${msg}\n`,
132
- );
133
- process.exit(1);
134
- }
135
- }
136
-
137
- return exports;
138
- }
139
-
140
- /**
141
- * Resolve Secrets Manager secrets.
142
- * Scans for *_SECRET_ARN env vars, fetches each secret from SM,
143
- * optionally extracts a JSON field, and exports as the prefix.
144
- */
145
- async function resolveSecretsManagerSecrets() {
146
- const exports = [];
147
-
148
- for (const [key, arn] of Object.entries(process.env)) {
149
- if (!key.endsWith("_SECRET_ARN") || !arn) continue;
150
-
151
- const prefix = key.slice(0, -"_SECRET_ARN".length);
152
- assertValidEnvName(prefix, "Secrets Manager");
153
- const fieldKey = `${prefix}_SECRET_FIELD`;
154
- const field = process.env[fieldKey];
155
-
156
- const encodedArn = encodeURIComponent(arn);
157
-
158
- try {
159
- const data = await fetchWithRetry(
160
- `/secretsmanager/get?secretId=${encodedArn}`,
161
- );
162
-
163
- let value;
164
- if (field && data?.SecretString) {
165
- let parsed;
166
- try {
167
- parsed = JSON.parse(data.SecretString);
168
- } catch {
169
- throw new Error(
170
- `Secret is not valid JSON but field '${field}' was requested. Store the secret as a JSON object.`,
171
- );
172
- }
173
- value = parsed[field];
174
- if (value === undefined) {
175
- throw new Error(
176
- `Field '${field}' not found in secret (${Object.keys(parsed).length} fields present).`,
177
- );
178
- }
179
- } else {
180
- value = data?.SecretString;
181
- }
182
-
183
- if (value !== undefined && value !== null) {
184
- exports.push(`export ${prefix}=${shellEscape(String(value))}`);
185
- }
186
- } catch (err) {
187
- const msg = err instanceof Error ? err.message : String(err);
188
- process.stderr.write(
189
- `[fjall-resolver] Failed to resolve SM secret ${prefix} (${arn}): ${msg}\n`,
190
- );
191
- process.exit(1);
192
- }
193
- }
194
-
195
- return exports;
196
- }
197
-
198
- async function main() {
199
- const ssmExports = await resolveSsmSecrets();
200
- const smExports = await resolveSecretsManagerSecrets();
201
- const allExports = [...ssmExports, ...smExports];
202
-
203
- if (allExports.length > 0) {
204
- process.stdout.write(allExports.join("\n") + "\n");
205
- }
206
- }
207
-
208
- main().catch((err) => {
209
- const msg = err instanceof Error ? err.message : String(err);
210
- process.stderr.write(`[fjall-resolver] Fatal error: ${msg}\n`);
211
- process.exit(1);
212
- });