@fjall/components-infrastructure 3.10.0 → 4.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.
@@ -24,6 +24,10 @@ export function composeApexDomain(scope, props) {
24
24
  const hostedZoneConstruct = new HostedZone(scope, `${safeZone}HostedZone`, {
25
25
  zoneName: props.zoneName,
26
26
  hostedZoneId: props.hostedZoneId,
27
+ // Apex pins the legacy first-label naming: fjallDelegateHostedZoneRole
28
+ // is LIVE in production and children hold its LITERAL ARN - the default
29
+ // full-zone naming would REPLACE the role and sever every child.
30
+ delegationRoleNaming: "first-label",
27
31
  costAllocationEnvironment: props.costAllocationEnvironment,
28
32
  costAllocationDomain: props.zoneName
29
33
  });
@@ -8,7 +8,7 @@ import { applyInferredPublicBuildArgs } from "./computeBuildArgInference.js";
8
8
  import { DEFAULT_ECS_FALLBACK_IMAGE, DEFAULT_EC2_INSTANCE_TYPE } from "../../resources/aws/compute/ecsConstants.js";
9
9
  // Import and re-export from per-pattern files
10
10
  import { EcsCompute, ECS_CAPACITY_PROVIDER_CONFIG, getEcsCapacityProviderConfig, ScalingType, validateEcsProps, buildContainerConfigs, expandMigrationsSugar, resolveScalingConfig } from "./computeEcs.js";
11
- import { LambdaCompute, resolveLambdaDeployment, lambdaImageKey, Architecture, HttpMethod, InvokeMode } from "./computeLambda.js";
11
+ import { LambdaCompute, resolveLambdaDeployment, lambdaImageKey, toManifestLambdaArchitecture, Architecture, HttpMethod, InvokeMode } from "./computeLambda.js";
12
12
  import { Ec2Compute } from "./computeEc2.js";
13
13
  // Re-export everything from per-pattern files
14
14
  export {
@@ -259,6 +259,14 @@ export class ComputeFactory {
259
259
  lambdaComputeProps.docker !== undefined) {
260
260
  manifestLambda.docker = lambdaComputeProps.docker;
261
261
  manifestLambda.imageKey = lambdaImageKey(lambdaComputeProps, id);
262
+ // Tells `fjall deploy` which platform to build so the pushed
263
+ // image's architecture matches this function's `Architectures` —
264
+ // without it the build pipeline always defaults to arm64.
265
+ const architecture = toManifestLambdaArchitecture(lambdaComputeProps.architecture ??
266
+ COMPUTE_DEFAULTS.LAMBDA.ARCHITECTURE);
267
+ if (architecture !== undefined) {
268
+ manifestLambda.architecture = architecture;
269
+ }
262
270
  }
263
271
  collector.addLambda(manifestLambda);
264
272
  return new LambdaCompute(scope, id, lambdaComputeProps);
@@ -8,7 +8,7 @@ import { type ILambdaCompute } from "./interfaces/compute.js";
8
8
  import { type ConnectionSpec } from "./interfaces/connector.js";
9
9
  import { LambdaFunction } from "../../resources/aws/compute/lambda.js";
10
10
  import { type SecretImport } from "../../resources/aws/secrets/index.js";
11
- import { type DockerBuild } from "@fjall/util/manifest/schemas";
11
+ import { type DockerBuild, type LambdaArchitecture } from "@fjall/util/manifest/schemas";
12
12
  export { Architecture, HttpMethod, InvokeMode, type FunctionUrlCorsOptions } from "aws-cdk-lib/aws-lambda";
13
13
  /**
14
14
  * Lambda function URL configuration.
@@ -36,7 +36,13 @@ interface BaseLambdaProps {
36
36
  /** Memory size in MB. Default: 128 */
37
37
  memorySize?: number;
38
38
  ephemeralStorageSize?: number;
39
- /** CPU architecture. Default: x86_64. Use Architecture.ARM_64 for Graviton2. */
39
+ /**
40
+ * CPU architecture. Default: Architecture.ARM_64 (Graviton2). Use
41
+ * Architecture.X86_64 for x86. For `deployment: "container"` with `docker`
42
+ * set, this also controls which platform `fjall deploy` builds the image
43
+ * for — the two must always agree, so set this instead of passing
44
+ * `--platform` to Docker directly.
45
+ */
40
46
  architecture?: Architecture;
41
47
  /** Lambda function description */
42
48
  description?: string;
@@ -156,6 +162,23 @@ export interface ContainerLambdaProps extends BaseLambdaProps {
156
162
  * `docker` is set.
157
163
  */
158
164
  image?: string;
165
+ /**
166
+ * Override the container's ENTRYPOINT (exec form, e.g.
167
+ * `["/lambda-entrypoint.sh"]`). Maps to Lambda's `ImageConfig.EntryPoint`.
168
+ * Default: use the image's own ENTRYPOINT.
169
+ */
170
+ entrypoint?: string[];
171
+ /**
172
+ * Override the container's CMD — i.e. the "docker run command" Lambda
173
+ * invokes as the handler (exec form, e.g. `["app.handler"]`). Maps to
174
+ * Lambda's `ImageConfig.Command`. Default: use the image's own CMD.
175
+ */
176
+ cmd?: string[];
177
+ /**
178
+ * Override the container's WORKDIR. Maps to Lambda's
179
+ * `ImageConfig.WorkingDirectory`. Default: use the image's own WORKDIR.
180
+ */
181
+ workingDirectory?: string;
159
182
  }
160
183
  /**
161
184
  * Code-based Lambda using inline code or S3.
@@ -219,6 +242,17 @@ export interface ResolvedLambdaDeployment {
219
242
  export declare function lambdaImageKey(props: {
220
243
  image?: string;
221
244
  }, id: string): string;
245
+ /**
246
+ * Project a CDK `Architecture` onto the manifest's `LambdaArchitecture`
247
+ * literal, so `fjall deploy` builds the same platform the function is
248
+ * configured to run on instead of always defaulting to arm64
249
+ * (`dockerPlatformForArchitecture`, `@fjall/util/docker`).
250
+ *
251
+ * Returns `undefined` for a custom architecture name (`Architecture.custom`)
252
+ * outside the two AWS Lambda supports today — the build pipeline then falls
253
+ * back to its own default rather than failing synth over an escape hatch.
254
+ */
255
+ export declare function toManifestLambdaArchitecture(architecture: Architecture): LambdaArchitecture | undefined;
222
256
  /**
223
257
  * Resolve Lambda deployment configuration from props.
224
258
  * Handles container vs code deployment types.
@@ -6,6 +6,7 @@ import { processConnections } from "../../utils/connections.js";
6
6
  import { LambdaFunction } from "../../resources/aws/compute/lambda.js";
7
7
  import { getOrCreateImageTagParameter } from "../../resources/aws/compute/imageTagParameter.js";
8
8
  import { COMPUTE_DEFAULTS } from "./compute.js";
9
+ import { LAMBDA_ARCHITECTURE_VALUES } from "@fjall/util/manifest/schemas";
9
10
  import { evaluateBakeGuard } from "@fjall/util/docker";
10
11
  import { toKebab } from "@fjall/util";
11
12
  // Re-export Lambda types from CDK for user convenience
@@ -22,6 +23,50 @@ export { Architecture, HttpMethod, InvokeMode } from "aws-cdk-lib/aws-lambda";
22
23
  export function lambdaImageKey(props, id) {
23
24
  return props.image !== undefined && props.image !== "" ? props.image : id;
24
25
  }
26
+ /**
27
+ * Project a CDK `Architecture` onto the manifest's `LambdaArchitecture`
28
+ * literal, so `fjall deploy` builds the same platform the function is
29
+ * configured to run on instead of always defaulting to arm64
30
+ * (`dockerPlatformForArchitecture`, `@fjall/util/docker`).
31
+ *
32
+ * Returns `undefined` for a custom architecture name (`Architecture.custom`)
33
+ * outside the two AWS Lambda supports today — the build pipeline then falls
34
+ * back to its own default rather than failing synth over an escape hatch.
35
+ */
36
+ export function toManifestLambdaArchitecture(architecture) {
37
+ return LAMBDA_ARCHITECTURE_VALUES.includes(architecture.name)
38
+ ? architecture.name
39
+ : undefined;
40
+ }
41
+ /**
42
+ * Project `ContainerLambdaProps`'s ENTRYPOINT/CMD/WORKDIR overrides onto the
43
+ * `EcrImageCodeProps` shape `Code.fromEcrImage` expects, omitting any field
44
+ * left unset so the image's own Dockerfile values apply (CDK's own default
45
+ * behaviour when these keys are absent).
46
+ *
47
+ * Degenerate values fail synth: an empty array or blank string is neither a
48
+ * usable override nor "use the image default", so passing it through would
49
+ * surface as a deploy/invoke-time ImageConfig failure instead of here.
50
+ */
51
+ function imageOverrides(props, id) {
52
+ if (props.entrypoint !== undefined && props.entrypoint.length === 0) {
53
+ throw new Error(`Lambda '${id}': \`entrypoint\` override must not be an empty array — omit the prop to keep the image's ENTRYPOINT.`);
54
+ }
55
+ if (props.cmd !== undefined && props.cmd.length === 0) {
56
+ throw new Error(`Lambda '${id}': \`cmd\` override must not be an empty array — omit the prop to keep the image's CMD.`);
57
+ }
58
+ if (props.workingDirectory !== undefined &&
59
+ props.workingDirectory.trim() === "") {
60
+ throw new Error(`Lambda '${id}': \`workingDirectory\` override must not be blank — omit the prop to keep the image's WORKDIR.`);
61
+ }
62
+ return {
63
+ ...(props.entrypoint !== undefined && { entrypoint: props.entrypoint }),
64
+ ...(props.cmd !== undefined && { cmd: props.cmd }),
65
+ ...(props.workingDirectory !== undefined && {
66
+ workingDirectory: props.workingDirectory
67
+ })
68
+ };
69
+ }
25
70
  /**
26
71
  * Resolve Lambda deployment configuration from props.
27
72
  * Handles container vs code deployment types.
@@ -53,7 +98,8 @@ export function resolveLambdaDeployment(scope, id, props) {
53
98
  });
54
99
  return {
55
100
  code: Code.fromEcrImage(props.ecrRepository, {
56
- tagOrDigest: param.valueAsString
101
+ tagOrDigest: param.valueAsString,
102
+ ...imageOverrides(props, id)
57
103
  }),
58
104
  handler: Handler.FROM_IMAGE,
59
105
  runtime: Runtime.FROM_IMAGE
@@ -61,7 +107,8 @@ export function resolveLambdaDeployment(scope, id, props) {
61
107
  }
62
108
  return {
63
109
  code: Code.fromEcrImage(props.ecrRepository, {
64
- tagOrDigest: COMPUTE_DEFAULTS.ECS.IMAGE_TAG
110
+ tagOrDigest: COMPUTE_DEFAULTS.ECS.IMAGE_TAG,
111
+ ...imageOverrides(props, id)
65
112
  }),
66
113
  handler: Handler.FROM_IMAGE,
67
114
  runtime: Runtime.FROM_IMAGE
@@ -34,6 +34,9 @@ const COMPUTE_PROP_APPLICABILITY = {
34
34
  ssmSecretsPath: ["lambda"],
35
35
  secretsImport: ["lambda"],
36
36
  connections: ["lambda"],
37
+ entrypoint: ["lambda"],
38
+ cmd: ["lambda"],
39
+ workingDirectory: ["lambda"],
37
40
  instanceType: ["ec2"],
38
41
  ssh: ["ec2"],
39
42
  userData: ["ec2"],
@@ -76,6 +79,9 @@ const LAMBDA_DEPLOYMENT_APPLICABILITY = {
76
79
  ecrRepository: "container",
77
80
  docker: "container",
78
81
  image: "container",
82
+ entrypoint: "container",
83
+ cmd: "container",
84
+ workingDirectory: "container",
79
85
  code: "code",
80
86
  handler: "code",
81
87
  runtime: "code"
@@ -28,13 +28,18 @@ import { DOMAIN_DEPLOY_DEFAULT_PHASE } from "../../utils/domainTypes.js";
28
28
  export function composeDelegatedDomain(scope, props) {
29
29
  const effectiveZone = `${props.delegatedSubdomain}.${props.zoneName}`;
30
30
  const safeZone = toPascalCase(getSafeZoneName(effectiveZone));
31
+ // `createDelegationRole` is deliberately omitted (mirroring the apex
32
+ // pattern): the wrapper's org-gated default mints the DelegationRole + ARN
33
+ // export for created AND adopted delegated zones, making this zone a
34
+ // delegating PARENT that nested children (k8.development.fjall.io-class)
35
+ // can assume. Default full-zone role naming applies - delegated children
36
+ // reuse first labels (dev/staging/k8), so first-label names would collide
37
+ // account-globally.
31
38
  const hostedZoneConstruct = new HostedZone(scope, `${safeZone}HostedZone`, {
32
39
  zoneName: effectiveZone,
33
40
  // Adoption path: an existing child zone is imported by id rather than
34
41
  // created (both-or-neither with adoptedNameServers, validated upstream).
35
42
  hostedZoneId: props.hostedZoneId,
36
- // The PARENT account owns the delegation role — this is the child.
37
- createDelegationRole: false,
38
43
  costAllocationEnvironment: props.costAllocationEnvironment,
39
44
  costAllocationDomain: props.zoneName
40
45
  });
@@ -1,13 +1,14 @@
1
1
  import { CfnOutput, Duration, Stack, Tags } from "aws-cdk-lib";
2
2
  import { Port } from "aws-cdk-lib/aws-ec2";
3
3
  import { Cluster } from "aws-cdk-lib/aws-ecs";
4
- import { Repository, TagMutability } from "aws-cdk-lib/aws-ecr";
4
+ import { Repository, TagMutability, TagStatus } from "aws-cdk-lib/aws-ecr";
5
5
  import { Effect, PolicyStatement } from "aws-cdk-lib/aws-iam";
6
6
  import { Code, Runtime } from "aws-cdk-lib/aws-lambda";
7
7
  import { PublicHostedZone } from "aws-cdk-lib/aws-route53";
8
8
  import { LoadBalancerTarget } from "aws-cdk-lib/aws-route53-targets";
9
9
  import { SqsDestination } from "aws-cdk-lib/aws-s3-notifications";
10
10
  import { Construct } from "constructs";
11
+ import { buildCacheRepositoryName, CACHE_REPO_UNTAGGED_RETENTION_DAYS } from "@fjall/util/docker";
11
12
  import { readFileSync } from "node:fs";
12
13
  import path from "node:path";
13
14
  import { fileURLToPath } from "node:url";
@@ -249,6 +250,30 @@ export class DevSubstrate extends Construct {
249
250
  // first same-branch recreate (BUG-22).
250
251
  tagMutability: TagMutability.MUTABLE
251
252
  });
253
+ // Buildx registry cache for slot builds, named by the same helper as the app
254
+ // path's `<repo>-cache` so `fjall dev up` derives this URI from the slot
255
+ // repo's without a second wire field. Adopts on the same flag: both repos are
256
+ // RETAIN, so a rolled-back create leaves BOTH behind, and a create-only cache
257
+ // repo would fail the very retry `adoptSlotEcr` exists to rescue.
258
+ const slotCacheRepositoryName = buildCacheRepositoryName(slotEcrRepositoryName);
259
+ if (props.adoptSlotEcr === true) {
260
+ Repository.fromRepositoryName(this, `${slotEcrId}Cache`, slotCacheRepositoryName);
261
+ }
262
+ else {
263
+ const slotCacheRepository = new Ecr(this, `${slotEcrId}Cache`, {
264
+ repositoryName: slotCacheRepositoryName,
265
+ // buildx overwrites the per-repo cache manifest tag on every export.
266
+ tagMutability: TagMutability.MUTABLE
267
+ });
268
+ // Each `mode=max` export orphans the previous manifest's blobs. The
269
+ // dev-deploy role holds no BatchDeleteImage or PutLifecyclePolicy, so this
270
+ // rule is the ONLY reclaim path — without it the cache grows without bound
271
+ // in a customer's dev account. An adopted repo keeps its existing policy.
272
+ slotCacheRepository.addLifecycleRule({
273
+ tagStatus: TagStatus.UNTAGGED,
274
+ maxImageAge: Duration.days(CACHE_REPO_UNTAGGED_RETENTION_DAYS)
275
+ });
276
+ }
252
277
  return {
253
278
  vpc,
254
279
  slotSecurityGroup,
@@ -29,5 +29,5 @@ export declare class Domain extends Construct {
29
29
  readonly nameServers: string[] | undefined;
30
30
  readonly manualRecords: ManualRecord[];
31
31
  readonly exportNames: ReturnType<typeof getDomainExportNames>;
32
- constructor(scope: Construct, id: string, props: DomainProps);
32
+ constructor(scope: Construct, id: string, rawProps: DomainProps);
33
33
  }
@@ -32,8 +32,17 @@ export class Domain extends Construct {
32
32
  nameServers;
33
33
  manualRecords;
34
34
  exportNames;
35
- constructor(scope, id, props) {
35
+ constructor(scope, id, rawProps) {
36
36
  super(scope, id);
37
+ // N6b hand-edit-trap guard: the CLI injects the deploy phase and parent
38
+ // role ARN as CDK context, historically read only by the
39
+ // tsEmitter-generated infrastructure.ts prelude - a hand-edited file
40
+ // without it silently lost phase-gating (R2 cert-hang) and the injected
41
+ // ARN. Merged BEFORE validation so context-sourced values face the same
42
+ // checks as props.
43
+ const props = rawProps.registrar === "external-delegated"
44
+ ? applyDelegatedContextFallback(this, rawProps)
45
+ : rawProps;
37
46
  validateDomainProps(this, props);
38
47
  this.registrar = props.registrar;
39
48
  this.exportNames = getDomainExportNames(resolveEffectiveZoneName(props));
@@ -115,3 +124,50 @@ function resolveEffectiveZoneName(props) {
115
124
  }
116
125
  return props.zoneName;
117
126
  }
127
+ // CLI injection channel for the delegated-child topology - keep the key
128
+ // literals in sync with deploy-core CdkArgumentBuilder.buildContextArgs and
129
+ // the cli tsEmitter prelude.
130
+ const CONTEXT_KEY_DOMAIN_DEPLOY_PHASE = "fjall:domainDeployPhase";
131
+ const CONTEXT_KEY_PARENT_DELEGATION_ROLE_ARN = "fjall:parentDelegationRoleArn";
132
+ /**
133
+ * Context FALLBACK only - explicit props always win, so a
134
+ * tsEmitter-generated file that already read the context and passed props
135
+ * cannot double-apply a diverging value. `undefined` and the `-c key=`
136
+ * empty-string boundary both mean "not injected". An unrecognised phase
137
+ * fails the synth: silently ignoring it would issue certificates during the
138
+ * zone phase (the R2 trap). Non-delegated registrars never reach this -
139
+ * they must ignore the injected keys rather than trip the
140
+ * delegated-props-only validation.
141
+ */
142
+ function applyDelegatedContextFallback(scope, props) {
143
+ let merged = props;
144
+ if (merged.phase === undefined) {
145
+ const rawPhase = scope.node.tryGetContext(CONTEXT_KEY_DOMAIN_DEPLOY_PHASE);
146
+ if (rawPhase !== undefined && rawPhase !== "") {
147
+ if (rawPhase !== "zone" && rawPhase !== "full") {
148
+ throw new Error(`Domain '${props.delegatedSubdomain}.${props.zoneName}': CDK ` +
149
+ `context '${CONTEXT_KEY_DOMAIN_DEPLOY_PHASE}' must be "zone" or ` +
150
+ `"full" (got ${JSON.stringify(rawPhase)}). Cure: re-run the ` +
151
+ `deploy through the Fjall CLI, or correct the hand-set context ` +
152
+ `entry.`);
153
+ }
154
+ merged = { ...merged, phase: rawPhase };
155
+ }
156
+ }
157
+ if (merged.parentDelegationRoleArn === undefined) {
158
+ const rawArn = scope.node.tryGetContext(CONTEXT_KEY_PARENT_DELEGATION_ROLE_ARN);
159
+ if (rawArn !== undefined && rawArn !== "") {
160
+ if (typeof rawArn !== "string") {
161
+ throw new Error(`Domain '${props.delegatedSubdomain}.${props.zoneName}': CDK ` +
162
+ `context '${CONTEXT_KEY_PARENT_DELEGATION_ROLE_ARN}' must be a ` +
163
+ `string IAM role ARN (got ${JSON.stringify(rawArn)}). Cure: ` +
164
+ `re-run the deploy through the Fjall CLI, or correct the ` +
165
+ `hand-set context entry.`);
166
+ }
167
+ // ARN-shape/token checks run in validateDomainProps on the merged
168
+ // props - a malformed injected value must fail the synth there.
169
+ merged = { ...merged, parentDelegationRoleArn: rawArn };
170
+ }
171
+ }
172
+ return merged;
173
+ }
@@ -1,10 +1,37 @@
1
1
  import { Construct } from "constructs";
2
2
  import { type IRole } from "aws-cdk-lib/aws-iam";
3
3
  import type { IHostedZone } from "aws-cdk-lib/aws-route53";
4
+ /**
5
+ * Physical-role-name derivation strategy.
6
+ *
7
+ * - `"full-zone"` (default): the name derives from every zone label, so two
8
+ * role-minting zones sharing a first label in one account (delegated
9
+ * children reuse labels like dev/staging/k8) get distinct names. Residual
10
+ * collision class: IAM compares RoleNames case-insensitively and the
11
+ * pascal-casing erases dot positions, so zones whose concatenated labels
12
+ * are equal ignoring case (staging.api.example.com vs
13
+ * stagingapi.example.com) still map to IAM-equal names - the failure is
14
+ * loud (CloudFormation EntityAlreadyExists rollback), never silent.
15
+ * - `"first-label"`: the legacy contract, and the DEFAULT before this prop
16
+ * existed. `fjallDelegateHostedZoneRole` is LIVE in production and
17
+ * children hold its LITERAL ARN - renaming replaces the role and severs
18
+ * them, so apex zones pin this explicitly. Any direct consumer of this
19
+ * construct (or HostedZone) with a role deployed under the old default
20
+ * must pin `"first-label"` on upgrade: the default flip changes BOTH the
21
+ * RoleName and the construct id, i.e. role REPLACEMENT.
22
+ */
23
+ export type DelegationRoleNaming = "full-zone" | "first-label";
4
24
  export interface DelegationRoleProps {
5
25
  readonly zoneName: string;
6
26
  readonly hostedZone: IHostedZone;
7
- readonly organisationIdExportName?: string;
27
+ /**
28
+ * LITERAL AWS Organization id for the trust policy (aws:PrincipalOrgID).
29
+ * Resolved at synth from CDK context by the HostedZone wrapper - never a
30
+ * CFN import: the role mints in child accounts that may carry no
31
+ * OrganisationId export, where an Fn::ImportValue rolls the stack back.
32
+ */
33
+ readonly organisationId: string;
34
+ readonly naming?: DelegationRoleNaming;
8
35
  readonly description?: string;
9
36
  readonly costAllocationEnvironment?: string;
10
37
  readonly costAllocationDomain?: string;
@@ -1,10 +1,38 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { Construct } from "constructs";
2
- import { ArnFormat, CfnOutput, Fn, Stack, Tags } from "aws-cdk-lib";
3
+ import { ArnFormat, CfnOutput, Stack, Tags } from "aws-cdk-lib";
3
4
  import { OrganizationPrincipal, PolicyDocument, PolicyStatement } from "aws-cdk-lib/aws-iam";
4
5
  import { getDomainExportNames } from "@fjall/util";
5
6
  import { Role } from "./role.js";
6
- import { toPascalCase, getSafeZoneName } from "../../../utils/capitaliseString.js";
7
+ import { capitaliseString, toPascalCase, getSafeZoneName } from "../../../utils/capitaliseString.js";
7
8
  import { DEFAULT_COST_ALLOCATION_ENVIRONMENT } from "../../../utils/costAllocationTags.js";
9
+ // The suffix is a deployed contract: the SCP dev-isolation carve-out
10
+ // wildcards arn:aws:iam::*:role/*DelegateHostedZoneRole (scpPreset).
11
+ const ROLE_NAME_SUFFIX = "DelegateHostedZoneRole";
12
+ // IAM caps RoleName at 64 chars; 40 + the 22-char suffix leaves headroom.
13
+ const MAX_ZONE_PART_LENGTH = 40;
14
+ // Truncated stem + hash must land exactly on MAX_ZONE_PART_LENGTH.
15
+ const TRUNCATED_STEM_LENGTH = 32;
16
+ const ZONE_HASH_LENGTH = 8;
17
+ /**
18
+ * Full-zone role-name stem: first label verbatim, every later label
19
+ * capitalised (k8.development.fjall.io -> k8DevelopmentFjallIo). Over-long
20
+ * stems truncate to a fixed-length prefix plus a stable sha256 fragment of
21
+ * the whole zone name, keeping the name deterministic per zone and distinct
22
+ * across zones that share a long prefix.
23
+ */
24
+ function deriveFullZoneStem(zoneName) {
25
+ const labels = zoneName.split(".");
26
+ const stem = labels[0] + labels.slice(1).map(capitaliseString).join("");
27
+ if (stem.length <= MAX_ZONE_PART_LENGTH) {
28
+ return stem;
29
+ }
30
+ const hash = createHash("sha256")
31
+ .update(zoneName)
32
+ .digest("hex")
33
+ .slice(0, ZONE_HASH_LENGTH);
34
+ return stem.slice(0, TRUNCATED_STEM_LENGTH) + hash;
35
+ }
8
36
  export class DelegationRole extends Construct {
9
37
  role;
10
38
  roleArn;
@@ -13,14 +41,19 @@ export class DelegationRole extends Construct {
13
41
  constructor(scope, id, props) {
14
42
  super(scope, id);
15
43
  const firstLabel = props.zoneName.split(".")[0];
16
- const safeFirstLabel = toPascalCase(firstLabel);
17
44
  const safeZone = toPascalCase(getSafeZoneName(props.zoneName));
45
+ const stem = (props.naming ?? "full-zone") === "first-label"
46
+ ? firstLabel
47
+ : deriveFullZoneStem(props.zoneName);
48
+ // First-label construct id must stay byte-identical to the deployed apex
49
+ // logical IDs (an id change is a role REPLACEMENT on live stacks).
50
+ const constructId = `${toPascalCase(stem)}${ROLE_NAME_SUFFIX}`;
18
51
  this.description =
19
52
  props.description ??
20
53
  `Fjall-managed cross-account delegation role for ${props.zoneName}`;
21
- const role = new Role(this, `${safeFirstLabel}DelegateHostedZoneRole`, {
22
- assumedBy: new OrganizationPrincipal(Fn.importValue(props.organisationIdExportName ?? "OrganisationId")),
23
- roleName: `${firstLabel}DelegateHostedZoneRole`,
54
+ const role = new Role(this, constructId, {
55
+ assumedBy: new OrganizationPrincipal(props.organisationId),
56
+ roleName: `${stem}${ROLE_NAME_SUFFIX}`,
24
57
  description: this.description,
25
58
  inlinePolicies: {
26
59
  listHostedZones: new PolicyDocument({
@@ -1,14 +1,25 @@
1
1
  import { Construct } from "constructs";
2
2
  import { type IHostedZone } from "aws-cdk-lib/aws-route53";
3
3
  import { type RemovalPolicyString } from "../messaging/utils.js";
4
- import { DelegationRole } from "../iam/delegationRole.js";
4
+ import { DelegationRole, type DelegationRoleNaming } from "../iam/delegationRole.js";
5
5
  import { type AwsStack } from "../base/awsStack.js";
6
6
  export interface HostedZoneProps {
7
7
  readonly zoneName: string;
8
8
  readonly hostedZoneId?: string;
9
9
  readonly description?: string;
10
10
  readonly createDelegationRole?: boolean;
11
- readonly organisationIdExportName?: string;
11
+ /**
12
+ * Physical naming for the minted DelegationRole. Default `"full-zone"`:
13
+ * delegated zones reuse first labels (dev/staging/k8) in one account, so
14
+ * first-label names would collide account-globally. Apex patterns pin
15
+ * `"first-label"` because that role name is a deployed contract (children
16
+ * hold its literal ARN). BREAKING default flip: before this prop existed
17
+ * every minted role was first-label-named - a consumer upgrading with a
18
+ * previously deployed default-named role must pin `"first-label"` or the
19
+ * rename (RoleName and construct id both change) REPLACES the role and
20
+ * severs children holding its literal ARN.
21
+ */
22
+ readonly delegationRoleNaming?: DelegationRoleNaming;
12
23
  readonly costAllocationEnvironment?: string;
13
24
  readonly costAllocationDomain?: string;
14
25
  /**
@@ -72,22 +72,27 @@ export class HostedZone extends Construct {
72
72
  this.isImported = true;
73
73
  Tags.of(this).add("fjall:description", this.description);
74
74
  }
75
- // Org-gate: a single account has no OrganisationId export, so an org-trusting
76
- // delegation role would leave an unresolved Fn::ImportValue and roll the stack
77
- // back. Default follows org presence; explicit `true` opts in (and fails fast).
75
+ // Org-gate: an org-trusting delegation role is meaningless on a single
76
+ // account, so the default follows org presence; explicit `true` opts in
77
+ // (and fails fast). The synth-resolved orgId LITERAL feeds the trust
78
+ // policy directly - never a CFN import, which would tie every
79
+ // role-minting account to an Account governance stack export.
78
80
  // Runs for created AND adopted zones — `grantDelegation` is on IHostedZone.
79
- const inOrganisation = resolveOrgId(this.node) !== undefined;
80
- if (props.createDelegationRole === true && !inOrganisation) {
81
+ const orgId = resolveOrgId(this.node);
82
+ if (props.createDelegationRole === true && orgId === undefined) {
81
83
  throw new Error(`HostedZone "${props.zoneName}": createDelegationRole was requested but ` +
82
84
  `this account is not part of an AWS Organization (no "orgId" context). ` +
83
85
  `Cross-account DNS delegation requires an organisation — omit ` +
84
86
  `createDelegationRole for single-account setups, or connect an organisation.`);
85
87
  }
86
- if (props.createDelegationRole ?? inOrganisation) {
88
+ // Equivalent to `props.createDelegationRole ?? inOrganisation`: the
89
+ // explicit-true-without-org case already threw, so orgId is narrowed.
90
+ if (orgId !== undefined && (props.createDelegationRole ?? true)) {
87
91
  this.delegationRole = new DelegationRole(this, `${safeZone}DelegationRole`, {
88
92
  zoneName: props.zoneName,
89
93
  hostedZone: this.hostedZone,
90
- organisationIdExportName: props.organisationIdExportName,
94
+ organisationId: orgId,
95
+ naming: props.delegationRoleNaming,
91
96
  costAllocationEnvironment: props.costAllocationEnvironment,
92
97
  costAllocationDomain: props.costAllocationDomain
93
98
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fjall/components-infrastructure",
3
- "version": "3.10.0",
3
+ "version": "4.0.0",
4
4
  "license": "SEE LICENSE IN LICENSE",
5
5
  "type": "module",
6
6
  "bin": {
@@ -67,8 +67,8 @@
67
67
  },
68
68
  "dependencies": {
69
69
  "@aws-sdk/client-organizations": "^3.1038.0",
70
- "@fjall/generator": "^3.10.0",
71
- "@fjall/util": "^3.10.0",
70
+ "@fjall/generator": "^4.0.0",
71
+ "@fjall/util": "^4.0.0",
72
72
  "constructs": "^10.6.0"
73
73
  },
74
74
  "overrides": {
@@ -82,5 +82,5 @@
82
82
  "engines": {
83
83
  "node": ">=18.0.0"
84
84
  },
85
- "gitHead": "ce4e8471194f7e3e5e7df66844bf9387503983f6"
85
+ "gitHead": "14031063ccd2685e390b70f6a3f4efe2bac7ced9"
86
86
  }