@fjall/components-infrastructure 33.0.0 → 34.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.
- package/README.md +15 -76
- package/dist/lib/app.d.ts +1 -1
- package/dist/lib/config/aws/identityCentreConfig.d.ts +1 -1
- package/dist/lib/config/aws/identityCentreConfig.js +1 -1
- package/dist/lib/config/aws/scpPreset.js +12 -3
- package/dist/lib/lambda-assets/cert-generator/asset/index.js +126 -0
- package/dist/lib/patterns/aws/bastionFactory.js +2 -1
- package/dist/lib/patterns/aws/buildkite/buildkite.d.ts +1 -1
- package/dist/lib/patterns/aws/buildkite/buildkite.js +1 -1
- package/dist/lib/patterns/aws/buildkite/factory.d.ts +2 -2
- package/dist/lib/patterns/aws/buildkite/pins.d.ts +2 -2
- package/dist/lib/patterns/aws/buildkite/pins.js +2 -2
- package/dist/lib/patterns/aws/buildkite/schema.d.ts +1 -1
- package/dist/lib/patterns/aws/buildkite/schema.js +1 -1
- package/dist/lib/patterns/aws/buildkite/userData.d.ts +1 -1
- package/dist/lib/patterns/aws/buildkite/userData.js +1 -1
- package/dist/lib/patterns/aws/buildkite.d.ts +1 -1
- package/dist/lib/patterns/aws/buildkite.js +1 -1
- package/dist/lib/patterns/aws/cdn.d.ts +4 -1
- package/dist/lib/patterns/aws/clickhouseDatabase.js +6 -5
- package/dist/lib/patterns/aws/clickhouseTls/types.d.ts +1 -1
- package/dist/lib/patterns/aws/compute.d.ts +2 -2
- package/dist/lib/patterns/aws/computeEc2.d.ts +5 -18
- package/dist/lib/patterns/aws/computeEc2.js +9 -3
- package/dist/lib/patterns/aws/computeEcs.d.ts +4 -4
- package/dist/lib/patterns/aws/computeEcs.js +5 -9
- package/dist/lib/patterns/aws/computeEcsTypes.d.ts +5 -5
- package/dist/lib/patterns/aws/computePropApplicability.js +1 -1
- package/dist/lib/patterns/aws/fivetranProxy.d.ts +8 -3
- package/dist/lib/patterns/aws/fivetranProxy.js +9 -6
- package/dist/lib/patterns/aws/interfaces/compute.d.ts +8 -0
- package/dist/lib/patterns/aws/payload.js +6 -2
- package/dist/lib/patterns/aws/storage.d.ts +8 -0
- package/dist/lib/patterns/aws/storage.js +1 -0
- package/dist/lib/resources/aws/cdn/cloudFront.d.ts +16 -0
- package/dist/lib/resources/aws/cdn/cloudFront.js +52 -2
- package/dist/lib/resources/aws/cdn/responseHeadersPolicy.d.ts +1 -1
- package/dist/lib/resources/aws/cdn/responseHeadersPolicy.js +1 -1
- package/dist/lib/resources/aws/compute/ec2.d.ts +35 -8
- package/dist/lib/resources/aws/compute/ec2.js +64 -15
- package/dist/lib/resources/aws/compute/ec2SshAccess.d.ts +80 -0
- package/dist/lib/resources/aws/compute/ec2SshAccess.js +166 -0
- package/dist/lib/resources/aws/compute/ecs.d.ts +11 -2
- package/dist/lib/resources/aws/compute/ecs.js +29 -6
- package/dist/lib/resources/aws/compute/lambda.js +1 -3
- package/dist/lib/resources/aws/database/clickhouseConstants.d.ts +5 -5
- package/dist/lib/resources/aws/database/clickhouseConstants.js +5 -5
- package/dist/lib/resources/aws/database/dynamodb.js +5 -6
- package/dist/lib/resources/aws/iam/identityCenter/user.d.ts +1 -1
- package/dist/lib/resources/aws/iam/identityCenter/user.js +1 -1
- package/dist/lib/resources/aws/logging/accessLogBucket.d.ts +5 -3
- package/dist/lib/resources/aws/logging/accessLogBucket.js +6 -3
- package/dist/lib/resources/aws/messaging/sns.d.ts +17 -7
- package/dist/lib/resources/aws/messaging/sns.js +4 -4
- package/dist/lib/resources/aws/secrets/stackEncryptionKey.d.ts +17 -4
- package/dist/lib/resources/aws/secrets/stackEncryptionKey.js +20 -4
- package/dist/lib/resources/aws/storage/bucketDeployment.d.ts +2 -3
- package/dist/lib/resources/aws/storage/bucketDeployment.js +2 -3
- package/dist/lib/resources/aws/storage/s3.d.ts +14 -1
- package/dist/lib/resources/aws/storage/s3.js +6 -3
- package/package.json +6 -4
|
@@ -182,8 +182,8 @@ export interface EcsContainerConfig {
|
|
|
182
182
|
* per service. Suitable for high-replica services where the init-container
|
|
183
183
|
* tax dominates rollout time.
|
|
184
184
|
*
|
|
185
|
-
* @see
|
|
186
|
-
* @see
|
|
185
|
+
* @see the 2026-04-29 decision: ECS init container over RunTask
|
|
186
|
+
* @see the 2026-04-29 ECS factory-hardening plan, § 4.3.4 — threshold
|
|
187
187
|
* guidance for choosing between modes.
|
|
188
188
|
* @see https://aws.amazon.com/blogs/containers/announcing-amazon-ecs-deployment-lifecycle-hooks/
|
|
189
189
|
*/
|
|
@@ -223,7 +223,7 @@ export type EcsMigrationsMode = "init-container" | "lifecycle-hook" | "post-depl
|
|
|
223
223
|
* mode: "lifecycle-hook"
|
|
224
224
|
* }
|
|
225
225
|
*
|
|
226
|
-
* @see
|
|
226
|
+
* @see the 2026-04-29 decision: ECS init container over RunTask
|
|
227
227
|
*/
|
|
228
228
|
/**
|
|
229
229
|
* Discriminated on `mode`. The init-container variant keeps its narrow surface
|
|
@@ -237,8 +237,8 @@ export type EcsMigrationsMode = "init-container" | "lifecycle-hook" | "post-depl
|
|
|
237
237
|
* lifecycle-hook-only field — is a typecheck error. This protects against
|
|
238
238
|
* the Pitfall-5 trap (silent field drift across union variants).
|
|
239
239
|
*
|
|
240
|
-
* @see
|
|
241
|
-
* @see
|
|
240
|
+
* @see the 2026-05-12 migration-runner abstraction-choice design
|
|
241
|
+
* @see the TypeScript standards, Zod Pitfall 5 (three-way option drift)
|
|
242
242
|
*/
|
|
243
243
|
export type EcsMigrationsConfig = EcsInitContainerMigrationsConfig | EcsLifecycleHookMigrationsConfig | EcsPostDeployMigrationsConfig;
|
|
244
244
|
/**
|
|
@@ -3,15 +3,20 @@ import { type Construct } from "constructs";
|
|
|
3
3
|
import { type KeyValue } from "../../types.js";
|
|
4
4
|
import { type Connections } from "aws-cdk-lib/aws-ec2";
|
|
5
5
|
import { type IVpc } from "aws-cdk-lib/aws-ec2";
|
|
6
|
-
interface
|
|
6
|
+
export interface FivetranProxyProps extends StackProps {
|
|
7
7
|
accountId?: string;
|
|
8
8
|
vpc?: IVpc;
|
|
9
9
|
publicKey: string;
|
|
10
10
|
instanceType: string;
|
|
11
|
+
/**
|
|
12
|
+
* Source ranges Fivetran connects from, one port-22 ingress rule each.
|
|
13
|
+
* Fivetran publishes its addresses per region; the proxy ships no
|
|
14
|
+
* world-open default.
|
|
15
|
+
*/
|
|
16
|
+
allowedIpCidrs: string[];
|
|
11
17
|
databaseConnection?: Connections;
|
|
12
18
|
tags?: KeyValue;
|
|
13
19
|
}
|
|
14
20
|
export declare class FivetranProxy extends Stack {
|
|
15
|
-
constructor(scope: Construct, id: string, props:
|
|
21
|
+
constructor(scope: Construct, id: string, props: FivetranProxyProps);
|
|
16
22
|
}
|
|
17
|
-
export {};
|
|
@@ -4,6 +4,10 @@ import { UserData } from "aws-cdk-lib/aws-ec2";
|
|
|
4
4
|
export class FivetranProxy extends Stack {
|
|
5
5
|
constructor(scope, id, props) {
|
|
6
6
|
super(scope, id, props);
|
|
7
|
+
if (!Array.isArray(props.allowedIpCidrs) ||
|
|
8
|
+
props.allowedIpCidrs.length === 0) {
|
|
9
|
+
throw new Error("FivetranProxy.allowedIpCidrs must list at least one IPv4 CIDR: the ranges Fivetran connects from for your region. The proxy ships no world-open default.");
|
|
10
|
+
}
|
|
7
11
|
const userData = UserData.forLinux();
|
|
8
12
|
userData.addCommands("groupadd fivetran", "useradd -m -g fivetran fivetran", "mkdir -p /home/fivetran/.ssh", "chown fivetran:fivetran /home/fivetran/.ssh", "chmod 700 /home/fivetran/.ssh", `echo "${props.publicKey}" > /home/fivetran/.ssh/authorized_keys`, "chown fivetran:fivetran /home/fivetran/.ssh/authorized_keys", "chmod 600 /home/fivetran/.ssh/authorized_keys");
|
|
9
13
|
new Ec2Instance(this, "FivetranProxy", {
|
|
@@ -11,12 +15,11 @@ export class FivetranProxy extends Stack {
|
|
|
11
15
|
serviceName: "fivetranProxy",
|
|
12
16
|
vpc: props.vpc,
|
|
13
17
|
instanceType: props.instanceType || "t2.micro",
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
|
|
19
|
-
enableSSH: true,
|
|
18
|
+
// This proxy exists so Fivetran can reach a private database over SSH
|
|
19
|
+
// (it provisions the `fivetran` user and its authorized_keys above), so
|
|
20
|
+
// the instance must be publicly reachable on port 22. `public` is
|
|
21
|
+
// therefore not the caller's choice; the source range is.
|
|
22
|
+
sshAccess: { allowedIpCidrs: props.allowedIpCidrs, public: true },
|
|
20
23
|
env: {
|
|
21
24
|
account: props.env?.account,
|
|
22
25
|
region: props.env?.region
|
|
@@ -73,6 +73,14 @@ export interface IEcsCompute extends ICompute, IConnectable {
|
|
|
73
73
|
* that front this compute. Undefined when there is no load balancer.
|
|
74
74
|
*/
|
|
75
75
|
getIngressProfile(): EcsIngressProfile | undefined;
|
|
76
|
+
/**
|
|
77
|
+
* Grant an operator principal what an `aws ecs execute-command` session
|
|
78
|
+
* needs against this cluster: `ecs:ExecuteCommand` scoped to the cluster
|
|
79
|
+
* and its tasks, plus `kms:GenerateDataKey` on the stack key when the
|
|
80
|
+
* session channel is key-encrypted (backup tier `enterprise`).
|
|
81
|
+
* @param grantee The operator principal
|
|
82
|
+
*/
|
|
83
|
+
grantExecuteCommand(grantee: IGrantable): Grant;
|
|
76
84
|
}
|
|
77
85
|
/**
|
|
78
86
|
* Lambda compute interface.
|
|
@@ -217,8 +217,10 @@ export class Payload extends Construct {
|
|
|
217
217
|
createAssetsBucket() {
|
|
218
218
|
const assetsConfig = this.props.storage?.assets ?? {};
|
|
219
219
|
const assetsProps = {
|
|
220
|
-
// Build output, pruned on every deploy — versioning would only bill for
|
|
220
|
+
// Build output, pruned on every deploy — versioning would only bill for
|
|
221
|
+
// churn; the exemption stamp tells posture the omission is by design.
|
|
221
222
|
versioned: assetsConfig.versioned ?? false,
|
|
223
|
+
versioningExemption: "build-output",
|
|
222
224
|
deployment: {
|
|
223
225
|
source: `${PAYLOAD_DEFAULTS.SOURCE}/${PAYLOAD_DEFAULTS.OPENNEXT.ASSETS}`,
|
|
224
226
|
prune: true,
|
|
@@ -233,8 +235,10 @@ export class Payload extends Construct {
|
|
|
233
235
|
createCacheBucket() {
|
|
234
236
|
const cacheConfig = this.props.storage?.cache ?? {};
|
|
235
237
|
const cacheProps = {
|
|
236
|
-
// ISR cache, regenerated at runtime — versioning would only bill for
|
|
238
|
+
// ISR cache, regenerated at runtime — versioning would only bill for
|
|
239
|
+
// churn; the exemption stamp tells posture the omission is by design.
|
|
237
240
|
versioned: cacheConfig.versioned ?? false,
|
|
241
|
+
versioningExemption: "isr-cache",
|
|
238
242
|
deployment: {
|
|
239
243
|
source: `${PAYLOAD_DEFAULTS.SOURCE}/${PAYLOAD_DEFAULTS.OPENNEXT.CACHE}`,
|
|
240
244
|
prune: false // ISR adds files at runtime
|
|
@@ -2,6 +2,7 @@ import { Construct } from "constructs";
|
|
|
2
2
|
import { type EventType, type IBucketNotificationDestination, type NotificationKeyFilter } from "aws-cdk-lib/aws-s3";
|
|
3
3
|
import { type IDistribution } from "aws-cdk-lib/aws-cloudfront";
|
|
4
4
|
import { type IGrantable, type Grant } from "aws-cdk-lib/aws-iam";
|
|
5
|
+
import { type S3VersioningExemption } from "@fjall/util/aws";
|
|
5
6
|
import type App from "../../app.js";
|
|
6
7
|
import { BucketDeployment, S3Bucket, type ResourcePolicyStatement, type WebsiteHostingConfig } from "../../resources/aws/storage/index.js";
|
|
7
8
|
import { type BackupTier } from "../../utils/backupTierMapping.js";
|
|
@@ -39,6 +40,13 @@ export interface S3Props {
|
|
|
39
40
|
* `resilient`/`enterprise`, off otherwise. An explicit value always wins.
|
|
40
41
|
*/
|
|
41
42
|
readonly versioned?: boolean;
|
|
43
|
+
/**
|
|
44
|
+
* Why this bucket runs unversioned by design (`build-output`, `isr-cache`,
|
|
45
|
+
* `cold-tier`) — see `S3BucketProps.versioningExemption`. Stamped as the
|
|
46
|
+
* `fjall:s3:versioning-exempt` tag when versioning resolves off; a bucket
|
|
47
|
+
* that versions after all is not stamped.
|
|
48
|
+
*/
|
|
49
|
+
readonly versioningExemption?: S3VersioningExemption;
|
|
42
50
|
readonly encryption?: "AES256" | "KMS";
|
|
43
51
|
readonly kmsKeyArn?: string;
|
|
44
52
|
/**
|
|
@@ -78,6 +78,7 @@ export class Storage extends Construct {
|
|
|
78
78
|
aliasTargetName: props.bucketName ?? id,
|
|
79
79
|
appName: props.appName,
|
|
80
80
|
versioned: props.versioned,
|
|
81
|
+
versioningExemption: props.versioningExemption,
|
|
81
82
|
encryption: toBucketEncryption(props.encryption),
|
|
82
83
|
encryptionKey,
|
|
83
84
|
...(props.bucketKeyEnabled !== undefined && {
|
|
@@ -58,7 +58,23 @@ export interface CloudFrontDistributionProps {
|
|
|
58
58
|
domainNames?: string[];
|
|
59
59
|
certificate?: ICertificate;
|
|
60
60
|
comment?: string;
|
|
61
|
+
/**
|
|
62
|
+
* Standard (legacy) S3 access logging. It cannot deliver to a bucket in an
|
|
63
|
+
* opt-in region (`af-south-1`, `ap-east-1`, …): CloudFormation rejects the
|
|
64
|
+
* distribution at deploy, so a concrete opt-in delivery region is refused
|
|
65
|
+
* at synth instead — cure with `enableLogging: false`, or a `logBucket`
|
|
66
|
+
* whose region CDK knows to be default-enabled (see `logBucket`).
|
|
67
|
+
*/
|
|
61
68
|
enableLogging?: boolean;
|
|
69
|
+
/**
|
|
70
|
+
* Delivery bucket for standard logs; without it CDK creates one in the
|
|
71
|
+
* distribution's own stack (and region). Its `env.region` is the delivery
|
|
72
|
+
* region the opt-in refusal on `enableLogging` checks, so an imported
|
|
73
|
+
* bucket must declare it: `Bucket.fromBucketAttributes(…, { region })`, or
|
|
74
|
+
* a `Bucket` in a stack pinned to its region. `fromBucketName` and
|
|
75
|
+
* `fromBucketArn` inherit this stack's region, and a token bucket region
|
|
76
|
+
* falls back to it.
|
|
77
|
+
*/
|
|
62
78
|
logBucket?: IBucket;
|
|
63
79
|
/** Key prefix for standard logs delivered to `logBucket`. */
|
|
64
80
|
logFilePrefix?: string;
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { Construct } from "constructs";
|
|
2
|
-
import { CfnOutput, Names, Stack } from "aws-cdk-lib";
|
|
2
|
+
import { CfnOutput, Names, Stack, Token } from "aws-cdk-lib";
|
|
3
3
|
import { toPascalCase } from "../../../utils/capitaliseString.js";
|
|
4
4
|
import { registerCdnAliasTarget } from "../../../utils/cdnAliasTargetRegistry.js";
|
|
5
|
-
import { cdnDomainExportName } from "@fjall/util";
|
|
5
|
+
import { cdnDomainExportName, getRegionInfo, OPT_IN_REGION_CODES } from "@fjall/util";
|
|
6
6
|
import { Distribution, PriceClass, ViewerProtocolPolicy, CachePolicy, OriginRequestPolicy, AllowedMethods, OriginProtocolPolicy, Function as CloudFrontFunction, FunctionCode, FunctionRuntime, FunctionEventType, OriginAccessIdentity, S3OriginAccessControl } from "aws-cdk-lib/aws-cloudfront";
|
|
7
7
|
import { S3BucketOrigin, LoadBalancerV2Origin, HttpOrigin } from "aws-cdk-lib/aws-cloudfront-origins";
|
|
8
8
|
/**
|
|
@@ -42,6 +42,55 @@ const URI_REWRITE_FRAGMENTS = {
|
|
|
42
42
|
" else { var seg = uri.substring(uri.lastIndexOf('/') + 1);" +
|
|
43
43
|
" if (seg.indexOf('.') === -1) { request.uri = uri + '/index.html'; } } }"
|
|
44
44
|
};
|
|
45
|
+
/**
|
|
46
|
+
* CloudFront standard (legacy) logging cannot deliver to an S3 bucket in an
|
|
47
|
+
* opt-in region: CDK's `renderLogging` performs no region check and
|
|
48
|
+
* CloudFormation rejects the distribution at deploy, after the rest of the
|
|
49
|
+
* stack has been created. The delivery region is known at synth whenever the
|
|
50
|
+
* bucket's stack is env-pinned, so the refusal lives here — the seam every
|
|
51
|
+
* distribution crosses, whether built directly or through the `Cdn`
|
|
52
|
+
* pattern — and names both cures.
|
|
53
|
+
*
|
|
54
|
+
* Logging is on exactly when `renderLogging` emits a Logging block:
|
|
55
|
+
* `enableLogging: true`, or a `logBucket` without an explicit
|
|
56
|
+
* `enableLogging: false` (that pair is CDK's own contradiction error and is
|
|
57
|
+
* left to it). The delivery region is the bucket's `env.region` when it is
|
|
58
|
+
* concrete, else the distribution stack's — the bucket CDK would create.
|
|
59
|
+
* A token delivery region (an unpinned stack, and no region on the bucket)
|
|
60
|
+
* cannot be checked and passes. A concrete region `AWS_REGIONS_METADATA`
|
|
61
|
+
* does not list is refused too — every region launched since 2019 is opt-in,
|
|
62
|
+
* so an unlisted one cannot be assumed default-enabled — naming the metadata
|
|
63
|
+
* as the cure. The guard fires whenever the delivery region is concrete and
|
|
64
|
+
* known to the metadata.
|
|
65
|
+
*/
|
|
66
|
+
function validateStandardLoggingRegion(scope, id, props) {
|
|
67
|
+
const loggingOn = props.enableLogging === true ||
|
|
68
|
+
(props.enableLogging === undefined && props.logBucket !== undefined);
|
|
69
|
+
if (!loggingOn)
|
|
70
|
+
return;
|
|
71
|
+
const bucketRegion = props.logBucket?.env.region;
|
|
72
|
+
const region = bucketRegion !== undefined && !Token.isUnresolved(bucketRegion)
|
|
73
|
+
? bucketRegion
|
|
74
|
+
: Stack.of(scope).region;
|
|
75
|
+
if (Token.isUnresolved(region))
|
|
76
|
+
return;
|
|
77
|
+
if (getRegionInfo(region) === undefined) {
|
|
78
|
+
throw new Error(`CloudFront distribution '${id}': region ${region} is not in ` +
|
|
79
|
+
"@fjall/util's AWS_REGIONS_METADATA, so its opt-in status cannot be " +
|
|
80
|
+
"checked — update the metadata, or set enableLogging: false on this " +
|
|
81
|
+
"CDN.");
|
|
82
|
+
}
|
|
83
|
+
if (!OPT_IN_REGION_CODES.has(region))
|
|
84
|
+
return;
|
|
85
|
+
throw new Error(`CloudFront distribution '${id}': standard (legacy) logging cannot ` +
|
|
86
|
+
`deliver to an S3 bucket in opt-in region ${region} — logging was ` +
|
|
87
|
+
"enabled explicitly or by the app backup tier's cloudFrontLogging " +
|
|
88
|
+
"default. Set enableLogging: false on this CDN, or pass a logBucket " +
|
|
89
|
+
"whose region CDK knows to be default-enabled: " +
|
|
90
|
+
"Bucket.fromBucketAttributes(…, { region }), or a bucket in a stack " +
|
|
91
|
+
"pinned to that region (fromBucketName and fromBucketArn inherit this " +
|
|
92
|
+
"stack's region).");
|
|
93
|
+
}
|
|
45
94
|
export class CloudFrontDistribution extends Construct {
|
|
46
95
|
id;
|
|
47
96
|
distribution;
|
|
@@ -53,6 +102,7 @@ export class CloudFrontDistribution extends Construct {
|
|
|
53
102
|
constructor(scope, id, props) {
|
|
54
103
|
super(scope, id);
|
|
55
104
|
this.id = id;
|
|
105
|
+
validateStandardLoggingRegion(this, id, props);
|
|
56
106
|
// Sanitise id for CloudFormation output keys (must be alphanumeric)
|
|
57
107
|
const outputName = toPascalCase(id);
|
|
58
108
|
const defaultOrigin = this.createOrigin(props.defaultOrigin);
|
|
@@ -40,7 +40,7 @@ export interface SecurityHeadersPolicyProps {
|
|
|
40
40
|
* Wrapped on the description + SOC2-defaults trigger (NOT taggability — the
|
|
41
41
|
* resource is not taggable): it owns Fjall's uniform security-header defaults
|
|
42
42
|
* so every consumer inherits the same posture in one edit. Per
|
|
43
|
-
*
|
|
43
|
+
* the Wrapper Routing Discipline rule (generator standards),
|
|
44
44
|
* `lib/{config,patterns}/` instantiate this class, never the raw L2.
|
|
45
45
|
*
|
|
46
46
|
* Defaults (all `override:true`): HSTS 730d+includeSubdomains+preload,
|
|
@@ -18,7 +18,7 @@ const DEFAULT_COMMENT = "Fjall security headers policy";
|
|
|
18
18
|
* Wrapped on the description + SOC2-defaults trigger (NOT taggability — the
|
|
19
19
|
* resource is not taggable): it owns Fjall's uniform security-header defaults
|
|
20
20
|
* so every consumer inherits the same posture in one edit. Per
|
|
21
|
-
*
|
|
21
|
+
* the Wrapper Routing Discipline rule (generator standards),
|
|
22
22
|
* `lib/{config,patterns}/` instantiate this class, never the raw L2.
|
|
23
23
|
*
|
|
24
24
|
* Defaults (all `override:true`): HSTS 730d+includeSubdomains+preload,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type BlockDevice, type IMachineImage, type IVpc, type UserData, type ISecurityGroup, type IConnectable, Connections, type SubnetConfiguration, type SubnetSelection } from "aws-cdk-lib/aws-ec2";
|
|
2
2
|
import { Construct } from "constructs";
|
|
3
|
+
import { type Ec2SshAccessProps } from "./ec2SshAccess.js";
|
|
3
4
|
import { Duration, Stack, type StackProps } from "aws-cdk-lib";
|
|
4
5
|
import { type Role } from "aws-cdk-lib/aws-iam";
|
|
5
6
|
import { AutoScalingGroup, Monitoring, type WarmPoolOptions } from "aws-cdk-lib/aws-autoscaling";
|
|
@@ -27,7 +28,7 @@ export type Ec2InstancePersistentDataVolumeConfig = Omit<PersistentDataVolumePro
|
|
|
27
28
|
* `AutoScalingScheduledAction.IgnoreUnmodifiedGroupSizeProperties`
|
|
28
29
|
* regardless. For callers owning rollout externally.
|
|
29
30
|
*
|
|
30
|
-
* See
|
|
31
|
+
* See the ClickHouse rolling-update-window troubleshooting note for the
|
|
31
32
|
* ClickHouse-specific downtime expectations.
|
|
32
33
|
*/
|
|
33
34
|
export type Ec2InstanceUpdatePolicyConfig = {
|
|
@@ -42,6 +43,7 @@ export type Ec2InstanceUpdatePolicyConfig = {
|
|
|
42
43
|
} | {
|
|
43
44
|
type: "none";
|
|
44
45
|
};
|
|
46
|
+
export { type Ec2SshAccessProps } from "./ec2SshAccess.js";
|
|
45
47
|
export interface Ec2InstanceProps extends StackProps {
|
|
46
48
|
spotCapacityPercentage?: number;
|
|
47
49
|
blockDevices?: BlockDevice[];
|
|
@@ -57,7 +59,13 @@ export interface Ec2InstanceProps extends StackProps {
|
|
|
57
59
|
machineImage?: IMachineImage;
|
|
58
60
|
userData?: UserData;
|
|
59
61
|
role?: Role;
|
|
60
|
-
|
|
62
|
+
/**
|
|
63
|
+
* SSH access: who may reach port 22, which key pair, and whether the
|
|
64
|
+
* instances are public. Omit for Session Manager-only access. Rejected
|
|
65
|
+
* alongside a caller-supplied `securityGroup` — the construct adds ingress
|
|
66
|
+
* only to a security group it owns.
|
|
67
|
+
*/
|
|
68
|
+
sshAccess?: Ec2SshAccessProps;
|
|
61
69
|
defaultPort?: number;
|
|
62
70
|
/**
|
|
63
71
|
* Caller-supplied EC2 instance monitoring resolution. Routes through the
|
|
@@ -79,8 +87,10 @@ export interface Ec2InstanceProps extends StackProps {
|
|
|
79
87
|
securityGroup?: ISecurityGroup;
|
|
80
88
|
/**
|
|
81
89
|
* CDK `AutoScalingGroupProps.vpcSubnets`. Overrides the default
|
|
82
|
-
* `
|
|
83
|
-
* the caller needs a precise subnet selection (e.g. AZ pinning).
|
|
90
|
+
* `sshAccess.public ? PUBLIC : resolvePrivateSubnetType(vpc)` inference.
|
|
91
|
+
* Use when the caller needs a precise subnet selection (e.g. AZ pinning).
|
|
92
|
+
* With `sshAccess.public: true` the selection may pin availability zones
|
|
93
|
+
* but not choose a non-public subnet type, group or list.
|
|
84
94
|
*/
|
|
85
95
|
vpcSubnets?: SubnetSelection;
|
|
86
96
|
/** CDK `AutoScalingGroupProps.capacityRebalance`. Absent → CDK default. */
|
|
@@ -92,9 +102,9 @@ export interface Ec2InstanceProps extends StackProps {
|
|
|
92
102
|
*/
|
|
93
103
|
warmPool?: WarmPoolOptions;
|
|
94
104
|
/**
|
|
95
|
-
* CDK `LaunchTemplateProps.associatePublicIpAddress`.
|
|
96
|
-
*
|
|
97
|
-
*
|
|
105
|
+
* CDK `LaunchTemplateProps.associatePublicIpAddress`. Absent → `true` only
|
|
106
|
+
* when `sshAccess.public` is `true`. An explicit `false` alongside
|
|
107
|
+
* `sshAccess.public: true` is rejected rather than silently overridden.
|
|
98
108
|
*/
|
|
99
109
|
associatePublicIpAddress?: boolean;
|
|
100
110
|
/**
|
|
@@ -127,7 +137,7 @@ export interface Ec2InstanceProps extends StackProps {
|
|
|
127
137
|
* ASG `UpdatePolicy` resolution. Absent → `UpdatePolicy.instanceRefresh`
|
|
128
138
|
* (0/100 with `persistentDataVolume`, 100/200 otherwise). See
|
|
129
139
|
* `Ec2InstanceUpdatePolicyConfig` for the variant menu and
|
|
130
|
-
*
|
|
140
|
+
* the ClickHouse rolling-update-window troubleshooting note for the
|
|
131
141
|
* downtime-window runbook.
|
|
132
142
|
*/
|
|
133
143
|
updatePolicy?: Ec2InstanceUpdatePolicyConfig;
|
|
@@ -162,11 +172,28 @@ export declare class Ec2Instance extends Construct implements IConnectable {
|
|
|
162
172
|
private autoScalingGroup;
|
|
163
173
|
private keyPair;
|
|
164
174
|
private persistentDataVolume?;
|
|
175
|
+
private readonly vpcSubnets;
|
|
176
|
+
private readonly associatePublicIpAddress;
|
|
165
177
|
readonly connections: Connections;
|
|
166
178
|
constructor(scope: Construct, id: string, props: Ec2InstanceProps);
|
|
167
179
|
private addVpc;
|
|
168
180
|
private addKeyPair;
|
|
181
|
+
/**
|
|
182
|
+
* One ingress rule per source range. A world-open range still lands (the
|
|
183
|
+
* caller asked for it) but warns; the wording reads the placement the group
|
|
184
|
+
* actually resolved to, not `sshAccess.public` alone, because a caller who
|
|
185
|
+
* owns the placement through public `vpcSubnets` and
|
|
186
|
+
* `associatePublicIpAddress` exposes it just the same.
|
|
187
|
+
*/
|
|
188
|
+
private addSshIngress;
|
|
169
189
|
private addLaunchTemplate;
|
|
190
|
+
/**
|
|
191
|
+
* `sshAccess.public` decides the subnet type; an explicit `vpcSubnets` keeps
|
|
192
|
+
* its other selectors (AZ pinning) — `validateEc2InstanceProps` has already
|
|
193
|
+
* rejected a selection that contradicts `public: true`, so filling in
|
|
194
|
+
* `PUBLIC` here only completes an AZ-only pin.
|
|
195
|
+
*/
|
|
196
|
+
private resolveVpcSubnets;
|
|
170
197
|
private addAutoScalingGroup;
|
|
171
198
|
/**
|
|
172
199
|
* Apply `props.tags` to the underlying ASG with
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { InstanceType, LaunchTemplate, SubnetType, MachineImage, LaunchTemplateHttpTokens, KeyPair, SecurityGroup, Peer, Port, Connections } from "aws-cdk-lib/aws-ec2";
|
|
2
2
|
import { Construct } from "constructs";
|
|
3
|
+
import { WORLD_OPEN_IPV4_CIDR } from "@fjall/generator";
|
|
4
|
+
import { mintedEc2KeyPairName } from "@fjall/util";
|
|
5
|
+
import { EC2_WORLD_OPEN_SSH_WARNING_ID, checkRetiredSshProp, sshFacesInternet, validateSshAccess, worldOpenSshWarning } from "./ec2SshAccess.js";
|
|
3
6
|
import { resolvePrivateSubnetType } from "../../../utils/vpcUtils.js";
|
|
4
7
|
import { safeEbs } from "./blockDeviceVolume.js";
|
|
5
|
-
import { Duration, Stack, Tags } from "aws-cdk-lib";
|
|
8
|
+
import { Annotations, Duration, Stack, Tags } from "aws-cdk-lib";
|
|
6
9
|
import { AutoScalingGroup, GroupMetrics, InstanceRefreshStrategy, Monitoring, TerminationPolicy, UpdatePolicy, OnDemandAllocationStrategy, SpotAllocationStrategy } from "aws-cdk-lib/aws-autoscaling";
|
|
7
10
|
import { DEFAULT_ROLLING_UPDATE_PAUSE_SECONDS, INSTANCE_REFRESH_DEFAULT_MAX_HEALTHY, INSTANCE_REFRESH_DEFAULT_MIN_HEALTHY, INSTANCE_REFRESH_PDV_MAX_HEALTHY, INSTANCE_REFRESH_PDV_MIN_HEALTHY } from "./ecsConstants.js";
|
|
8
11
|
import { AwsCustomResource } from "../utilities/awsCustomResource.js";
|
|
@@ -17,7 +20,7 @@ import { PersistentDataVolume } from "./persistentDataVolume.js";
|
|
|
17
20
|
* `encrypted: true` enforces EBS encryption at the wrapper layer rather than
|
|
18
21
|
* relying on the AWS account-level "encryption by default" setting — the
|
|
19
22
|
* propagation contract documented in
|
|
20
|
-
*
|
|
23
|
+
* the Wrapper Routing Discipline rule (generator standards).
|
|
21
24
|
*/
|
|
22
25
|
const DEFAULT_ROOT_DEVICE_NAME = "/dev/xvda";
|
|
23
26
|
const DEFAULT_ROOT_VOLUME_SIZE_GIB = 30;
|
|
@@ -72,11 +75,19 @@ export class Ec2Instance extends Construct {
|
|
|
72
75
|
autoScalingGroup;
|
|
73
76
|
keyPair;
|
|
74
77
|
persistentDataVolume;
|
|
78
|
+
vpcSubnets;
|
|
79
|
+
associatePublicIpAddress;
|
|
75
80
|
connections;
|
|
76
81
|
constructor(scope, id, props) {
|
|
77
82
|
super(scope, id);
|
|
78
83
|
validateEc2InstanceProps(props);
|
|
84
|
+
checkRetiredSshProp(this, "Ec2Instance", props);
|
|
85
|
+
// `sshAccess.public` assigns a public IP unless the caller's own prop
|
|
86
|
+
// decides; validateEc2InstanceProps has already rejected a contradiction.
|
|
87
|
+
this.associatePublicIpAddress =
|
|
88
|
+
props.associatePublicIpAddress ?? props.sshAccess?.public === true;
|
|
79
89
|
this.addVpc(props);
|
|
90
|
+
this.vpcSubnets = this.resolveVpcSubnets(props);
|
|
80
91
|
this.addKeyPair(props);
|
|
81
92
|
this.addLaunchTemplate(props);
|
|
82
93
|
this.addAutoScalingGroup(props);
|
|
@@ -101,13 +112,35 @@ export class Ec2Instance extends Construct {
|
|
|
101
112
|
}
|
|
102
113
|
}
|
|
103
114
|
addKeyPair(props) {
|
|
104
|
-
|
|
115
|
+
const access = props.sshAccess;
|
|
116
|
+
if (access === undefined) {
|
|
105
117
|
this.keyPair = undefined;
|
|
118
|
+
return;
|
|
106
119
|
}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
120
|
+
this.keyPair =
|
|
121
|
+
access.keyName !== undefined
|
|
122
|
+
? KeyPair.fromKeyPairName(this, "KeyPair", access.keyName)
|
|
123
|
+
: new KeyPair(this, "KeyPair", {
|
|
124
|
+
keyPairName: mintedEc2KeyPairName(props.serviceName)
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* One ingress rule per source range. A world-open range still lands (the
|
|
129
|
+
* caller asked for it) but warns; the wording reads the placement the group
|
|
130
|
+
* actually resolved to, not `sshAccess.public` alone, because a caller who
|
|
131
|
+
* owns the placement through public `vpcSubnets` and
|
|
132
|
+
* `associatePublicIpAddress` exposes it just the same.
|
|
133
|
+
*/
|
|
134
|
+
addSshIngress(props, access) {
|
|
135
|
+
const facesInternet = sshFacesInternet({
|
|
136
|
+
associatePublicIpAddress: this.associatePublicIpAddress,
|
|
137
|
+
hasPublicSubnets: this.vpc.selectSubnets(this.vpcSubnets).hasPublic
|
|
138
|
+
});
|
|
139
|
+
for (const cidr of access.allowedIpCidrs) {
|
|
140
|
+
if (cidr === WORLD_OPEN_IPV4_CIDR) {
|
|
141
|
+
Annotations.of(this).addWarningV2(EC2_WORLD_OPEN_SSH_WARNING_ID, worldOpenSshWarning(props.serviceName, facesInternet));
|
|
142
|
+
}
|
|
143
|
+
this.asgSecurityGroup.addIngressRule(Peer.ipv4(cidr), Port.tcp(22), `SSH from ${cidr}`);
|
|
111
144
|
}
|
|
112
145
|
}
|
|
113
146
|
addLaunchTemplate(props) {
|
|
@@ -120,8 +153,8 @@ export class Ec2Instance extends Construct {
|
|
|
120
153
|
description: `Security group for the ${props.serviceName} auto scaling group`
|
|
121
154
|
});
|
|
122
155
|
}
|
|
123
|
-
if (props.
|
|
124
|
-
this.
|
|
156
|
+
if (props.sshAccess !== undefined) {
|
|
157
|
+
this.addSshIngress(props, props.sshAccess);
|
|
125
158
|
}
|
|
126
159
|
this.launchTemplate = new LaunchTemplate(this, "LaunchTemplate", {
|
|
127
160
|
launchTemplateName: `${props.serviceName}LaunchTemplate`,
|
|
@@ -144,15 +177,30 @@ export class Ec2Instance extends Construct {
|
|
|
144
177
|
httpTokens: LaunchTemplateHttpTokens.REQUIRED,
|
|
145
178
|
instanceMetadataTags: true,
|
|
146
179
|
keyPair: this.keyPair,
|
|
147
|
-
associatePublicIpAddress:
|
|
180
|
+
associatePublicIpAddress: this.associatePublicIpAddress
|
|
148
181
|
});
|
|
149
182
|
}
|
|
183
|
+
/**
|
|
184
|
+
* `sshAccess.public` decides the subnet type; an explicit `vpcSubnets` keeps
|
|
185
|
+
* its other selectors (AZ pinning) — `validateEc2InstanceProps` has already
|
|
186
|
+
* rejected a selection that contradicts `public: true`, so filling in
|
|
187
|
+
* `PUBLIC` here only completes an AZ-only pin.
|
|
188
|
+
*/
|
|
189
|
+
resolveVpcSubnets(props) {
|
|
190
|
+
const isPublic = props.sshAccess?.public === true;
|
|
191
|
+
if (props.vpcSubnets === undefined) {
|
|
192
|
+
return {
|
|
193
|
+
subnetType: isPublic
|
|
194
|
+
? SubnetType.PUBLIC
|
|
195
|
+
: resolvePrivateSubnetType(this.vpc)
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
return isPublic
|
|
199
|
+
? { ...props.vpcSubnets, subnetType: SubnetType.PUBLIC }
|
|
200
|
+
: props.vpcSubnets;
|
|
201
|
+
}
|
|
150
202
|
addAutoScalingGroup(props) {
|
|
151
|
-
const vpcSubnets =
|
|
152
|
-
subnetType: props.enableSSH
|
|
153
|
-
? SubnetType.PUBLIC
|
|
154
|
-
: resolvePrivateSubnetType(this.vpc)
|
|
155
|
-
};
|
|
203
|
+
const vpcSubnets = this.vpcSubnets;
|
|
156
204
|
const resolvedUpdatePolicy = resolveUpdatePolicy(props.updatePolicy, props.persistentDataVolume !== undefined);
|
|
157
205
|
const baseAsgProps = {
|
|
158
206
|
vpc: this.vpc,
|
|
@@ -282,6 +330,7 @@ export class Ec2Instance extends Construct {
|
|
|
282
330
|
}
|
|
283
331
|
}
|
|
284
332
|
function validateEc2InstanceProps(props) {
|
|
333
|
+
validateSshAccess(props);
|
|
285
334
|
if (props.spotCapacityPercentage !== undefined) {
|
|
286
335
|
if (!Number.isInteger(props.spotCapacityPercentage) ||
|
|
287
336
|
props.spotCapacityPercentage < 0 ||
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { type ISecurityGroup, type SubnetSelection } from "aws-cdk-lib/aws-ec2";
|
|
2
|
+
import { type IConstruct } from "constructs";
|
|
3
|
+
/**
|
|
4
|
+
* SSH access to the instances. Each field is one decision the construct used
|
|
5
|
+
* to fold into a single `enableSSH` boolean. Absent → no key pair, no port-22
|
|
6
|
+
* ingress, private subnets and no public IP; Session Manager remains the
|
|
7
|
+
* access path.
|
|
8
|
+
*/
|
|
9
|
+
export interface Ec2SshAccessProps {
|
|
10
|
+
/**
|
|
11
|
+
* IPv4 source ranges allowed on TCP 22, one ingress rule each. At least one
|
|
12
|
+
* entry, no duplicates. A literal is validated as dotted-quad/prefix-length
|
|
13
|
+
* (octets 0–255, prefix 0–32); a CDK token per element such as
|
|
14
|
+
* `vpc.vpcCidrBlock` passes through to CloudFormation, but the list itself
|
|
15
|
+
* must be literal at synth (no `valueAsList` / `Fn.split` over a token),
|
|
16
|
+
* because each range becomes one rule. `"0.0.0.0/0"` synthesises with a
|
|
17
|
+
* warning that names the instance.
|
|
18
|
+
*/
|
|
19
|
+
allowedIpCidrs: string[];
|
|
20
|
+
/**
|
|
21
|
+
* Existing EC2 key pair name, resolved with `KeyPair.fromKeyPairName`. Omit
|
|
22
|
+
* to mint one named `${serviceName}KeyPair`; naming that key pair here is
|
|
23
|
+
* rejected, because the switch to an imported reference would delete it.
|
|
24
|
+
*/
|
|
25
|
+
keyName?: string;
|
|
26
|
+
/**
|
|
27
|
+
* Public subnets and a public IP. Default `false`: the instances stay in
|
|
28
|
+
* private subnets, so the ranges in `allowedIpCidrs` reach port 22 only from
|
|
29
|
+
* inside the VPC, a peered VPC or a VPN. `vpcSubnets` may still pin
|
|
30
|
+
* availability zones; a `vpcSubnets.subnetType` or an
|
|
31
|
+
* `associatePublicIpAddress` that contradicts `public: true` is rejected.
|
|
32
|
+
*/
|
|
33
|
+
public?: boolean;
|
|
34
|
+
}
|
|
35
|
+
export declare const EC2_WORLD_OPEN_SSH_WARNING_ID = "@fjall/components-infrastructure:ec2WorldOpenSsh";
|
|
36
|
+
export declare const EC2_RETIRED_SSH_PROP_WARNING_ID = "@fjall/components-infrastructure:ec2RetiredSshProp";
|
|
37
|
+
type RetiredSshOwner = "Ec2Instance" | "Ec2Compute";
|
|
38
|
+
/**
|
|
39
|
+
* The retired spelling is read at synth, not only at typecheck: a JavaScript
|
|
40
|
+
* caller, or a spread of an older props object, would otherwise deploy with
|
|
41
|
+
* no SSH and no signal. A value the pre-34 owner read as SSH on asked for an
|
|
42
|
+
* exposure the new shape must spell out, so it throws with the translation.
|
|
43
|
+
* A value it read as off asked for the default and gets it, with a warning
|
|
44
|
+
* to delete the prop: refusing a no-op would strand every file the pre-34
|
|
45
|
+
* wizard wrote.
|
|
46
|
+
*/
|
|
47
|
+
export declare function checkRetiredSshProp(scope: IConstruct, owner: RetiredSshOwner, props: object): void;
|
|
48
|
+
/** The fields of `Ec2InstanceProps` the SSH contract reads. */
|
|
49
|
+
export interface SshAccessValidationInput {
|
|
50
|
+
serviceName: string;
|
|
51
|
+
sshAccess?: Ec2SshAccessProps;
|
|
52
|
+
securityGroup?: ISecurityGroup;
|
|
53
|
+
associatePublicIpAddress?: boolean;
|
|
54
|
+
vpcSubnets?: SubnetSelection;
|
|
55
|
+
}
|
|
56
|
+
/** Synth-time contract for `sshAccess`; every refusal names the cure. */
|
|
57
|
+
export declare function validateSshAccess(props: SshAccessValidationInput): void;
|
|
58
|
+
/** The placement `Ec2Instance` resolved for the group, as the warning reads it. */
|
|
59
|
+
export interface ResolvedSshPlacement {
|
|
60
|
+
/** The launch template's setting once `sshAccess.public` and the caller's own prop are reconciled. */
|
|
61
|
+
associatePublicIpAddress: boolean;
|
|
62
|
+
/** Whether the subnets the group lands in include public ones (`vpc.selectSubnets(...).hasPublic`). */
|
|
63
|
+
hasPublicSubnets: boolean;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Whether port 22 faces the internet. An instance is reachable from outside
|
|
67
|
+
* only with a public IP AND a subnet routed to an internet gateway, so both
|
|
68
|
+
* halves of the resolved placement must say public. `public: true` sets
|
|
69
|
+
* both; a caller who owns the placement through `vpcSubnets` and
|
|
70
|
+
* `associatePublicIpAddress` reaches the same exposure without the flag,
|
|
71
|
+
* which is why the warning reads the placement rather than `sshAccess.public`.
|
|
72
|
+
*/
|
|
73
|
+
export declare function sshFacesInternet(placement: ResolvedSshPlacement): boolean;
|
|
74
|
+
/**
|
|
75
|
+
* The world-open warning: a `0.0.0.0/0` range still lands (the caller asked
|
|
76
|
+
* for it) but synthesises with an acknowledgeable warning, as
|
|
77
|
+
* `rdsWorldOpenIngress` does for a database.
|
|
78
|
+
*/
|
|
79
|
+
export declare function worldOpenSshWarning(serviceName: string, facesInternet: boolean): string;
|
|
80
|
+
export {};
|