@fjall/components-infrastructure 4.1.0 → 4.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/lib/config/aws/oidcConnector.d.ts +13 -0
- package/dist/lib/config/aws/oidcConnector.js +6 -2
- package/dist/lib/patterns/aws/account.d.ts +14 -0
- package/dist/lib/patterns/aws/account.js +16 -0
- package/dist/lib/patterns/aws/computeEcs.js +2 -1
- package/dist/lib/patterns/aws/organisation.d.ts +9 -0
- package/dist/lib/patterns/aws/organisation.js +10 -0
- package/dist/lib/patterns/aws/platform.d.ts +2 -0
- package/dist/lib/patterns/aws/platform.js +3 -0
- package/dist/lib/resources/aws/compute/ecsTaskDefinition.js +3 -0
- package/dist/lib/resources/aws/compute/ecsTypes.d.ts +18 -3
- package/dist/lib/resources/aws/compute/ecsValidation.d.ts +56 -1
- package/dist/lib/resources/aws/compute/ecsValidation.js +66 -0
- package/dist/lib/utils/engineCompat.d.ts +37 -0
- package/dist/lib/utils/engineCompat.js +118 -0
- package/dist/lib/utils/manifestWriter.js +8 -0
- package/package.json +4 -4
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type GovernancePreset } from "@fjall/generator";
|
|
2
|
+
import { type AccountTier } from "@fjall/util";
|
|
2
3
|
import { Construct } from "constructs";
|
|
3
4
|
export interface OidcConnectorProps {
|
|
4
5
|
fjallOrgId: string;
|
|
@@ -19,6 +20,18 @@ export interface OidcConnectorProps {
|
|
|
19
20
|
* other stage ⇒ deploy role only.
|
|
20
21
|
*/
|
|
21
22
|
accountStage?: string;
|
|
23
|
+
/**
|
|
24
|
+
* Account TIER of the connected account (organisation | platform | account) —
|
|
25
|
+
* the structural TIER axis, from the account stack's polymorphic
|
|
26
|
+
* `deployRoleAccountTier()`; NEVER the wire `environment` / stage axis (that is
|
|
27
|
+
* `accountStage`). Drives the §4.4a deploy-trust governance expand: a
|
|
28
|
+
* governance tier (organisation/platform) plus the synth account id widens the
|
|
29
|
+
* `FjallDeploy` machine subject to ALSO accept the account-scoped literal
|
|
30
|
+
* `org:{orgId}:acct:{awsAccountId}` (StringEquals array = OR of exact
|
|
31
|
+
* literals), so per-account deploy issuance can later flip. App-tier
|
|
32
|
+
* ("account") or absent ⇒ generic-only scalar (unchanged, fail-safe).
|
|
33
|
+
*/
|
|
34
|
+
accountTier?: AccountTier;
|
|
22
35
|
}
|
|
23
36
|
export declare class OidcConnector extends Construct {
|
|
24
37
|
readonly deployRoleArn: string;
|
|
@@ -115,10 +115,15 @@ export class OidcConnector extends Construct {
|
|
|
115
115
|
statements: SECURITY_TIER_TO_BOUNDARY[props.securityTier].map((statement) => iam.PolicyStatement.fromJson(statement))
|
|
116
116
|
})
|
|
117
117
|
: undefined;
|
|
118
|
+
// Concrete when env.account is set, a token otherwise — both render. Feeds
|
|
119
|
+
// the governance-expand machine subject and the dev-tier surface below.
|
|
120
|
+
const accountId = Stack.of(this).account;
|
|
118
121
|
const trustParams = {
|
|
119
122
|
fjallOrgId: props.fjallOrgId,
|
|
120
123
|
providerArn,
|
|
121
|
-
issuerDomain
|
|
124
|
+
issuerDomain,
|
|
125
|
+
accountId,
|
|
126
|
+
...(props.accountTier !== undefined && { accountTier: props.accountTier })
|
|
122
127
|
};
|
|
123
128
|
const deployRole = new Role(this, "DeployRole", {
|
|
124
129
|
roleName: `FjallDeploy${props.fjallOrgId}`,
|
|
@@ -141,7 +146,6 @@ export class OidcConnector extends Construct {
|
|
|
141
146
|
// with the Quick-Create yaml's IsDevelopmentStage-conditional resources
|
|
142
147
|
// and the webapp dev-gate-remediate pass).
|
|
143
148
|
if (props.accountStage === "development") {
|
|
144
|
-
const accountId = Stack.of(this).account;
|
|
145
149
|
new ManagedPolicy(this, "DevBoundary", {
|
|
146
150
|
managedPolicyName: DEV_BOUNDARY_POLICY_NAME,
|
|
147
151
|
description: "Fjall dev-tier permissions boundary — ceiling for the per-slot task roles the dev provisioner creates",
|
|
@@ -13,6 +13,7 @@ import type { ConfigRecorderProps } from "../../config/aws/configRecorder.js";
|
|
|
13
13
|
import type { ConfigRulePresetProps } from "../../config/aws/configRulePreset.js";
|
|
14
14
|
import type { OrganisationType } from "./interfaces/organisation.js";
|
|
15
15
|
import { type GovernancePreset } from "@fjall/generator";
|
|
16
|
+
import { type AccountTier } from "@fjall/util";
|
|
16
17
|
export interface AccountProps extends StackProps {
|
|
17
18
|
accountId?: string;
|
|
18
19
|
region?: string;
|
|
@@ -47,6 +48,19 @@ export declare class Account extends Stack {
|
|
|
47
48
|
* independent, and Platform must take one but not the other.
|
|
48
49
|
*/
|
|
49
50
|
protected receivesDeployRole(): boolean;
|
|
51
|
+
/**
|
|
52
|
+
* Account TIER passed to this account's `OidcConnector` for the §4.4a
|
|
53
|
+
* deploy-trust governance expand. A polymorphic METHOD, not a read of the
|
|
54
|
+
* `organisationType` field: the connector is instantiated in THIS base
|
|
55
|
+
* constructor, and subclass field initialisers run AFTER `super()`, so
|
|
56
|
+
* `this.organisationType` would read "account" for every subclass here (the
|
|
57
|
+
* same construction-order reason `receivesDeployRole()` is a method). Base ⇒
|
|
58
|
+
* "account" (app-tier, generic-only trust); `Platform` ⇒ "platform" and
|
|
59
|
+
* `Organisation` ⇒ "organisation" override. Governance tiers widen the deploy
|
|
60
|
+
* machine subject; kept in lockstep with `organisationType` by the
|
|
61
|
+
* Platform-synth deploy-trust parity test.
|
|
62
|
+
*/
|
|
63
|
+
protected deployRoleAccountTier(): AccountTier;
|
|
50
64
|
/**
|
|
51
65
|
* Whether this account creates the default-ECR-image helper (the CodeBuild
|
|
52
66
|
* project that seeds a placeholder application image). App-hosting accounts
|
|
@@ -81,6 +81,7 @@ export class Account extends Stack {
|
|
|
81
81
|
new OidcConnector(this, "OidcConnector", {
|
|
82
82
|
fjallOrgId,
|
|
83
83
|
accountStage: environment,
|
|
84
|
+
accountTier: this.deployRoleAccountTier(),
|
|
84
85
|
...(props.securityTier !== undefined && {
|
|
85
86
|
securityTier: props.securityTier
|
|
86
87
|
})
|
|
@@ -146,6 +147,21 @@ export class Account extends Stack {
|
|
|
146
147
|
receivesDeployRole() {
|
|
147
148
|
return this.constructor === Account;
|
|
148
149
|
}
|
|
150
|
+
/**
|
|
151
|
+
* Account TIER passed to this account's `OidcConnector` for the §4.4a
|
|
152
|
+
* deploy-trust governance expand. A polymorphic METHOD, not a read of the
|
|
153
|
+
* `organisationType` field: the connector is instantiated in THIS base
|
|
154
|
+
* constructor, and subclass field initialisers run AFTER `super()`, so
|
|
155
|
+
* `this.organisationType` would read "account" for every subclass here (the
|
|
156
|
+
* same construction-order reason `receivesDeployRole()` is a method). Base ⇒
|
|
157
|
+
* "account" (app-tier, generic-only trust); `Platform` ⇒ "platform" and
|
|
158
|
+
* `Organisation` ⇒ "organisation" override. Governance tiers widen the deploy
|
|
159
|
+
* machine subject; kept in lockstep with `organisationType` by the
|
|
160
|
+
* Platform-synth deploy-trust parity test.
|
|
161
|
+
*/
|
|
162
|
+
deployRoleAccountTier() {
|
|
163
|
+
return "account";
|
|
164
|
+
}
|
|
149
165
|
/**
|
|
150
166
|
* Whether this account creates the default-ECR-image helper (the CodeBuild
|
|
151
167
|
* project that seeds a placeholder application image). App-hosting accounts
|
|
@@ -25,7 +25,7 @@ import { FjallLogger } from "../../utils/validationLogger.js";
|
|
|
25
25
|
import { VALIDATION_PATTERNS } from "@fjall/generator";
|
|
26
26
|
import { evaluateBakeGuard } from "@fjall/util/docker";
|
|
27
27
|
import { toKebab, buildParameterPath } from "@fjall/util";
|
|
28
|
-
import { validateEcsDomainConfig, validateSecretName } from "../../resources/aws/compute/ecsValidation.js";
|
|
28
|
+
import { validateEc2ServiceSizing, validateEcsDomainConfig, validateSecretName } from "../../resources/aws/compute/ecsValidation.js";
|
|
29
29
|
import { COMPUTE_DEFAULTS, collectImportedSecretNames } from "./compute.js";
|
|
30
30
|
import { isHookMigrations } from "./computeEcsTypes.js";
|
|
31
31
|
export { ScalingType } from "./computeEcsTypes.js";
|
|
@@ -150,6 +150,7 @@ export function validateEcsProps(props) {
|
|
|
150
150
|
FjallLogger.warn(`Service '${service.name}' has ec2Config but capacityProvider is not 'EC2'. ` +
|
|
151
151
|
"The ec2Config will be ignored unless capacityProvider is set to 'EC2'.");
|
|
152
152
|
}
|
|
153
|
+
validateEc2ServiceSizing(service);
|
|
153
154
|
if (service.deployment !== undefined) {
|
|
154
155
|
const min = service.deployment.minHealthyPercent;
|
|
155
156
|
const max = service.deployment.maxHealthyPercent;
|
|
@@ -5,6 +5,7 @@ import { type IdentityCentreConfig } from "../../config/aws/identityCentreConfig
|
|
|
5
5
|
import { ScpPreset } from "../../config/aws/scpPreset.js";
|
|
6
6
|
import type { ScpPresetProps } from "../../config/aws/scpPreset.js";
|
|
7
7
|
import { OrganisationResource } from "../../resources/aws/organisation/index.js";
|
|
8
|
+
import { type AccountTier } from "@fjall/util";
|
|
8
9
|
export type AccountsConfig = {
|
|
9
10
|
readonly [key: string]: readonly string[] | string | AccountsConfig;
|
|
10
11
|
};
|
|
@@ -58,6 +59,14 @@ export declare class Organisation extends Account {
|
|
|
58
59
|
* gate, so no `OidcConnector` is synthesised on the root stack.
|
|
59
60
|
*/
|
|
60
61
|
protected receivesDeployRole(): boolean;
|
|
62
|
+
/**
|
|
63
|
+
* Account TIER = organisation (the AWS Organizations management account). The
|
|
64
|
+
* root never instantiates an `OidcConnector` (`receivesDeployRole()` is false
|
|
65
|
+
* — its deploy trust is owned by the Quick-Create template), so this override
|
|
66
|
+
* is not reached on the CDK path today; it keeps `deployRoleAccountTier()`
|
|
67
|
+
* honest with `organisationType` for any future caller.
|
|
68
|
+
*/
|
|
69
|
+
protected deployRoleAccountTier(): AccountTier;
|
|
61
70
|
/**
|
|
62
71
|
* The organisation root runs no application workloads, so it needs no
|
|
63
72
|
* default-ECR-image helper.
|
|
@@ -125,6 +125,16 @@ export class Organisation extends Account {
|
|
|
125
125
|
receivesDeployRole() {
|
|
126
126
|
return false;
|
|
127
127
|
}
|
|
128
|
+
/**
|
|
129
|
+
* Account TIER = organisation (the AWS Organizations management account). The
|
|
130
|
+
* root never instantiates an `OidcConnector` (`receivesDeployRole()` is false
|
|
131
|
+
* — its deploy trust is owned by the Quick-Create template), so this override
|
|
132
|
+
* is not reached on the CDK path today; it keeps `deployRoleAccountTier()`
|
|
133
|
+
* honest with `organisationType` for any future caller.
|
|
134
|
+
*/
|
|
135
|
+
deployRoleAccountTier() {
|
|
136
|
+
return "organisation";
|
|
137
|
+
}
|
|
128
138
|
/**
|
|
129
139
|
* The organisation root runs no application workloads, so it needs no
|
|
130
140
|
* default-ECR-image helper.
|
|
@@ -4,6 +4,7 @@ import { type AccountsConfig } from "./organisation.js";
|
|
|
4
4
|
import { SecurityServicesAdmin } from "../../config/aws/securityServicesAdmin.js";
|
|
5
5
|
import type { SecurityServicesAdminProps } from "../../config/aws/securityServicesAdmin.js";
|
|
6
6
|
import type { OrganisationType } from "./interfaces/organisation.js";
|
|
7
|
+
import { type AccountTier } from "@fjall/util";
|
|
7
8
|
export interface PlatformProps extends AccountProps {
|
|
8
9
|
accounts?: AccountsConfig;
|
|
9
10
|
}
|
|
@@ -11,5 +12,6 @@ export declare class Platform extends Account {
|
|
|
11
12
|
readonly organisationType: OrganisationType;
|
|
12
13
|
constructor(scope: Construct, id: string, props: PlatformProps);
|
|
13
14
|
protected receivesDeployRole(): boolean;
|
|
15
|
+
protected deployRoleAccountTier(): AccountTier;
|
|
14
16
|
enableSecurityServicesAdmin(props: SecurityServicesAdminProps): SecurityServicesAdmin;
|
|
15
17
|
}
|
|
@@ -26,6 +26,9 @@ export class Platform extends Account {
|
|
|
26
26
|
receivesDeployRole() {
|
|
27
27
|
return true;
|
|
28
28
|
}
|
|
29
|
+
deployRoleAccountTier() {
|
|
30
|
+
return "platform";
|
|
31
|
+
}
|
|
29
32
|
enableSecurityServicesAdmin(props) {
|
|
30
33
|
return new SecurityServicesAdmin(this, "SecurityServicesAdmin", props);
|
|
31
34
|
}
|
|
@@ -214,6 +214,9 @@ export function addContainersToTask(ctx, serviceName, serviceProps, taskDefiniti
|
|
|
214
214
|
stopTimeout: containerConfig.stopTimeout !== undefined
|
|
215
215
|
? Duration.seconds(containerConfig.stopTimeout)
|
|
216
216
|
: undefined,
|
|
217
|
+
// EC2 container memory is ec2Config-only by design — do NOT add a
|
|
218
|
+
// `?? serviceProps.memoryLimitMiB` fallback (that is the silent-ignore
|
|
219
|
+
// footgun `validateEc2ServiceSizing` exists to reject).
|
|
217
220
|
...(isServiceEc2(serviceProps) && {
|
|
218
221
|
memoryLimitMiB: serviceProps.ec2Config?.memoryLimitMiB ??
|
|
219
222
|
DEFAULT_EC2_CONTAINER_MEMORY_MIB
|
|
@@ -102,7 +102,12 @@ export interface Ec2CapacityConfig {
|
|
|
102
102
|
minCapacity?: number;
|
|
103
103
|
/** Maximum number of instances. Default: 3 */
|
|
104
104
|
maxCapacity?: number;
|
|
105
|
-
/**
|
|
105
|
+
/**
|
|
106
|
+
* Memory limit in MiB for the container. Default: 1024. This is the ONLY
|
|
107
|
+
* memory knob for an EC2 service — the service-level `memoryLimitMiB` on
|
|
108
|
+
* `EcsServiceProps` is Fargate-only and is rejected at synth for EC2 capacity
|
|
109
|
+
* (see `validateEc2ServiceSizing`).
|
|
110
|
+
*/
|
|
106
111
|
memoryLimitMiB?: number;
|
|
107
112
|
/** Warm pool keeps stopped instances for faster start (10-15s vs 60-90s).
|
|
108
113
|
* Mirrors generator WarmPool type (generator/src/schemas/computeSchemas.ts). */
|
|
@@ -442,9 +447,19 @@ export interface EcsServiceProps {
|
|
|
442
447
|
* The first container with a port is the **primary container** (receives ALB traffic).
|
|
443
448
|
*/
|
|
444
449
|
containers: EcsClusterContainerConfig[];
|
|
445
|
-
/**
|
|
450
|
+
/**
|
|
451
|
+
* CPU units for this service's tasks (256-4096). Fargate only — EC2 task
|
|
452
|
+
* definitions carry no task-level CPU reservation (CPU is bounded by the
|
|
453
|
+
* instance type), so a value here is rejected at synth when `capacityProvider`
|
|
454
|
+
* is 'EC2' (see `validateEc2ServiceSizing`).
|
|
455
|
+
*/
|
|
446
456
|
cpu?: number;
|
|
447
|
-
/**
|
|
457
|
+
/**
|
|
458
|
+
* Memory in MiB for this service's tasks (512-30720). Fargate only — for EC2
|
|
459
|
+
* capacity the container memory comes from `ec2Config.memoryLimitMiB`; a
|
|
460
|
+
* service-level value here is rejected at synth unless it exactly mirrors
|
|
461
|
+
* that field (see `validateEc2ServiceSizing`).
|
|
462
|
+
*/
|
|
448
463
|
memoryLimitMiB?: number;
|
|
449
464
|
/** Desired number of tasks. Default: 2 */
|
|
450
465
|
desiredCount?: number;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { DomainConfig, EcsClusterProps } from "./ecsTypes.js";
|
|
1
|
+
import type { DomainConfig, EcsCapacityProvider, EcsClusterProps } from "./ecsTypes.js";
|
|
2
2
|
/**
|
|
3
3
|
* Validates ECS cluster props before construction.
|
|
4
4
|
* Pure function — does not depend on class state.
|
|
@@ -23,6 +23,60 @@ import type { DomainConfig, EcsClusterProps } from "./ecsTypes.js";
|
|
|
23
23
|
* @throws Error if validation fails
|
|
24
24
|
*/
|
|
25
25
|
export declare function validateEcsClusterProps(props: EcsClusterProps): void;
|
|
26
|
+
/**
|
|
27
|
+
* Minimal structural input for {@link validateEc2ServiceSizing} — exactly the
|
|
28
|
+
* fields the guard reads. Both the resources-layer `EcsServiceProps` and the
|
|
29
|
+
* patterns-layer `EcsServiceConfig` structurally satisfy it, so one guard serves
|
|
30
|
+
* both validation layers without coupling to either full service type
|
|
31
|
+
* (typescript-standards § "Helpers consume resolved state, not the dispatch enum":
|
|
32
|
+
* the parameter type is the fields the body actually reads, nothing wider).
|
|
33
|
+
*/
|
|
34
|
+
interface Ec2ServiceSizingInput {
|
|
35
|
+
name: string;
|
|
36
|
+
capacityProvider?: EcsCapacityProvider;
|
|
37
|
+
cpu?: number;
|
|
38
|
+
memoryLimitMiB?: number;
|
|
39
|
+
ec2Config?: {
|
|
40
|
+
memoryLimitMiB?: number;
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Rejects service-level task sizing that is silently ignored for EC2 capacity.
|
|
45
|
+
*
|
|
46
|
+
* For an EC2-capacity ECS service the container's hard memory limit is derived
|
|
47
|
+
* from `ec2Config.memoryLimitMiB` (default {@link DEFAULT_EC2_CONTAINER_MEMORY_MIB}),
|
|
48
|
+
* NOT from the service-level `memoryLimitMiB` — that field only feeds the
|
|
49
|
+
* Fargate task definition (`createTaskDefinition` in `ecsTaskDefinition.ts`: the
|
|
50
|
+
* EC2 branch passes neither `cpu` nor `memoryLimitMiB`; the container's memory
|
|
51
|
+
* comes solely from the `ec2Config.memoryLimitMiB ?? default` spread). A
|
|
52
|
+
* service-level `cpu` has no EC2 analogue at all — EC2 task definitions carry no
|
|
53
|
+
* task-level CPU reservation and `Ec2CapacityConfig` has no `cpu` field — so it
|
|
54
|
+
* is always dead on an EC2 service. Both fields read as "size the task to N"
|
|
55
|
+
* while the deployed container ignores the value.
|
|
56
|
+
*
|
|
57
|
+
* Fail loud at synth rather than silently honour the value: silently honouring
|
|
58
|
+
* memory would rewrite deployed container memory in BOTH directions — raise it
|
|
59
|
+
* past the EC2 instance's capacity (failure-to-place mid-rollout) or lower it
|
|
60
|
+
* into the service-level [512,1023] band below the 1024 EC2 default (fresh OOM).
|
|
61
|
+
* Rejecting changes nothing deployed; it converts two silent misconfigs into a
|
|
62
|
+
* synth error with a named cure.
|
|
63
|
+
*
|
|
64
|
+
* `both-set-and-equal` is allowed for memory — a config that mirrors
|
|
65
|
+
* `ec2Config.memoryLimitMiB` into the service-level field is self-consistent and
|
|
66
|
+
* the derived container memory is unchanged. `cpu` has no such carve-out (there
|
|
67
|
+
* is no `ec2Config.cpu` to agree with); it always throws on EC2.
|
|
68
|
+
*
|
|
69
|
+
* Called from BOTH validation layers — resources `validateEcsClusterProps` and
|
|
70
|
+
* patterns `validateEcsProps` — so the guard cannot drift between them
|
|
71
|
+
* (code-quality § "Coupled values: shared source at 2 occurrences"; mirrors the
|
|
72
|
+
* `validateEcsDomainConfig` shared-helper precedent below).
|
|
73
|
+
*
|
|
74
|
+
* @param service - The service props to validate (no-op unless EC2 capacity)
|
|
75
|
+
* @throws Error if an EC2 service carries an ignored service-level `cpu`, or a
|
|
76
|
+
* service-level `memoryLimitMiB` that is absent from / conflicts with
|
|
77
|
+
* `ec2Config.memoryLimitMiB`
|
|
78
|
+
*/
|
|
79
|
+
export declare function validateEc2ServiceSizing(service: Ec2ServiceSizingInput): void;
|
|
26
80
|
/**
|
|
27
81
|
* Domain-config constraints shared by the resources-layer hook
|
|
28
82
|
* (`validateEcsClusterProps`) and the patterns-layer mirror
|
|
@@ -52,3 +106,4 @@ export declare function validateSsmPathComponent(component: string, fieldName: s
|
|
|
52
106
|
* reference the fjall secrets tooling can never write.
|
|
53
107
|
*/
|
|
54
108
|
export declare function validateSecretName(name: string, context: string): void;
|
|
109
|
+
export {};
|
|
@@ -2,6 +2,7 @@ import { NetworkMode } from "aws-cdk-lib/aws-ecs";
|
|
|
2
2
|
import { evaluateBakeGuard } from "@fjall/util/docker";
|
|
3
3
|
import { toKebab, SSM_COMPONENT_PATTERN, SSM_COMPONENT_ERROR, SECRET_NAME_PATTERN, SECRET_NAME_ERROR } from "@fjall/util";
|
|
4
4
|
import { ScalingType } from "./ecsTypes.js";
|
|
5
|
+
import { DEFAULT_EC2_CONTAINER_MEMORY_MIB } from "./ecsConstants.js";
|
|
5
6
|
/**
|
|
6
7
|
* Validates ECS cluster props before construction.
|
|
7
8
|
* Pure function — does not depend on class state.
|
|
@@ -121,6 +122,7 @@ export function validateEcsClusterProps(props) {
|
|
|
121
122
|
throw new Error(`Service '${service.name}' uses EC2 capacity provider but no ec2Config is defined. ` +
|
|
122
123
|
"Provide ec2Config on the service.");
|
|
123
124
|
}
|
|
125
|
+
validateEc2ServiceSizing(service);
|
|
124
126
|
if (service.deployment !== undefined) {
|
|
125
127
|
const min = service.deployment.minHealthyPercent;
|
|
126
128
|
const max = service.deployment.maxHealthyPercent;
|
|
@@ -188,6 +190,70 @@ export function validateEcsClusterProps(props) {
|
|
|
188
190
|
}
|
|
189
191
|
}
|
|
190
192
|
}
|
|
193
|
+
/**
|
|
194
|
+
* Rejects service-level task sizing that is silently ignored for EC2 capacity.
|
|
195
|
+
*
|
|
196
|
+
* For an EC2-capacity ECS service the container's hard memory limit is derived
|
|
197
|
+
* from `ec2Config.memoryLimitMiB` (default {@link DEFAULT_EC2_CONTAINER_MEMORY_MIB}),
|
|
198
|
+
* NOT from the service-level `memoryLimitMiB` — that field only feeds the
|
|
199
|
+
* Fargate task definition (`createTaskDefinition` in `ecsTaskDefinition.ts`: the
|
|
200
|
+
* EC2 branch passes neither `cpu` nor `memoryLimitMiB`; the container's memory
|
|
201
|
+
* comes solely from the `ec2Config.memoryLimitMiB ?? default` spread). A
|
|
202
|
+
* service-level `cpu` has no EC2 analogue at all — EC2 task definitions carry no
|
|
203
|
+
* task-level CPU reservation and `Ec2CapacityConfig` has no `cpu` field — so it
|
|
204
|
+
* is always dead on an EC2 service. Both fields read as "size the task to N"
|
|
205
|
+
* while the deployed container ignores the value.
|
|
206
|
+
*
|
|
207
|
+
* Fail loud at synth rather than silently honour the value: silently honouring
|
|
208
|
+
* memory would rewrite deployed container memory in BOTH directions — raise it
|
|
209
|
+
* past the EC2 instance's capacity (failure-to-place mid-rollout) or lower it
|
|
210
|
+
* into the service-level [512,1023] band below the 1024 EC2 default (fresh OOM).
|
|
211
|
+
* Rejecting changes nothing deployed; it converts two silent misconfigs into a
|
|
212
|
+
* synth error with a named cure.
|
|
213
|
+
*
|
|
214
|
+
* `both-set-and-equal` is allowed for memory — a config that mirrors
|
|
215
|
+
* `ec2Config.memoryLimitMiB` into the service-level field is self-consistent and
|
|
216
|
+
* the derived container memory is unchanged. `cpu` has no such carve-out (there
|
|
217
|
+
* is no `ec2Config.cpu` to agree with); it always throws on EC2.
|
|
218
|
+
*
|
|
219
|
+
* Called from BOTH validation layers — resources `validateEcsClusterProps` and
|
|
220
|
+
* patterns `validateEcsProps` — so the guard cannot drift between them
|
|
221
|
+
* (code-quality § "Coupled values: shared source at 2 occurrences"; mirrors the
|
|
222
|
+
* `validateEcsDomainConfig` shared-helper precedent below).
|
|
223
|
+
*
|
|
224
|
+
* @param service - The service props to validate (no-op unless EC2 capacity)
|
|
225
|
+
* @throws Error if an EC2 service carries an ignored service-level `cpu`, or a
|
|
226
|
+
* service-level `memoryLimitMiB` that is absent from / conflicts with
|
|
227
|
+
* `ec2Config.memoryLimitMiB`
|
|
228
|
+
*/
|
|
229
|
+
export function validateEc2ServiceSizing(service) {
|
|
230
|
+
if (service.capacityProvider !== "EC2")
|
|
231
|
+
return;
|
|
232
|
+
if (service.memoryLimitMiB !== undefined) {
|
|
233
|
+
const ec2Memory = service.ec2Config?.memoryLimitMiB;
|
|
234
|
+
if (ec2Memory === undefined) {
|
|
235
|
+
throw new Error(`Service '${service.name}': service-level memoryLimitMiB (${service.memoryLimitMiB}) is ignored ` +
|
|
236
|
+
"for EC2 capacity — the container's hard memory limit comes from ec2Config.memoryLimitMiB " +
|
|
237
|
+
`(default ${DEFAULT_EC2_CONTAINER_MEMORY_MIB} MiB), not the service-level field. Set ` +
|
|
238
|
+
`ec2Config.memoryLimitMiB: ${service.memoryLimitMiB} to size the container, or remove the ` +
|
|
239
|
+
"service-level memoryLimitMiB. (Service-level memoryLimitMiB only applies to Fargate services.)");
|
|
240
|
+
}
|
|
241
|
+
if (ec2Memory !== service.memoryLimitMiB) {
|
|
242
|
+
throw new Error(`Service '${service.name}': conflicting memory limits — service-level memoryLimitMiB ` +
|
|
243
|
+
`(${service.memoryLimitMiB}) disagrees with ec2Config.memoryLimitMiB (${ec2Memory}). For EC2 ` +
|
|
244
|
+
"capacity only ec2Config.memoryLimitMiB sizes the container; the service-level value is ignored. " +
|
|
245
|
+
"Set both to the same value or remove the service-level memoryLimitMiB.");
|
|
246
|
+
}
|
|
247
|
+
// both-set-and-equal falls through untouched (allowed): the config is
|
|
248
|
+
// self-consistent and the derived container memory is unchanged.
|
|
249
|
+
}
|
|
250
|
+
if (service.cpu !== undefined) {
|
|
251
|
+
throw new Error(`Service '${service.name}': service-level cpu (${service.cpu}) is ignored for EC2 capacity — ` +
|
|
252
|
+
"EC2 task definitions carry no task-level CPU reservation (CPU is bounded by the instance type) " +
|
|
253
|
+
"and there is no ec2Config.cpu field. Remove the service-level cpu. " +
|
|
254
|
+
"(Service-level cpu only applies to Fargate services.)");
|
|
255
|
+
}
|
|
256
|
+
}
|
|
191
257
|
/**
|
|
192
258
|
* Domain-config constraints shared by the resources-layer hook
|
|
193
259
|
* (`validateEcsClusterProps`) and the patterns-layer mirror
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { type EngineCompat } from "@fjall/util/manifest/schemas";
|
|
2
|
+
/**
|
|
3
|
+
* The constructs' own package version — the assembly's `synthesisedBy`.
|
|
4
|
+
* `undefined` only when the package layout is broken enough that the upward
|
|
5
|
+
* walk finds no matching `package.json`.
|
|
6
|
+
*/
|
|
7
|
+
export declare const CONSTRUCTS_VERSION: string | undefined;
|
|
8
|
+
/**
|
|
9
|
+
* Pure core of the emitter: derive the `engineCompat` block from a given
|
|
10
|
+
* version string (or undefined). Both the absent/empty guard and the
|
|
11
|
+
* `${major}.0.0` non-integer-major derivation live here — the single source of
|
|
12
|
+
* the derivation, delegated to by both `MINIMUM_ENGINE_VERSION` and
|
|
13
|
+
* `buildEngineCompat` — so the fail-safe and malformed-version paths are
|
|
14
|
+
* unit-testable directly, without mocking module-load state. Returns undefined
|
|
15
|
+
* when the version is absent, empty, or has no valid integer major (fail-safe:
|
|
16
|
+
* the caller stamps no block).
|
|
17
|
+
*/
|
|
18
|
+
export declare function buildEngineCompatFrom(version: string | undefined): EngineCompat | undefined;
|
|
19
|
+
/**
|
|
20
|
+
* The compatible deploy-engine floor for assemblies this constructs version
|
|
21
|
+
* synthesises: `${major}.0.0`, derived from `CONSTRUCTS_VERSION`. `undefined`
|
|
22
|
+
* when the version is unresolved or has a non-integer major (in which case
|
|
23
|
+
* `buildEngineCompat` stamps no block — fail-safe).
|
|
24
|
+
*/
|
|
25
|
+
export declare const MINIMUM_ENGINE_VERSION: string | undefined;
|
|
26
|
+
/**
|
|
27
|
+
* Build the `engineCompat` block to stamp into the manifest, or `undefined`
|
|
28
|
+
* when the constructs' own version cannot be resolved (fail-safe: the caller
|
|
29
|
+
* stamps no block and the deploy degrades to a constraint-free proceed).
|
|
30
|
+
*
|
|
31
|
+
* Phase 1 emits only the two required fields. The verdict classification of the
|
|
32
|
+
* emitted floor is proven in `@fjall/deploy-core`'s
|
|
33
|
+
* `orchestration/application/__tests__/engineCompat.test.ts` (a `4.0.0` floor
|
|
34
|
+
* refuses a 3.x engine, proceeds for 4.x/5.x); this module's tests prove the
|
|
35
|
+
* emitted shape matches that floor and reads back through the gate's reader.
|
|
36
|
+
*/
|
|
37
|
+
export declare function buildEngineCompat(): EngineCompat | undefined;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Synth-time engine-compatibility stamp (the contract's self-honest emitter).
|
|
3
|
+
*
|
|
4
|
+
* The constructs package is the one place that knows which deploy engine can
|
|
5
|
+
* safely deploy the assembly THIS version of the constructs produced, so it is
|
|
6
|
+
* the correct emitter of the engine ↔ assembly version contract. At synth,
|
|
7
|
+
* `writeManifest` stamps the `engineCompat` block this module builds into
|
|
8
|
+
* `cdk.out/fjall-manifest.json`; the deploy engine (`@fjall/deploy-core`) reads
|
|
9
|
+
* it back at deploy-start (`readEngineCompatEnvelope` + `evaluateEngineCompat`)
|
|
10
|
+
* and refuses an incompatible pairing before any build / push / CloudFormation
|
|
11
|
+
* mutation. Both the CLI and the webapp worker inherit that one chokepoint.
|
|
12
|
+
*
|
|
13
|
+
* `synthesisedBy` is the constructs' OWN package version — audit/diagnostics
|
|
14
|
+
* only, never a gate input. It is resolved at module load by the same upward
|
|
15
|
+
* `package.json` walk `@fjall/deploy-core`'s `DEPLOY_CORE_VERSION` uses: the
|
|
16
|
+
* source layout (`lib/utils/engineCompat.ts`) and the compiled layout
|
|
17
|
+
* (`dist/lib/utils/engineCompat.js`) sit at different depths from the package
|
|
18
|
+
* root, so a fixed relative path would resolve the wrong file in one of them;
|
|
19
|
+
* matching on `name` finds the right `package.json` from both, and skips any
|
|
20
|
+
* unrelated `package.json` higher up (e.g. a consuming app's).
|
|
21
|
+
*
|
|
22
|
+
* `minimumEngineVersion` is the PRIMARY refuse gate. Under Lerna FIXED lockstep
|
|
23
|
+
* every `@fjall/*` package shares one version, so the compatible engine floor
|
|
24
|
+
* is this constructs major with a zeroed minor/patch (`${major}.0.0`) — DERIVED,
|
|
25
|
+
* never hardcoded, so it advances to `5.0.0` on the next major with no
|
|
26
|
+
* hand-edit. A same-major-or-newer engine proceeds; an older-major engine
|
|
27
|
+
* hard-refuses.
|
|
28
|
+
*
|
|
29
|
+
* The optional `maximumEngineMajor` / `minimumAwsCdkCli` bounds are deliberately
|
|
30
|
+
* NOT emitted in Phase 1 — they are ADR open question Q5 (engine-ahead cap) and
|
|
31
|
+
* the Phase-2 FM1 aws-cdk floor graft respectively, both unresolved and
|
|
32
|
+
* deferred. This emitter stamps only the two required fields.
|
|
33
|
+
*
|
|
34
|
+
* Fail-safe: if the constructs' own version cannot be resolved (a broken
|
|
35
|
+
* package layout), `buildEngineCompat` returns undefined and the caller stamps
|
|
36
|
+
* NO block — degrading to the constraint-free "proceed" behaviour that predates
|
|
37
|
+
* this contract rather than breaking synth. A resolution failure is logged, not
|
|
38
|
+
* silently swallowed.
|
|
39
|
+
*/
|
|
40
|
+
import { fileURLToPath } from "url";
|
|
41
|
+
import { dirname, join, parse } from "path";
|
|
42
|
+
import { existsSync, readFileSync } from "fs";
|
|
43
|
+
import { getErrorMessage, maskSensitiveOutput } from "@fjall/util";
|
|
44
|
+
import { FjallLogger } from "./validationLogger.js";
|
|
45
|
+
const PACKAGE_NAME = "@fjall/components-infrastructure";
|
|
46
|
+
function resolveConstructsVersion() {
|
|
47
|
+
try {
|
|
48
|
+
let dir = dirname(fileURLToPath(import.meta.url));
|
|
49
|
+
const { root } = parse(dir);
|
|
50
|
+
for (;;) {
|
|
51
|
+
const candidate = join(dir, "package.json");
|
|
52
|
+
if (existsSync(candidate)) {
|
|
53
|
+
const pkg = JSON.parse(readFileSync(candidate, "utf8"));
|
|
54
|
+
if (pkg.name === PACKAGE_NAME &&
|
|
55
|
+
typeof pkg.version === "string" &&
|
|
56
|
+
pkg.version.length > 0) {
|
|
57
|
+
return pkg.version;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (dir === root)
|
|
61
|
+
return undefined;
|
|
62
|
+
dir = dirname(dir);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
FjallLogger.warn(`Could not resolve ${PACKAGE_NAME} version for engineCompat: ` +
|
|
67
|
+
maskSensitiveOutput(getErrorMessage(error)));
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* The constructs' own package version — the assembly's `synthesisedBy`.
|
|
73
|
+
* `undefined` only when the package layout is broken enough that the upward
|
|
74
|
+
* walk finds no matching `package.json`.
|
|
75
|
+
*/
|
|
76
|
+
export const CONSTRUCTS_VERSION = resolveConstructsVersion();
|
|
77
|
+
/**
|
|
78
|
+
* Pure core of the emitter: derive the `engineCompat` block from a given
|
|
79
|
+
* version string (or undefined). Both the absent/empty guard and the
|
|
80
|
+
* `${major}.0.0` non-integer-major derivation live here — the single source of
|
|
81
|
+
* the derivation, delegated to by both `MINIMUM_ENGINE_VERSION` and
|
|
82
|
+
* `buildEngineCompat` — so the fail-safe and malformed-version paths are
|
|
83
|
+
* unit-testable directly, without mocking module-load state. Returns undefined
|
|
84
|
+
* when the version is absent, empty, or has no valid integer major (fail-safe:
|
|
85
|
+
* the caller stamps no block).
|
|
86
|
+
*/
|
|
87
|
+
export function buildEngineCompatFrom(version) {
|
|
88
|
+
if (version === undefined || version.length === 0)
|
|
89
|
+
return undefined;
|
|
90
|
+
const major = Number(version.split(".")[0]);
|
|
91
|
+
if (!Number.isInteger(major) || major < 0)
|
|
92
|
+
return undefined;
|
|
93
|
+
return {
|
|
94
|
+
synthesisedBy: version,
|
|
95
|
+
minimumEngineVersion: `${major}.0.0`
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* The compatible deploy-engine floor for assemblies this constructs version
|
|
100
|
+
* synthesises: `${major}.0.0`, derived from `CONSTRUCTS_VERSION`. `undefined`
|
|
101
|
+
* when the version is unresolved or has a non-integer major (in which case
|
|
102
|
+
* `buildEngineCompat` stamps no block — fail-safe).
|
|
103
|
+
*/
|
|
104
|
+
export const MINIMUM_ENGINE_VERSION = buildEngineCompatFrom(CONSTRUCTS_VERSION)?.minimumEngineVersion;
|
|
105
|
+
/**
|
|
106
|
+
* Build the `engineCompat` block to stamp into the manifest, or `undefined`
|
|
107
|
+
* when the constructs' own version cannot be resolved (fail-safe: the caller
|
|
108
|
+
* stamps no block and the deploy degrades to a constraint-free proceed).
|
|
109
|
+
*
|
|
110
|
+
* Phase 1 emits only the two required fields. The verdict classification of the
|
|
111
|
+
* emitted floor is proven in `@fjall/deploy-core`'s
|
|
112
|
+
* `orchestration/application/__tests__/engineCompat.test.ts` (a `4.0.0` floor
|
|
113
|
+
* refuses a 3.x engine, proceeds for 4.x/5.x); this module's tests prove the
|
|
114
|
+
* emitted shape matches that floor and reads back through the gate's reader.
|
|
115
|
+
*/
|
|
116
|
+
export function buildEngineCompat() {
|
|
117
|
+
return buildEngineCompatFrom(CONSTRUCTS_VERSION);
|
|
118
|
+
}
|
|
@@ -19,6 +19,7 @@ import { createHash } from "crypto";
|
|
|
19
19
|
import { buildConstructMap, constructMapToRecord, ACCOUNT_CONSTRUCT_GROUPS } from "@fjall/util/constructMap";
|
|
20
20
|
import { getErrorMessage, maskSensitiveOutput } from "@fjall/util";
|
|
21
21
|
import { FJALL_MANIFEST_FILENAME, MANIFEST_SCHEMA_VERSION, FjallManifestSchema } from "@fjall/util/manifest/schemas";
|
|
22
|
+
import { buildEngineCompat } from "./engineCompat.js";
|
|
22
23
|
import { FjallLogger } from "./validationLogger.js";
|
|
23
24
|
/**
|
|
24
25
|
* Collected manifest data during synthesis.
|
|
@@ -170,6 +171,12 @@ export function writeManifest(assembly, collector) {
|
|
|
170
171
|
const stacks = computeStackHashes(cdkOutPath);
|
|
171
172
|
// Build resource map from CDK manifest metadata
|
|
172
173
|
const constructMap = buildConstructMap(cdkOutPath, collector.getConstructGroups());
|
|
174
|
+
const engineCompat = buildEngineCompat();
|
|
175
|
+
if (engineCompat === undefined) {
|
|
176
|
+
FjallLogger.warn("Could not determine constructs version — manifest stamped with no " +
|
|
177
|
+
"engineCompat block; the deploy engine will proceed without a " +
|
|
178
|
+
"version-compatibility gate.");
|
|
179
|
+
}
|
|
173
180
|
const manifest = {
|
|
174
181
|
version: MANIFEST_SCHEMA_VERSION,
|
|
175
182
|
generatedAt: new Date().toISOString(),
|
|
@@ -177,6 +184,7 @@ export function writeManifest(assembly, collector) {
|
|
|
177
184
|
services: collector.getServices(),
|
|
178
185
|
lambdas: collector.getLambdas(),
|
|
179
186
|
stacks,
|
|
187
|
+
...(engineCompat !== undefined ? { engineCompat } : {}),
|
|
180
188
|
...(constructMap.size > 0
|
|
181
189
|
? { resourceMap: constructMapToRecord(constructMap) }
|
|
182
190
|
: {})
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fjall/components-infrastructure",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.3.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": "^4.
|
|
71
|
-
"@fjall/util": "^4.
|
|
70
|
+
"@fjall/generator": "^4.3.0",
|
|
71
|
+
"@fjall/util": "^4.3.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": "8a3ae096f9d15c47dc8c277c9bddfa40e4168c19"
|
|
86
86
|
}
|