@fjall/components-infrastructure 3.10.0 → 3.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/lib/patterns/aws/apexDomainPattern.js +4 -0
- package/dist/lib/patterns/aws/compute.js +9 -1
- package/dist/lib/patterns/aws/computeLambda.d.ts +36 -2
- package/dist/lib/patterns/aws/computeLambda.js +49 -2
- package/dist/lib/patterns/aws/computePropApplicability.js +6 -0
- package/dist/lib/patterns/aws/delegatedDomainPattern.js +7 -2
- package/dist/lib/patterns/aws/domain.d.ts +1 -1
- package/dist/lib/patterns/aws/domain.js +57 -1
- package/dist/lib/resources/aws/iam/delegationRole.d.ts +28 -1
- package/dist/lib/resources/aws/iam/delegationRole.js +39 -6
- package/dist/lib/resources/aws/networking/hostedZone.d.ts +13 -2
- package/dist/lib/resources/aws/networking/hostedZone.js +12 -7
- package/package.json +4 -4
|
@@ -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
|
-
/**
|
|
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
|
});
|
|
@@ -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,
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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,
|
|
22
|
-
assumedBy: new OrganizationPrincipal(
|
|
23
|
-
roleName: `${
|
|
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
|
-
|
|
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:
|
|
76
|
-
//
|
|
77
|
-
//
|
|
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
|
|
80
|
-
if (props.createDelegationRole === true &&
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "3.11.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.
|
|
71
|
-
"@fjall/util": "^3.
|
|
70
|
+
"@fjall/generator": "^3.11.0",
|
|
71
|
+
"@fjall/util": "^3.11.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": "
|
|
85
|
+
"gitHead": "a15125ea026d52428ff5433d098699899695ca3f"
|
|
86
86
|
}
|