@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.
Files changed (52) hide show
  1. package/dist/lib/config/aws/ebsDefaultEncryption.js +1 -1
  2. package/dist/lib/config/aws/inspectorEnablement.js +1 -1
  3. package/dist/lib/config/aws/oidcConnector.js +1 -1
  4. package/dist/lib/config/aws/s3BlockPublicAccess.js +1 -1
  5. package/dist/lib/config/aws/securityServicesAdmin.js +2 -2
  6. package/dist/lib/lambda-assets/cert-generator/asset/index.js +31 -31
  7. package/dist/lib/patterns/aws/clickhouseDatabase.js +4 -0
  8. package/dist/lib/patterns/aws/compute.js +1 -1
  9. package/dist/lib/patterns/aws/computeEcs.js +5 -0
  10. package/dist/lib/patterns/aws/computeLambda.d.ts +1 -1
  11. package/dist/lib/patterns/aws/devSubstrate.js +1 -1
  12. package/dist/lib/patterns/aws/payload.js +3 -3
  13. package/dist/lib/patterns/aws/staticSite.js +1 -1
  14. package/dist/lib/resources/aws/compute/ec2.d.ts +31 -14
  15. package/dist/lib/resources/aws/compute/ec2.js +34 -10
  16. package/dist/lib/resources/aws/compute/ec2GracefulTerminationHandler.js +1 -1
  17. package/dist/lib/resources/aws/compute/ecsCapacityConfig.d.ts +83 -0
  18. package/dist/lib/resources/aws/compute/ecsCapacityConfig.js +323 -0
  19. package/dist/lib/resources/aws/compute/ecsConstants.d.ts +19 -0
  20. package/dist/lib/resources/aws/compute/ecsConstants.js +24 -0
  21. package/dist/lib/resources/aws/compute/ecsLifecycleHookMigration.js +1 -1
  22. package/dist/lib/resources/aws/compute/ecsServiceFactory.d.ts +20 -16
  23. package/dist/lib/resources/aws/compute/ecsServiceFactory.js +73 -118
  24. package/dist/lib/resources/aws/compute/ecsTypes.d.ts +33 -4
  25. package/dist/lib/resources/aws/compute/ecsValidation.js +6 -0
  26. package/dist/lib/resources/aws/compute/persistentDataVolume.d.ts +15 -5
  27. package/dist/lib/resources/aws/compute/persistentDataVolume.js +10 -7
  28. package/dist/lib/resources/aws/database/rdsInstance.js +1 -1
  29. package/dist/lib/resources/aws/iam/identityCenter/user.js +1 -1
  30. package/dist/lib/resources/aws/networking/crossAccountReturnRoutes.js +1 -1
  31. package/dist/lib/resources/aws/networking/ipamPool.js +1 -1
  32. package/dist/lib/resources/aws/organisation/costAllocationTagActivator.js +1 -1
  33. package/dist/lib/resources/aws/utilities/tlsCertGenerator.js +1 -1
  34. package/dist/lib/utils/capacityIdentityContext.d.ts +20 -0
  35. package/dist/lib/utils/capacityIdentityContext.js +43 -0
  36. package/dist/lib/utils/engineCompat.d.ts +12 -5
  37. package/dist/lib/utils/engineCompat.js +41 -22
  38. package/dist/lib/utils/manifestWriter.d.ts +19 -2
  39. package/dist/lib/utils/manifestWriter.js +35 -0
  40. package/package.json +10 -6
  41. package/dist/lib/config/aws/identityCenterMembership.d.ts +0 -11
  42. package/dist/lib/config/aws/identityCenterMembership.js +0 -61
  43. package/dist/lib/layers/layers/secrets-resolver/bin/resolve-secrets +0 -30
  44. package/dist/lib/layers/layers/secrets-resolver/bin/resolve-secrets.mjs +0 -212
  45. package/dist/lib/patterns/aws/buildkite/alarms.d.ts +0 -25
  46. package/dist/lib/patterns/aws/buildkite/alarms.js +0 -78
  47. package/dist/lib/patterns/aws/domainDelegation.d.ts +0 -8
  48. package/dist/lib/patterns/aws/domainDelegation.js +0 -45
  49. package/dist/lib/patterns/aws/domainFactory.d.ts +0 -23
  50. package/dist/lib/patterns/aws/domainFactory.js +0 -61
  51. package/dist/lib/utils/addSuffixToEmail.d.ts +0 -1
  52. 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 (`^`, `~`, `>=`, `>`, `=`, `v`) and any prerelease/build
60
- * metadata, then reconstruct `${major}.${minor}.${patch}` (missing minor/patch
61
- * default to 0). Returns undefined for anything without a valid integer major —
62
- * so a non-semver range (a git URL, `*`) yields no floor and the caller omits
63
- * the field rather than emitting garbage.
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 firstToken = range.trim().split(/\s+/)[0] ?? "";
69
- const core = firstToken.replace(/^[\^~>=v ]+/i, "").split(/[-+]/)[0] ?? "";
70
- if (core.length === 0)
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
- return {
104
- version: pkg.version,
105
- awsCdkCliFloor: typeof awsCdkPeer === "string"
106
- ? rangeFloor(awsCdkPeer)
107
- : undefined
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 major = Number(version.split(".")[0]);
154
- if (!Number.isInteger(major) || major < 0)
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": "5.0.0",
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": "^5.0.0",
73
- "@fjall/util": "^5.0.0",
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.262.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
- }