@fjall/components-infrastructure 2.5.0 → 2.7.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.
@@ -55,13 +55,20 @@ export class Account extends Stack {
55
55
  }
56
56
  const fjallOrgId = this.node.tryGetContext("fjallOrgId");
57
57
  const oidcAlreadyConfigured = this.node.tryGetContext("fjallOidcConfigured") === "true";
58
- if (isStandaloneAccount && fjallOrgId && !oidcAlreadyConfigured) {
58
+ // True for non-home-region cascade stacks: the org-global IAM resources
59
+ // (OIDC provider/role, FjallMonitoring, FjallAudit) have fixed names that
60
+ // collide across regions, so they are created only in the home region.
61
+ const accountGlobalsConfigured = this.node.tryGetContext("fjallAccountGlobalsConfigured") === "true";
62
+ if (isStandaloneAccount &&
63
+ fjallOrgId &&
64
+ !oidcAlreadyConfigured &&
65
+ !accountGlobalsConfigured) {
59
66
  new OidcConnector(this, "OidcConnector", { fjallOrgId });
60
67
  }
61
- // Per-account monitoring role (unconditional; ExternalId added when orgId known)
62
- new AccountMonitoringRole(this, "MonitoringRole", fjallOrgId ? { fjallOrgId } : undefined);
63
- // Per-account audit role (conditional on fjallOrgId)
64
- if (fjallOrgId) {
68
+ if (!accountGlobalsConfigured) {
69
+ new AccountMonitoringRole(this, "MonitoringRole", fjallOrgId ? { fjallOrgId } : undefined);
70
+ }
71
+ if (fjallOrgId && !accountGlobalsConfigured) {
65
72
  new AccountAuditRole(this, "AuditRole", { fjallOrgId });
66
73
  }
67
74
  new ManagementEventsTrail(this, "CloudTrail", {
@@ -6,6 +6,7 @@ import { Effect, PolicyStatement } from "aws-cdk-lib/aws-iam";
6
6
  import { ResourceShare } from "../utilities/resourceShare.js";
7
7
  import { CustomResource } from "../utilities/customResource.js";
8
8
  import getAccountId from "../../../utils/getAccountId.js";
9
+ import { accountConstructKey, findAccountNameCollision } from "../../../utils/capitaliseString.js";
9
10
  import { FjallLogger } from "../../../utils/validationLogger.js";
10
11
  const IPAM_TAGS = {
11
12
  OPERATIONS_POOL: "fjall:operations:pool",
@@ -21,6 +22,19 @@ export class IpamPool extends Construct {
21
22
  if (!regions || regions.length === 0) {
22
23
  throw new Error("At least one region must be specified for IPAM pools");
23
24
  }
25
+ // Reject name collisions here — the single convergence point all callers
26
+ // reach (Platform's props.accounts, IpamPoolStack, direct instantiation).
27
+ // Two names with the same construct key mint duplicate construct IDs and
28
+ // crash synth with a cryptic CDK message; an empty key has no construct
29
+ // identity. Scan the original (pre-lowercase) names so the message keeps
30
+ // the user's casing — findAccountNameCollision normalises internally.
31
+ const collision = findAccountNameCollision(props.orgAccounts);
32
+ if (collision) {
33
+ if (collision.kind === "empty-construct-key") {
34
+ throw new Error(`IpamPool: org account name "${collision.name}" normalises to an empty construct key — account names must contain at least one alphanumeric character`);
35
+ }
36
+ throw new Error(`IpamPool: org account names "${collision.existingName}" and "${collision.name}" collide on construct key "${collision.constructKey}" — account names must be unique ignoring case and separators`);
37
+ }
24
38
  const organisationAccounts = props.orgAccounts.map((account) => account.toLowerCase());
25
39
  // Determine the child-pool CIDR size (defaults to /20)
26
40
  const defaultNetmaskLength = props.allocationDefaultNetmaskLength ?? 20;
@@ -107,7 +121,10 @@ export class IpamPool extends Construct {
107
121
  let previousAccountCidr;
108
122
  for (const account of organisationAccounts) {
109
123
  const accountId = getAccountId(account);
110
- const ipamPool = new IpamPoolClass(this, `${account}IpamPool${regionSuffix}`, {
124
+ // Construct IDs only: strips separators so case/format variants can't mint
125
+ // distinct constructs. `account` still feeds tags, descriptions, physical names.
126
+ const constructKey = accountConstructKey(account);
127
+ const ipamPool = new IpamPoolClass(this, `${constructKey}IpamPool${regionSuffix}`, {
111
128
  description: `${account} - IPAM pool - ${region}`,
112
129
  addressFamily: "ipv4",
113
130
  ipamScopeId,
@@ -138,14 +155,14 @@ export class IpamPool extends Construct {
138
155
  if (previousAccountCidr) {
139
156
  ipamPool.addDependency(previousAccountCidr);
140
157
  }
141
- const cidrAllocation = new CfnIPAMPoolCidr(this, `${account}PoolCidr${provisionedNetmaskLength}${regionSuffix}`, {
158
+ const cidrAllocation = new CfnIPAMPoolCidr(this, `${constructKey}PoolCidr${provisionedNetmaskLength}${regionSuffix}`, {
142
159
  ipamPoolId: ipamPool.attrIpamPoolId,
143
160
  netmaskLength: provisionedNetmaskLength
144
161
  });
145
162
  previousAccountCidr = cidrAllocation;
146
163
  // On stack deletion, VPC allocations (not managed by CloudFormation)
147
164
  // block CIDR deprovisioning. This custom resource releases them first.
148
- const allocationCleanup = new CustomResource(this, `${account}IpamCleanup${regionSuffix}`, {
165
+ const allocationCleanup = new CustomResource(this, `${constructKey}IpamCleanup${regionSuffix}`, {
149
166
  runtime: Runtime.NODEJS_22_X,
150
167
  timeout: Duration.minutes(5),
151
168
  lambdaDescription: `${account} IPAM pool allocation cleanup - ${region}`,
@@ -204,7 +221,7 @@ exports.handler = async (event) => {
204
221
  });
205
222
  allocationCleanup.node.addDependency(cidrAllocation);
206
223
  // Share the pool with the target account so that VPCs can allocate
207
- const share = new ResourceShare(this, `${account}IpamResourceShare${regionSuffix}`, {
224
+ const share = new ResourceShare(this, `${constructKey}IpamResourceShare${regionSuffix}`, {
208
225
  name: `${account}IpamResourceShare.${region}`,
209
226
  allowExternalPrincipals: false,
210
227
  principals: [accountId],
@@ -215,13 +215,18 @@ export class Vpc extends ec2.Vpc {
215
215
  }
216
216
  }
217
217
  static ipAddresses(scope, id, props) {
218
- if (!props?.accountId || !props?.ipv4IpamPoolId) {
218
+ // A non-empty pool id is the SOLE signal of IPAM intent: do NOT gate on
219
+ // accountId (awsIpamAllocation never consumes it), or an IPAM-intended VPC
220
+ // whose ambient accountId failed to resolve silently takes CDK's default
221
+ // 10.0.0.0/16 and collides with its org siblings. No pool id = standalone.
222
+ const poolId = props?.ipv4IpamPoolId;
223
+ if (poolId === undefined || poolId === "") {
219
224
  return undefined;
220
225
  }
221
- const vpcCidrMask = props.vpcCidrMask ?? 20;
222
- const subnetCidrMask = props.subnetCidrMask ?? 23;
226
+ const vpcCidrMask = props?.vpcCidrMask ?? 20;
227
+ const subnetCidrMask = props?.subnetCidrMask ?? 23;
223
228
  return ec2.IpAddresses.awsIpamAllocation({
224
- ipv4IpamPoolId: props.ipv4IpamPoolId,
229
+ ipv4IpamPoolId: poolId,
225
230
  ipv4NetmaskLength: vpcCidrMask,
226
231
  defaultSubnetIpv4NetmaskLength: subnetCidrMask
227
232
  });
@@ -1 +1 @@
1
- export { capitalise as capitaliseString, toPascalCase, toKebab, toValidDatabaseName, toScreamingSnake, getSafeZoneName } from "@fjall/util";
1
+ export { capitalise as capitaliseString, toPascalCase, toKebab, toValidDatabaseName, toScreamingSnake, getSafeZoneName, accountConstructKey, findAccountNameCollision, type AccountNameCollision } from "@fjall/util";
@@ -1 +1 @@
1
- export { capitalise as capitaliseString, toPascalCase, toKebab, toValidDatabaseName, toScreamingSnake, getSafeZoneName } from "@fjall/util";
1
+ export { capitalise as capitaliseString, toPascalCase, toKebab, toValidDatabaseName, toScreamingSnake, getSafeZoneName, accountConstructKey, findAccountNameCollision } from "@fjall/util";
@@ -1 +1,15 @@
1
- export default function getAccountId(environment: string): string;
1
+ /**
2
+ * Resolve an account name to its AWS account ID via the org config's
3
+ * name→id map. Matched via `accountConstructKey` (case-insensitive, strips
4
+ * separators too), because provider account names arrive verbatim from AWS
5
+ * Organizations (TitleCase) through reconciliation while callers (e.g. IpamPool)
6
+ * normalise them for construct-ID safety. Matching case-sensitively here would
7
+ * miss every account and silently fall back to the ambient context accountId —
8
+ * producing colliding `IpamPoolId<accountId>` outputs. Collisions across config
9
+ * names are rejected upstream in getConfig, so the normalised key is unambiguous.
10
+ *
11
+ * Throws on no-match rather than returning a sentinel: an unresolvable account
12
+ * named in the org config is a configuration error, and a sentinel id would
13
+ * itself collide across multiple unresolved names.
14
+ */
15
+ export default function getAccountId(accountName: string): string;
@@ -1,9 +1,26 @@
1
1
  import { getConfig } from "./getConfig.js";
2
- export default function getAccountId(environment) {
3
- //TODO: in the use cases of getAccountId, we are currently passing in accountName
4
- const config = getConfig(environment);
5
- // TODO: need to fix how we handle this in the VPC module
6
- if (!config || !config.accountId)
7
- return "error";
8
- return config.accountId;
2
+ import { accountConstructKey } from "./capitaliseString.js";
3
+ /**
4
+ * Resolve an account name to its AWS account ID via the org config's
5
+ * name→id map. Matched via `accountConstructKey` (case-insensitive, strips
6
+ * separators too), because provider account names arrive verbatim from AWS
7
+ * Organizations (TitleCase) through reconciliation while callers (e.g. IpamPool)
8
+ * normalise them for construct-ID safety. Matching case-sensitively here would
9
+ * miss every account and silently fall back to the ambient context accountId —
10
+ * producing colliding `IpamPoolId<accountId>` outputs. Collisions across config
11
+ * names are rejected upstream in getConfig, so the normalised key is unambiguous.
12
+ *
13
+ * Throws on no-match rather than returning a sentinel: an unresolvable account
14
+ * named in the org config is a configuration error, and a sentinel id would
15
+ * itself collide across multiple unresolved names.
16
+ */
17
+ export default function getAccountId(accountName) {
18
+ const config = getConfig();
19
+ const target = accountConstructKey(accountName);
20
+ for (const [name, id] of Object.entries(config.accountIds ?? {})) {
21
+ if (accountConstructKey(name) === target)
22
+ return id;
23
+ }
24
+ const known = Object.keys(config.accountIds ?? {}).join(", ") || "(none)";
25
+ throw new Error(`getAccountId: no provider account named "${accountName}" in org config — known accounts: ${known}`);
9
26
  }
@@ -1,5 +1,6 @@
1
1
  import App from "../app.js";
2
2
  import { parseOrgConfig } from "./orgConfigParser.js";
3
+ import { findAccountNameCollision } from "./capitaliseString.js";
3
4
  import { FjallLogger } from "./validationLogger.js";
4
5
  export function getConfig(accountName) {
5
6
  const app = App.getInstance();
@@ -32,6 +33,16 @@ export function getConfig(accountName) {
32
33
  if (config.disasterRecoveryRegion)
33
34
  regions.add(config.disasterRecoveryRegion);
34
35
  config.allRegions = [...regions];
36
+ // Reject name collisions here, before synth turns them into a cryptic
37
+ // duplicate-construct-ID crash. Two names with the same construct key are the
38
+ // same account; an empty key has no construct identity.
39
+ const collision = findAccountNameCollision(providerAccounts.map((pa) => pa.name));
40
+ if (collision) {
41
+ if (collision.kind === "empty-construct-key") {
42
+ throw new Error(`getConfig: provider account name "${collision.name}" normalises to an empty construct key — account names must contain at least one alphanumeric character`);
43
+ }
44
+ throw new Error(`getConfig: provider account names "${collision.existingName}" and "${collision.name}" collide on construct key "${collision.constructKey}" — account names must be unique ignoring case and separators`);
45
+ }
35
46
  // Build account IDs map from provider accounts (name → id)
36
47
  if (providerAccounts.length > 0) {
37
48
  config.accountIds = {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fjall/components-infrastructure",
3
- "version": "2.5.0",
3
+ "version": "2.7.0",
4
4
  "license": "SEE LICENSE IN LICENSE",
5
5
  "type": "module",
6
6
  "bin": {
@@ -63,8 +63,8 @@
63
63
  },
64
64
  "dependencies": {
65
65
  "@aws-sdk/client-organizations": "^3.1038.0",
66
- "@fjall/generator": "^2.5.0",
67
- "@fjall/util": "^2.5.0",
66
+ "@fjall/generator": "^2.7.0",
67
+ "@fjall/util": "^2.7.0",
68
68
  "constructs": "^10.0.0",
69
69
  "uuid": "^14.0.0"
70
70
  },
@@ -79,5 +79,5 @@
79
79
  "engines": {
80
80
  "node": ">=18.0.0"
81
81
  },
82
- "gitHead": "5c8f0e004f5520c692f2ee2063c3558c2451f2cf"
82
+ "gitHead": "cfcfbb9f546974d62756e257fce012f629db79ce"
83
83
  }