@fjall/components-infrastructure 5.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/config/aws/ebsDefaultEncryption.js +1 -1
- package/dist/lib/config/aws/inspectorEnablement.js +1 -1
- package/dist/lib/config/aws/oidcConnector.js +1 -1
- package/dist/lib/config/aws/s3BlockPublicAccess.js +1 -1
- package/dist/lib/config/aws/securityServicesAdmin.js +2 -2
- package/dist/lib/lambda-assets/cert-generator/asset/index.js +31 -31
- package/dist/lib/patterns/aws/clickhouseDatabase.js +4 -0
- package/dist/lib/patterns/aws/compute.js +1 -1
- package/dist/lib/patterns/aws/computeEcs.js +5 -0
- package/dist/lib/patterns/aws/computeLambda.d.ts +1 -1
- package/dist/lib/patterns/aws/devSubstrate.js +1 -1
- package/dist/lib/patterns/aws/payload.js +3 -3
- package/dist/lib/patterns/aws/staticSite.js +1 -1
- 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/ec2GracefulTerminationHandler.js +1 -1
- package/dist/lib/resources/aws/compute/ecsCapacityConfig.d.ts +83 -0
- package/dist/lib/resources/aws/compute/ecsCapacityConfig.js +323 -0
- package/dist/lib/resources/aws/compute/ecsConstants.d.ts +19 -0
- package/dist/lib/resources/aws/compute/ecsConstants.js +24 -0
- package/dist/lib/resources/aws/compute/ecsLifecycleHookMigration.js +1 -1
- package/dist/lib/resources/aws/compute/ecsServiceFactory.d.ts +20 -16
- package/dist/lib/resources/aws/compute/ecsServiceFactory.js +73 -118
- package/dist/lib/resources/aws/compute/ecsTypes.d.ts +33 -4
- package/dist/lib/resources/aws/compute/ecsValidation.js +6 -0
- package/dist/lib/resources/aws/compute/persistentDataVolume.d.ts +15 -5
- package/dist/lib/resources/aws/compute/persistentDataVolume.js +10 -7
- package/dist/lib/resources/aws/database/rdsInstance.js +1 -1
- package/dist/lib/resources/aws/iam/identityCenter/user.js +1 -1
- package/dist/lib/resources/aws/networking/crossAccountReturnRoutes.js +1 -1
- package/dist/lib/resources/aws/networking/ipamPool.js +1 -1
- package/dist/lib/resources/aws/organisation/costAllocationTagActivator.js +1 -1
- package/dist/lib/resources/aws/utilities/tlsCertGenerator.js +1 -1
- package/dist/lib/utils/capacityIdentityContext.d.ts +20 -0
- package/dist/lib/utils/capacityIdentityContext.js +43 -0
- package/dist/lib/utils/engineCompat.d.ts +12 -5
- package/dist/lib/utils/engineCompat.js +41 -22
- package/dist/lib/utils/manifestWriter.d.ts +19 -2
- package/dist/lib/utils/manifestWriter.js +35 -0
- package/package.json +10 -6
- 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
|
@@ -56,29 +56,39 @@ import { FjallLogger } from "./validationLogger.js";
|
|
|
56
56
|
const PACKAGE_NAME = "@fjall/components-infrastructure";
|
|
57
57
|
/**
|
|
58
58
|
* Normalise an npm dependency RANGE to its minimum X.Y.Z floor: strip a leading
|
|
59
|
-
* range operator (`^`, `~`, `>=`,
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
59
|
+
* inclusive range operator (`^`, `~`, `>=`, `=`, `v`), then reconstruct
|
|
60
|
+
* `${major}.${minor}.${patch}` (missing minor/patch default to 0).
|
|
61
|
+
*
|
|
62
|
+
* Deliberately conservative — returns undefined (→ the caller omits the field,
|
|
63
|
+
* FM1 degrades to no-op) for every shape whose true floor the first-token strip
|
|
64
|
+
* would MIS-state rather than merely fail to state:
|
|
65
|
+
* - unions (`^3 || ^2` floors at 2.0.0, not the first branch's 3.0.0)
|
|
66
|
+
* - strict lower bounds (`>2.1134.0` excludes its own literal)
|
|
67
|
+
* - prerelease bounds (`^2.0.0-rc.1` floors below the stripped 2.0.0)
|
|
68
|
+
* - non-decimal segments (`1e2`, hex, `2.x`, `*`, git URLs, 4+ segments)
|
|
69
|
+
* A wrong floor is worse than no floor: over-strict refuses a valid engine,
|
|
70
|
+
* under-strict admits an incompatible one — both silently.
|
|
64
71
|
*/
|
|
65
72
|
export function rangeFloor(range) {
|
|
66
73
|
if (range === undefined || range.length === 0)
|
|
67
74
|
return undefined;
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
75
|
+
const trimmed = range.trim();
|
|
76
|
+
if (trimmed.includes("||"))
|
|
77
|
+
return undefined;
|
|
78
|
+
const firstToken = trimmed.split(/\s+/)[0] ?? "";
|
|
79
|
+
if (/^>(?!=)/.test(firstToken))
|
|
80
|
+
return undefined;
|
|
81
|
+
const core = firstToken.replace(/^[\^~>=v ]+/i, "");
|
|
82
|
+
if (/[-+]/.test(core))
|
|
71
83
|
return undefined;
|
|
72
84
|
const parts = core.split(".");
|
|
85
|
+
if (parts.length === 0 || parts.length > 3)
|
|
86
|
+
return undefined;
|
|
87
|
+
if (!parts.every((segment) => /^\d+$/.test(segment)))
|
|
88
|
+
return undefined;
|
|
73
89
|
const major = Number(parts[0]);
|
|
74
90
|
const minor = parts.length > 1 ? Number(parts[1]) : 0;
|
|
75
91
|
const patch = parts.length > 2 ? Number(parts[2]) : 0;
|
|
76
|
-
if (!Number.isInteger(major) || major < 0)
|
|
77
|
-
return undefined;
|
|
78
|
-
if (!Number.isInteger(minor) || minor < 0)
|
|
79
|
-
return undefined;
|
|
80
|
-
if (!Number.isInteger(patch) || patch < 0)
|
|
81
|
-
return undefined;
|
|
82
92
|
return `${major}.${minor}.${patch}`;
|
|
83
93
|
}
|
|
84
94
|
/**
|
|
@@ -100,12 +110,20 @@ function resolveConstructsPackageMeta() {
|
|
|
100
110
|
typeof pkg.version === "string" &&
|
|
101
111
|
pkg.version.length > 0) {
|
|
102
112
|
const awsCdkPeer = pkg.peerDependencies?.["aws-cdk"];
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
113
|
+
const awsCdkCliFloor = typeof awsCdkPeer === "string" ? rangeFloor(awsCdkPeer) : undefined;
|
|
114
|
+
if (awsCdkCliFloor === undefined) {
|
|
115
|
+
// The FM1 omission is fail-safe but must never be silent — an
|
|
116
|
+
// assembly without minimumAwsCdkCli skips the deploy engine's
|
|
117
|
+
// CLI-floor check entirely.
|
|
118
|
+
FjallLogger.warn(typeof awsCdkPeer === "string"
|
|
119
|
+
? `engineCompat: aws-cdk peer range "${awsCdkPeer}" has no ` +
|
|
120
|
+
`derivable minimum floor — minimumAwsCdkCli will be ` +
|
|
121
|
+
`omitted and the deploy engine's CLI-floor check skipped`
|
|
122
|
+
: "engineCompat: no aws-cdk peerDependency found — " +
|
|
123
|
+
"minimumAwsCdkCli will be omitted and the deploy " +
|
|
124
|
+
"engine's CLI-floor check skipped");
|
|
125
|
+
}
|
|
126
|
+
return { version: pkg.version, awsCdkCliFloor };
|
|
109
127
|
}
|
|
110
128
|
}
|
|
111
129
|
if (dir === root)
|
|
@@ -150,9 +168,10 @@ export const AWS_CDK_CLI_FLOOR = CONSTRUCTS_PACKAGE_META?.awsCdkCliFloor;
|
|
|
150
168
|
export function buildEngineCompatFrom(version, awsCdkCliFloor) {
|
|
151
169
|
if (version === undefined || version.length === 0)
|
|
152
170
|
return undefined;
|
|
153
|
-
const
|
|
154
|
-
if (
|
|
171
|
+
const majorToken = version.split(".")[0] ?? "";
|
|
172
|
+
if (!/^\d+$/.test(majorToken))
|
|
155
173
|
return undefined;
|
|
174
|
+
const major = Number(majorToken);
|
|
156
175
|
return {
|
|
157
176
|
synthesisedBy: version,
|
|
158
177
|
minimumEngineVersion: `${major}.0.0`,
|
|
@@ -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,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fjall/components-infrastructure",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "7.0.0",
|
|
4
|
+
"repository": {
|
|
5
|
+
"type": "git",
|
|
6
|
+
"url": "git+https://github.com/fjall-tech/fjall.git",
|
|
7
|
+
"directory": "components/infrastructure"
|
|
8
|
+
},
|
|
4
9
|
"license": "SEE LICENSE IN LICENSE",
|
|
5
10
|
"type": "module",
|
|
6
11
|
"bin": {
|
|
@@ -69,8 +74,8 @@
|
|
|
69
74
|
},
|
|
70
75
|
"dependencies": {
|
|
71
76
|
"@aws-sdk/client-organizations": "^3.1098.0",
|
|
72
|
-
"@fjall/generator": "^
|
|
73
|
-
"@fjall/util": "^
|
|
77
|
+
"@fjall/generator": "^7.0.0",
|
|
78
|
+
"@fjall/util": "^7.0.0",
|
|
74
79
|
"constructs": "^10.7.2"
|
|
75
80
|
},
|
|
76
81
|
"overrides": {
|
|
@@ -78,11 +83,10 @@
|
|
|
78
83
|
},
|
|
79
84
|
"peerDependencies": {
|
|
80
85
|
"aws-cdk": "^2.1134.0",
|
|
81
|
-
"aws-cdk-lib": "^2.
|
|
86
|
+
"aws-cdk-lib": "^2.263.0",
|
|
82
87
|
"constructs": "^10.7.2"
|
|
83
88
|
},
|
|
84
89
|
"engines": {
|
|
85
90
|
"node": ">=18.0.0"
|
|
86
|
-
}
|
|
87
|
-
"gitHead": "78dded6f582368076e8b5f351399219e5a954659"
|
|
91
|
+
}
|
|
88
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
|
-
});
|
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
import type { Construct } from "constructs";
|
|
2
|
-
export interface BuildkiteAlarmParams {
|
|
3
|
-
readonly buildkiteOrgSlug: string;
|
|
4
|
-
readonly buildkiteQueue: string;
|
|
5
|
-
readonly autoScalingGroupName: string;
|
|
6
|
-
/**
|
|
7
|
-
* Alarm-action destination, in either `resolveAlertsTopic` string shape:
|
|
8
|
-
* a literal `arn:...` or `"import:<ExportName>"` (e.g.
|
|
9
|
-
* `"import:SharedAlarmTopicArn"`). Omitted → alarms exist but page nobody.
|
|
10
|
-
*/
|
|
11
|
-
readonly alarmSnsTopicArn?: string;
|
|
12
|
-
}
|
|
13
|
-
/**
|
|
14
|
-
* The fleet's two Phase-1 alarms (design § D14) — the "~zero babysitting"
|
|
15
|
-
* posture is honest only with these:
|
|
16
|
-
*
|
|
17
|
-
* 1. Scaler heartbeat — `ScheduledJobsCount` goes MISSING for 15 minutes.
|
|
18
|
-
* The scaler publishes every poll, so metric absence means the scaler
|
|
19
|
-
* Lambda is dead or failing; `treatMissingData: BREACHING` is the alarm's
|
|
20
|
-
* entire mechanism.
|
|
21
|
-
* 2. Queued with zero capacity — jobs scheduled while the ASG has no
|
|
22
|
-
* in-service instances for 15 minutes: the fleet cannot boot (AMI gone,
|
|
23
|
-
* quota, subnet failure) while work is waiting.
|
|
24
|
-
*/
|
|
25
|
-
export declare function addBuildkiteAlarms(scope: Construct, params: BuildkiteAlarmParams): void;
|
|
@@ -1,78 +0,0 @@
|
|
|
1
|
-
import { Duration } from "aws-cdk-lib";
|
|
2
|
-
import { Alarm, ComparisonOperator, MathExpression, Metric, TreatMissingData } from "aws-cdk-lib/aws-cloudwatch";
|
|
3
|
-
import { SnsAction } from "aws-cdk-lib/aws-cloudwatch-actions";
|
|
4
|
-
import { resolveAlertsTopic } from "../../../utils/resolveAlertsTopic.js";
|
|
5
|
-
/**
|
|
6
|
-
* Metrics namespace the buildkite-agent-scaler publishes to, dimensioned by
|
|
7
|
-
* {Org, Queue} — BOTH dimensions are required; querying Queue alone reads no
|
|
8
|
-
* data (CloudWatch dimension matching is exact-set), leaving the heartbeat
|
|
9
|
-
* permanently ALARM and the queued alarm permanently inert. Phase-1b rollout
|
|
10
|
-
* gate: verify both metrics carry data after the first build before trusting
|
|
11
|
-
* the alarms (design § D13/D14) — a namespace/dimension mismatch here is
|
|
12
|
-
* invisible to the synth tests.
|
|
13
|
-
*/
|
|
14
|
-
const SCALER_METRICS_NAMESPACE = "Buildkite";
|
|
15
|
-
/**
|
|
16
|
-
* The fleet's two Phase-1 alarms (design § D14) — the "~zero babysitting"
|
|
17
|
-
* posture is honest only with these:
|
|
18
|
-
*
|
|
19
|
-
* 1. Scaler heartbeat — `ScheduledJobsCount` goes MISSING for 15 minutes.
|
|
20
|
-
* The scaler publishes every poll, so metric absence means the scaler
|
|
21
|
-
* Lambda is dead or failing; `treatMissingData: BREACHING` is the alarm's
|
|
22
|
-
* entire mechanism.
|
|
23
|
-
* 2. Queued with zero capacity — jobs scheduled while the ASG has no
|
|
24
|
-
* in-service instances for 15 minutes: the fleet cannot boot (AMI gone,
|
|
25
|
-
* quota, subnet failure) while work is waiting.
|
|
26
|
-
*/
|
|
27
|
-
export function addBuildkiteAlarms(scope, params) {
|
|
28
|
-
const scheduledJobs = new Metric({
|
|
29
|
-
namespace: SCALER_METRICS_NAMESPACE,
|
|
30
|
-
metricName: "ScheduledJobsCount",
|
|
31
|
-
dimensionsMap: {
|
|
32
|
-
Org: params.buildkiteOrgSlug,
|
|
33
|
-
Queue: params.buildkiteQueue
|
|
34
|
-
},
|
|
35
|
-
statistic: "Maximum",
|
|
36
|
-
period: Duration.minutes(5)
|
|
37
|
-
});
|
|
38
|
-
const heartbeatAlarm = new Alarm(scope, "ScalerHeartbeatAlarm", {
|
|
39
|
-
alarmDescription: `Buildkite scaler for queue '${params.buildkiteQueue}' has stopped ` +
|
|
40
|
-
"publishing metrics — scaler Lambda dead or erroring. Jobs will queue " +
|
|
41
|
-
"with no scale-out.",
|
|
42
|
-
metric: scheduledJobs,
|
|
43
|
-
comparisonOperator: ComparisonOperator.LESS_THAN_THRESHOLD,
|
|
44
|
-
threshold: 0,
|
|
45
|
-
evaluationPeriods: 3,
|
|
46
|
-
treatMissingData: TreatMissingData.BREACHING
|
|
47
|
-
});
|
|
48
|
-
const inServiceInstances = new Metric({
|
|
49
|
-
namespace: "AWS/AutoScaling",
|
|
50
|
-
metricName: "GroupInServiceInstances",
|
|
51
|
-
dimensionsMap: { AutoScalingGroupName: params.autoScalingGroupName },
|
|
52
|
-
statistic: "Maximum",
|
|
53
|
-
period: Duration.minutes(5)
|
|
54
|
-
});
|
|
55
|
-
const queuedWithZeroCapacity = new MathExpression({
|
|
56
|
-
expression: "IF(scheduled > 0 AND inService == 0, 1, 0)",
|
|
57
|
-
usingMetrics: {
|
|
58
|
-
scheduled: scheduledJobs,
|
|
59
|
-
inService: inServiceInstances
|
|
60
|
-
},
|
|
61
|
-
period: Duration.minutes(5)
|
|
62
|
-
});
|
|
63
|
-
const queuedAlarm = new Alarm(scope, "QueuedWithZeroCapacityAlarm", {
|
|
64
|
-
alarmDescription: `Buildkite queue '${params.buildkiteQueue}' has scheduled jobs but ` +
|
|
65
|
-
"zero in-service agents for 15 minutes — the fleet cannot boot " +
|
|
66
|
-
"(AMI, quota, or subnet failure) while work waits.",
|
|
67
|
-
metric: queuedWithZeroCapacity,
|
|
68
|
-
comparisonOperator: ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD,
|
|
69
|
-
threshold: 1,
|
|
70
|
-
evaluationPeriods: 3,
|
|
71
|
-
treatMissingData: TreatMissingData.NOT_BREACHING
|
|
72
|
-
});
|
|
73
|
-
const topic = resolveAlertsTopic(scope, "BuildkiteAlarmTopic", params.alarmSnsTopicArn);
|
|
74
|
-
if (topic !== undefined) {
|
|
75
|
-
heartbeatAlarm.addAlarmAction(new SnsAction(topic));
|
|
76
|
-
queuedAlarm.addAlarmAction(new SnsAction(topic));
|
|
77
|
-
}
|
|
78
|
-
}
|