@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.
- package/dist/lib/patterns/aws/clickhouseDatabase.js +4 -0
- package/dist/lib/resources/aws/compute/ec2.d.ts +31 -14
- package/dist/lib/resources/aws/compute/ec2.js +34 -10
- package/dist/lib/resources/aws/compute/ecsCapacityConfig.d.ts +56 -16
- package/dist/lib/resources/aws/compute/ecsCapacityConfig.js +226 -64
- package/dist/lib/resources/aws/compute/ecsConstants.d.ts +15 -0
- package/dist/lib/resources/aws/compute/ecsConstants.js +15 -0
- package/dist/lib/resources/aws/compute/ecsServiceFactory.d.ts +16 -6
- package/dist/lib/resources/aws/compute/ecsServiceFactory.js +68 -19
- package/dist/lib/resources/aws/compute/ecsTypes.d.ts +33 -4
- package/dist/lib/resources/aws/compute/persistentDataVolume.d.ts +15 -5
- package/dist/lib/resources/aws/compute/persistentDataVolume.js +9 -6
- package/dist/lib/utils/capacityIdentityContext.d.ts +20 -0
- package/dist/lib/utils/capacityIdentityContext.js +43 -0
- package/dist/lib/utils/manifestWriter.d.ts +19 -2
- package/dist/lib/utils/manifestWriter.js +35 -0
- package/package.json +4 -5
- package/dist/lib/config/aws/identityCenterMembership.d.ts +0 -11
- package/dist/lib/config/aws/identityCenterMembership.js +0 -61
- package/dist/lib/layers/layers/secrets-resolver/bin/resolve-secrets +0 -30
- package/dist/lib/layers/layers/secrets-resolver/bin/resolve-secrets.mjs +0 -212
- package/dist/lib/patterns/aws/buildkite/alarms.d.ts +0 -25
- package/dist/lib/patterns/aws/buildkite/alarms.js +0 -78
- package/dist/lib/patterns/aws/domainDelegation.d.ts +0 -8
- package/dist/lib/patterns/aws/domainDelegation.js +0 -45
- package/dist/lib/patterns/aws/domainFactory.d.ts +0 -23
- package/dist/lib/patterns/aws/domainFactory.js +0 -61
- package/dist/lib/utils/addSuffixToEmail.d.ts +0 -1
- 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 —
|
|
155
|
-
*
|
|
156
|
-
*
|
|
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 = <
|
|
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.
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
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 = <
|
|
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.
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
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 =
|
|
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": "
|
|
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": "^
|
|
78
|
-
"@fjall/util": "^
|
|
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
|
-
});
|