@fjall/components-infrastructure 37.0.0 → 39.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.
Files changed (72) hide show
  1. package/dist/lib/app.d.ts +52 -12
  2. package/dist/lib/app.js +60 -24
  3. package/dist/lib/config/aws/configRecorder.js +3 -0
  4. package/dist/lib/config/aws/disasterRecovery.d.ts +1 -2
  5. package/dist/lib/config/aws/disasterRecovery.js +167 -45
  6. package/dist/lib/patterns/aws/buildkite/buildkite.js +7 -2
  7. package/dist/lib/patterns/aws/cdn.d.ts +19 -11
  8. package/dist/lib/patterns/aws/cdn.js +53 -25
  9. package/dist/lib/patterns/aws/clickhouseDatabase.js +8 -4
  10. package/dist/lib/patterns/aws/computeEcs.js +17 -1
  11. package/dist/lib/patterns/aws/computeEcsTypes.d.ts +30 -4
  12. package/dist/lib/patterns/aws/computePropApplicability.js +1 -0
  13. package/dist/lib/patterns/aws/domain.js +3 -3
  14. package/dist/lib/patterns/aws/externalRecordsPattern.d.ts +9 -6
  15. package/dist/lib/patterns/aws/externalRecordsPattern.js +22 -46
  16. package/dist/lib/patterns/aws/interfaces/domain.d.ts +8 -5
  17. package/dist/lib/patterns/aws/interfaces/pattern.d.ts +9 -10
  18. package/dist/lib/patterns/aws/messaging.d.ts +10 -0
  19. package/dist/lib/patterns/aws/messaging.js +10 -6
  20. package/dist/lib/patterns/aws/payload.d.ts +5 -0
  21. package/dist/lib/patterns/aws/payload.js +23 -11
  22. package/dist/lib/patterns/aws/storage.d.ts +41 -0
  23. package/dist/lib/patterns/aws/storage.js +23 -8
  24. package/dist/lib/patterns/aws/targets/targetResolution.d.ts +4 -4
  25. package/dist/lib/patterns/aws/targets/targetResolution.js +4 -4
  26. package/dist/lib/resources/aws/backup/backupVault.d.ts +27 -2
  27. package/dist/lib/resources/aws/backup/backupVault.js +49 -6
  28. package/dist/lib/resources/aws/cdn/cloudFront.d.ts +42 -18
  29. package/dist/lib/resources/aws/cdn/cloudFront.js +39 -51
  30. package/dist/lib/resources/aws/cdn/cloudFrontLogDelivery.d.ts +90 -0
  31. package/dist/lib/resources/aws/cdn/cloudFrontLogDelivery.js +213 -0
  32. package/dist/lib/resources/aws/cdn/index.d.ts +1 -0
  33. package/dist/lib/resources/aws/cdn/index.js +1 -0
  34. package/dist/lib/resources/aws/compute/ecs.d.ts +7 -4
  35. package/dist/lib/resources/aws/compute/ecs.js +27 -21
  36. package/dist/lib/resources/aws/compute/ecsServiceFactory.js +3 -0
  37. package/dist/lib/resources/aws/compute/ecsTypes.d.ts +11 -0
  38. package/dist/lib/resources/aws/compute/ecsValidation.d.ts +8 -0
  39. package/dist/lib/resources/aws/compute/ecsValidation.js +8 -0
  40. package/dist/lib/resources/aws/compute/persistentDataVolume.d.ts +8 -0
  41. package/dist/lib/resources/aws/compute/persistentDataVolume.js +2 -0
  42. package/dist/lib/resources/aws/database/dynamodb.js +2 -1
  43. package/dist/lib/resources/aws/database/rdsAurora.js +6 -1
  44. package/dist/lib/resources/aws/database/rdsInstance.js +6 -1
  45. package/dist/lib/resources/aws/logging/accessLogBucket.d.ts +15 -8
  46. package/dist/lib/resources/aws/logging/accessLogBucket.js +16 -9
  47. package/dist/lib/resources/aws/logging/cloudTrail.js +3 -0
  48. package/dist/lib/resources/aws/messaging/eventBridgeQueueGrants.d.ts +16 -0
  49. package/dist/lib/resources/aws/messaging/eventBridgeQueueGrants.js +91 -0
  50. package/dist/lib/resources/aws/messaging/eventBridgeRule.js +36 -5
  51. package/dist/lib/resources/aws/messaging/eventTargets.d.ts +4 -3
  52. package/dist/lib/resources/aws/messaging/eventTargets.js +71 -31
  53. package/dist/lib/resources/aws/messaging/queuePolicyQuota.d.ts +25 -0
  54. package/dist/lib/resources/aws/messaging/queuePolicyQuota.js +190 -0
  55. package/dist/lib/resources/aws/messaging/schedule.d.ts +5 -5
  56. package/dist/lib/resources/aws/messaging/sqs.d.ts +36 -1
  57. package/dist/lib/resources/aws/messaging/sqs.js +40 -2
  58. package/dist/lib/resources/aws/messaging/subscription.d.ts +4 -3
  59. package/dist/lib/resources/aws/networking/vpc.js +12 -6
  60. package/dist/lib/resources/aws/secrets/stackEncryptionKey.d.ts +26 -1
  61. package/dist/lib/resources/aws/secrets/stackEncryptionKey.js +56 -1
  62. package/dist/lib/resources/aws/storage/s3.d.ts +21 -1
  63. package/dist/lib/resources/aws/storage/s3.js +89 -3
  64. package/dist/lib/utils/backupTierMapping.d.ts +23 -2
  65. package/dist/lib/utils/backupTierMapping.js +25 -0
  66. package/dist/lib/utils/cdkContext.d.ts +14 -0
  67. package/dist/lib/utils/cdkContext.js +38 -1
  68. package/dist/lib/utils/exportNaming.d.ts +30 -27
  69. package/dist/lib/utils/exportNaming.js +32 -28
  70. package/dist/lib/utils/standardTagsAspect.d.ts +8 -18
  71. package/dist/lib/utils/standardTagsAspect.js +8 -34
  72. package/package.json +4 -4
@@ -95,8 +95,13 @@ export class Buildkite extends Construct {
95
95
  appBackupTier
96
96
  });
97
97
  // Secrets (SSH deploy keys, git credentials) must survive stack teardown
98
- // and accidental overwrite.
99
- const managedSecretsBucket = new S3Bucket(this, `${id}ManagedSecretsBucket`, { versioned: true, removalPolicy: RemovalPolicy.RETAIN });
98
+ // and accidental overwrite — which is exactly what a backup plan is for,
99
+ // so the bucket takes the app's tier like the artefact bucket above.
100
+ const managedSecretsBucket = new S3Bucket(this, `${id}ManagedSecretsBucket`, {
101
+ versioned: true,
102
+ removalPolicy: RemovalPolicy.RETAIN,
103
+ appBackupTier
104
+ });
100
105
  if (fjallApiKeyParameterName !== undefined ||
101
106
  pipelineSecretsPrefix !== undefined) {
102
107
  this.addAgentSecretsEnvHook(id, managedSecretsBucket, {
@@ -4,7 +4,7 @@ import { type IApplicationLoadBalancer } from "aws-cdk-lib/aws-elasticloadbalanc
4
4
  import { type ICertificate } from "aws-cdk-lib/aws-certificatemanager";
5
5
  import { type ICachePolicy, type IResponseHeadersPolicy } from "aws-cdk-lib/aws-cloudfront";
6
6
  import type App from "../../app.js";
7
- import { CloudFrontDistribution, type CachePolicyPreset, type AccessGateConfig } from "../../resources/aws/cdn/index.js";
7
+ import { CloudFrontDistribution, type CloudFrontLogFormat, type CachePolicyPreset, type AccessGateConfig } from "../../resources/aws/cdn/index.js";
8
8
  import { type ICdn } from "./interfaces/cdn.js";
9
9
  import { type StaticSiteRouting, type CloudFrontPriceClass } from "@fjall/util";
10
10
  import { type BackupTier } from "../../utils/backupTierMapping.js";
@@ -83,17 +83,17 @@ interface BaseCdnProps {
83
83
  certificateArn?: string;
84
84
  comment?: string;
85
85
  /**
86
- * Standard (S3) access logging. Default: the app backup tier's
87
- * `cloudFrontLogging` column — enterprise logs to the stack's shared,
88
- * lifecycle-expired access-log bucket under `cloudfront/<id>/`. Set either
89
- * knob to take over: `enableLogging: false` opts out at any tier,
90
- * `logBucket` delivers to a bucket of your own. Standard logging cannot
91
- * deliver into an opt-in region, so a tier- or explicitly-enabled CDN whose
92
- * delivery region is opt-in is refused at synth with those two cures — see
93
- * {@link CloudFrontDistributionProps.enableLogging}.
86
+ * Standard logging (v2): access logs delivered through CloudWatch Logs.
87
+ * Default: the app backup tier's `cloudFrontLogging` column — enterprise
88
+ * logs to the stack's shared, lifecycle-expired access-log bucket under
89
+ * `cloudfront/<id>`, as JSON. An object takes over at any tier (its
90
+ * `bucket`, `prefix` and `format` each default as above); `false` opts
91
+ * out at any tier. The delivery resources live in the CDN stack when the
92
+ * app deploys to us-east-1 and in the app's `UsEast1CdnLogging` stack
93
+ * otherwise (CloudWatch Logs accepts them there only), so a logging CDN
94
+ * outside us-east-1 needs the `App` — the factory path fills it.
94
95
  */
95
- enableLogging?: boolean;
96
- logBucket?: IBucket;
96
+ logging?: CdnLoggingConfig | false;
97
97
  priceClass?: CloudFrontPriceClass;
98
98
  forwardHostHeader?: boolean;
99
99
  accessGate?: false | AccessGateConfig;
@@ -104,6 +104,14 @@ interface BaseCdnProps {
104
104
  */
105
105
  appBackupTier?: BackupTier;
106
106
  }
107
+ export interface CdnLoggingConfig {
108
+ /** Bucket the logs land in; defaults to the stack's access-log bucket. */
109
+ bucket?: IBucket;
110
+ /** Key prefix (no leading or trailing slash); defaults to `cloudfront/<id>`. */
111
+ prefix?: string;
112
+ /** CloudWatch Logs output format; defaults to `json`. */
113
+ format?: CloudFrontLogFormat;
114
+ }
107
115
  /**
108
116
  * S3 origin CDN props - use with S3 bucket
109
117
  */
@@ -1,7 +1,7 @@
1
- import { Fn, Token } from "aws-cdk-lib";
1
+ import { Fn, Stack, Token } from "aws-cdk-lib";
2
2
  import { Certificate } from "aws-cdk-lib/aws-certificatemanager";
3
3
  import { CloudFrontTarget, LoadBalancerTarget } from "aws-cdk-lib/aws-route53-targets";
4
- import { CloudFrontDistribution } from "../../resources/aws/cdn/index.js";
4
+ import { assertNoLegacyLoggingProps, CLOUDFRONT_LOG_DELIVERY_REGION, CloudFrontDistribution } from "../../resources/aws/cdn/index.js";
5
5
  import { AliasRecord } from "../../resources/aws/networking/index.js";
6
6
  import { tierDefault } from "../../utils/backupTierMapping.js";
7
7
  import { stackAccessLogBucket } from "../../resources/aws/logging/index.js";
@@ -12,31 +12,54 @@ import { isStorage } from "./storage.js";
12
12
  import { CdnEcsOriginResolver, cdnOriginRefusal, internalAlbMessage, normaliseDnsName } from "./cdnAppOrigin.js";
13
13
  import { isCompute, isEcsCompute, isLambdaCompute } from "./compute.js";
14
14
  /**
15
- * An explicit `enableLogging` or `logBucket` is the override and passes
16
- * through untouched; otherwise the tier decides, and a tier-enabled CDN logs
17
- * to the stack's shared access-log bucket under its own prefix.
15
+ * `false` is off at any tier; an object is on at any tier with its own
16
+ * defaults filled; `undefined` lets the tier decide, and a tier-enabled CDN
17
+ * logs to the stack's shared access-log bucket under its own prefix.
18
18
  */
19
19
  function resolveCdnLogging(scope, id, props) {
20
- if (props.enableLogging !== undefined || props.logBucket !== undefined) {
21
- return {
22
- enableLogging: props.enableLogging,
23
- logBucket: props.logBucket,
24
- logFilePrefix: undefined
25
- };
26
- }
27
- if (!tierDefault(props.appBackupTier, "cloudFrontLogging")) {
28
- return {
29
- enableLogging: undefined,
30
- logBucket: undefined,
31
- logFilePrefix: undefined
32
- };
20
+ if (props.logging === false)
21
+ return undefined;
22
+ if (props.logging === undefined &&
23
+ !tierDefault(props.appBackupTier, "cloudFrontLogging")) {
24
+ return undefined;
33
25
  }
26
+ const config = props.logging ?? {};
34
27
  return {
35
- enableLogging: true,
36
- logBucket: stackAccessLogBucket(scope),
37
- logFilePrefix: `cloudfront/${id}/`
28
+ bucket: config.bucket ?? stackAccessLogBucket(scope),
29
+ ...(config.prefix !== undefined && { prefix: config.prefix }),
30
+ ...(config.format !== undefined && { format: config.format }),
31
+ ...resolveLogDeliveryScope(scope, id, props.app)
38
32
  };
39
33
  }
34
+ /**
35
+ * CloudWatch Logs accepts CloudFront delivery resources in us-east-1 only.
36
+ * A CDN stack already there hosts them itself; any other region hands them
37
+ * to the app's `UsEast1CdnLogging` stack, whose distribution and bucket
38
+ * ARNs arrive through CDK cross-region references — which need both stacks
39
+ * env-pinned, so a token region is refused here with the cure rather than
40
+ * by CloudFormation with a region error after the CDN stack has deployed.
41
+ */
42
+ function resolveLogDeliveryScope(scope, id, app) {
43
+ const region = Stack.of(scope).region;
44
+ if (Token.isUnresolved(region)) {
45
+ throw new Error(`CDN '${id}': standard logging (v2) needs a concrete region at synth ` +
46
+ "— the delivery resources go to us-east-1 and reach the " +
47
+ "distribution through cross-region references, which resolve only " +
48
+ "between env-pinned stacks — but this stack's region is a token. " +
49
+ "Deploy through the Fjall CLI, or export CDK_DEFAULT_ACCOUNT and " +
50
+ "CDK_DEFAULT_REGION before synthesising; or set logging: false.");
51
+ }
52
+ if (region === CLOUDFRONT_LOG_DELIVERY_REGION)
53
+ return {};
54
+ if (app === undefined) {
55
+ throw new Error(`CDN '${id}': standard logging (v2) from region ${region} places its ` +
56
+ "delivery resources in the app's us-east-1 logging stack, but 'app' " +
57
+ "is unset. Create the CDN through the factory path — " +
58
+ "app.addCdn(CdnFactory.build(...)) — which fills it, pass 'app' " +
59
+ "explicitly, or set logging: false.");
60
+ }
61
+ return { deliveryScope: app.getUsEast1CdnLoggingStack().getStack() };
62
+ }
40
63
  /**
41
64
  * The distribution's own alias names, normalised — consumed by the
42
65
  * origin-loop guard and by the ECS origin resolver (auto-resolution excludes
@@ -59,7 +82,10 @@ function collectAliasNames(props) {
59
82
  * Validates CDN props — synth-time hard errors for shapes CloudFormation
60
83
  * would only reject at deploy.
61
84
  */
62
- function validateCdnProps(props) {
85
+ function validateCdnProps(id, props) {
86
+ // The pattern rebuilds the distribution props field by field, so the
87
+ // resource-layer refusal never sees a legacy key given here.
88
+ assertNoLegacyLoggingProps(`CDN '${id}'`, props, "logging: { bucket?, prefix?, format? } | false");
63
89
  const hasCertificate = !!(props.certificate || props.certificateArn);
64
90
  // domainConfig is the managed-domain authority for the viewer config —
65
91
  // mixing it with the literal surface would give the alias list and the
@@ -251,7 +277,7 @@ const ROUTING_CDN_BEHAVIOUR = {
251
277
  };
252
278
  export class Cdn extends CloudFrontDistribution {
253
279
  constructor(scope, id, props) {
254
- validateCdnProps(props);
280
+ validateCdnProps(id, props);
255
281
  // Zone + certificate resolve BEFORE super() — the viewer certificate is
256
282
  // part of the distribution's props — so their constructs land on the CDN
257
283
  // stack (`scope`) under `${id}`-prefixed IDs, the pattern convention.
@@ -476,6 +502,7 @@ export class Cdn extends CloudFrontDistribution {
476
502
  originRequestPolicy: props.originRequestPolicy
477
503
  }
478
504
  : {};
505
+ const logging = resolveCdnLogging(scope, id, props);
479
506
  return {
480
507
  distributionProps: {
481
508
  appName,
@@ -486,7 +513,7 @@ export class Cdn extends CloudFrontDistribution {
486
513
  domainNames,
487
514
  certificate,
488
515
  comment: props.comment,
489
- ...resolveCdnLogging(scope, id, props),
516
+ ...(logging !== undefined && { logging }),
490
517
  priceClass: props.priceClass,
491
518
  forwardHostHeader: props.forwardHostHeader ?? impliedForwardHostHeader,
492
519
  accessGate: props.accessGate,
@@ -699,7 +726,8 @@ export class CdnFactory {
699
726
  // The App is the only place the app name is known here; without it the
700
727
  // export falls back to the construct id and the Domain import misses.
701
728
  // The App instance itself rides along for domainConfig resolution
702
- // (certificate placement needs app.getUsEast1CertificateStack()).
729
+ // (certificate placement needs app.getUsEast1CertificateStack()) and
730
+ // for log-delivery placement (app.getUsEast1CdnLoggingStack()).
703
731
  return new Cdn(scope, id, {
704
732
  ...props,
705
733
  appName: props.appName ?? app.getName(),
@@ -367,6 +367,9 @@ export class ClickHouseDatabase extends Construct {
367
367
  const backupBucket = props.backupBucket ??
368
368
  new S3Bucket(this, "ClickHouseBackupBucket", {
369
369
  versioned: true,
370
+ ...(props.appBackupTier !== undefined && {
371
+ appBackupTier: props.appBackupTier
372
+ }),
370
373
  lifecycleRules: [
371
374
  {
372
375
  enabled: true,
@@ -525,8 +528,7 @@ export class ClickHouseDatabase extends Construct {
525
528
  `${sidecarTlsPreamble}${optimisePreamble}\n${optimiseScript}`
526
529
  ],
527
530
  secrets: maintenanceSecrets,
528
- logGroup: optimiseTaskLogGroup,
529
- securityGroups: [securityGroup]
531
+ logGroup: optimiseTaskLogGroup
530
532
  });
531
533
  maintenanceLogGroups.push({
532
534
  job: "optimise",
@@ -555,8 +557,7 @@ export class ClickHouseDatabase extends Construct {
555
557
  })}`
556
558
  ],
557
559
  secrets: maintenanceSecrets,
558
- logGroup: backupTaskLogGroup,
559
- securityGroups: [securityGroup]
560
+ logGroup: backupTaskLogGroup
560
561
  });
561
562
  maintenanceLogGroups.push({
562
563
  job: "backup",
@@ -590,6 +591,9 @@ export class ClickHouseDatabase extends Construct {
590
591
  ...(props.appBackupTier !== undefined && {
591
592
  appBackupTier: props.appBackupTier
592
593
  }),
594
+ // ClickHouse is infrastructure: the deploy engine must never roll it
595
+ // out, restart it or rebind it as one of the app's own services.
596
+ deployableServices: false,
593
597
  cluster: {
594
598
  loadBalancer: false,
595
599
  securityGroup,
@@ -1,4 +1,4 @@
1
- import { AwsLogDriver, ContainerImage, DeploymentLifecycleStage, Secret as EcsSecret } from "aws-cdk-lib/aws-ecs";
1
+ import { AwsLogDriver, ContainerImage, DeploymentLifecycleStage, NetworkMode, Secret as EcsSecret } from "aws-cdk-lib/aws-ecs";
2
2
  import { Peer, Port, SubnetType } from "aws-cdk-lib/aws-ec2";
3
3
  import { Effect, PolicyStatement, ServicePrincipal } from "aws-cdk-lib/aws-iam";
4
4
  import { Annotations, Aws, Stack, Token } from "aws-cdk-lib";
@@ -269,6 +269,21 @@ export function validateEcsProps(props) {
269
269
  "does not own. Either omit cluster.securityGroup (a Fjall-owned SG will be created) or set directAccess: false.");
270
270
  }
271
271
  }
272
+ for (const entry of props.cluster?.scheduledTasks ?? []) {
273
+ if (entry.networkMode === NetworkMode.AWS_VPC) {
274
+ throw new Error(`Scheduled task '${entry.name}': networkMode AWS_VPC is not supported for scheduled tasks — ` +
275
+ "a scheduled task's connections are wired to the cluster's EC2 capacity security groups " +
276
+ "(EcsCluster.getEc2CapacityConnectable), which an awsvpc task's own ENI does not wear, so every " +
277
+ "connection grant would be unreachable at runtime with nothing failing at synth. Remove networkMode " +
278
+ "to run on the instance ENI, or set NetworkMode.HOST. Govern the task's reachability with the " +
279
+ "cluster-level securityGroup on EcsClusterConfig.");
280
+ }
281
+ if (entry.logGroup !== undefined && entry.logRetention !== undefined) {
282
+ throw new Error(`Scheduled task '${entry.name}': "logGroup" and "logRetention" are mutually exclusive on ` +
283
+ "EcsScheduledTaskConfig — the supplied logGroup owns its own retention, so logRetention " +
284
+ `(${entry.logRetention}) would be silently discarded. Supply one or the other, not both.`);
285
+ }
286
+ }
272
287
  }
273
288
  const DEFAULT_MIGRATE_CONTAINER_NAME = "migrate";
274
289
  /**
@@ -976,6 +991,7 @@ export class EcsCompute extends Construct {
976
991
  applicationId: props.applicationId ??
977
992
  (App.hasInstance() ? App.getInstance().getName() : undefined),
978
993
  ...(props.taskStopWatchdog === false && { taskStopWatchdog: false }),
994
+ ...(props.deployableServices === false && { deployableServices: false }),
979
995
  ...(props.containerInsights !== undefined && {
980
996
  containerInsights: props.containerInsights
981
997
  }),
@@ -1,6 +1,6 @@
1
1
  import { type Repository } from "aws-cdk-lib/aws-ecr";
2
2
  import { type RepositoryImage, type ContainerImage, type PortMapping, type Secret as EcsSecret, type NetworkMode } from "aws-cdk-lib/aws-ecs";
3
- import { type IPeer, type ISecurityGroup, type IVpc, type Port, type SubnetSelection } from "aws-cdk-lib/aws-ec2";
3
+ import { type IPeer, type ISecurityGroup, type IVpc, type Port } from "aws-cdk-lib/aws-ec2";
4
4
  import { type ILogGroup, type RetentionDays } from "aws-cdk-lib/aws-logs";
5
5
  import { type PolicyDocument, type IManagedPolicy, type PolicyStatement } from "aws-cdk-lib/aws-iam";
6
6
  import type { ITopic } from "aws-cdk-lib/aws-sns";
@@ -554,7 +554,8 @@ export interface EcsScheduledTaskConfig {
554
554
  * Connections to data resources — the same connector interface services
555
555
  * use. Produces IAM grants on the task role, security-group wiring (via the
556
556
  * cluster's EC2 capacity security group — scheduled tasks run on the
557
- * cluster's instances), and schema-version gate env injection when a
557
+ * cluster's instances, which is why `networkMode: AWS_VPC` is refused
558
+ * here), and schema-version gate env injection when a
558
559
  * connected database declares `migrations:`. Scheduled tasks never
559
560
  * materialise a `fjall-schema-gate` container; only the env is injected —
560
561
  * the task's own entrypoint decides whether to gate on it.
@@ -576,8 +577,18 @@ export interface EcsScheduledTaskConfig {
576
577
  * removal default (D17).
577
578
  */
578
579
  logRetention?: RetentionDays;
579
- securityGroups?: ISecurityGroup[];
580
- subnetSelection?: SubnetSelection;
580
+ /**
581
+ * Task network mode. Omit for the `Ec2TaskDefinition` default (BRIDGE, as
582
+ * measured on aws-cdk-lib 2.265.0 — the caret range can move it); `HOST` is
583
+ * permitted.
584
+ *
585
+ * `AWS_VPC` is REFUSED at synth. A scheduled task's `connections` are wired
586
+ * onto the cluster's EC2 capacity security groups
587
+ * (`EcsCluster.getEc2CapacityConnectable`), which an awsvpc task's own ENI
588
+ * does not wear — every grant would land and every network path would not,
589
+ * with nothing failing at synth. Govern reachability with the cluster-level
590
+ * `securityGroup` on `EcsClusterConfig` instead.
591
+ */
581
592
  networkMode?: NetworkMode;
582
593
  /** Host-bind volumes mounted into the task's container. */
583
594
  volumes?: ContainerVolume[];
@@ -641,6 +652,10 @@ export interface EcsClusterConfig {
641
652
  * Scheduled tasks materialised into `Ec2TaskDefinition`s and registered via
642
653
  * `app.addSchedule(...)` against the existing `EcsScheduleTarget` shape
643
654
  * ({ ecs, serviceName, taskCount }). See AC34.
655
+ *
656
+ * These run on the cluster's own EC2 instances and share their network
657
+ * interface, so `networkMode: AWS_VPC` is refused per entry — see
658
+ * {@link EcsScheduledTaskConfig.networkMode}.
644
659
  */
645
660
  scheduledTasks?: EcsScheduledTaskConfig[];
646
661
  }
@@ -1081,6 +1096,17 @@ export interface EcsComputeProps {
1081
1096
  * opt-out for cost-sensitive clusters.
1082
1097
  */
1083
1098
  taskStopWatchdog?: false;
1099
+ /**
1100
+ * Deployable-service exports (default on): one `DeployableService` output
1101
+ * per service plus the cluster's `DeployableCluster` and `ClusterArn`
1102
+ * outputs, named by `@fjall/util`'s `stackExportNames` builders. The
1103
+ * deploy engine's `EcsServiceResolver` reads the app's Compute stack
1104
+ * outputs to find the ECS services that `fjall services`, `fjall rollout`,
1105
+ * `fjall restart`, a code-only deploy and a rollback act on. `false` opts
1106
+ * out a cluster hosting an infrastructure service, so the resolver never
1107
+ * binds it as an app service.
1108
+ */
1109
+ deployableServices?: false;
1084
1110
  /**
1085
1111
  * CloudWatch Container Insights (default off). Enabling it bills
1086
1112
  * ~$0.30/metric-month of custom `ECS/ContainerInsights` metrics per cluster
@@ -19,6 +19,7 @@ const COMPUTE_PROP_APPLICABILITY = {
19
19
  alertsTopic: ["ecs"],
20
20
  applicationId: ["ecs"],
21
21
  taskStopWatchdog: ["ecs"],
22
+ deployableServices: ["ecs"],
22
23
  containerInsights: ["ecs"],
23
24
  deployment: ["lambda"],
24
25
  functionUrl: ["lambda"],
@@ -99,9 +99,9 @@ export class Domain extends Construct {
99
99
  }
100
100
  }
101
101
  /**
102
- * Re-publish the domain's effective zone name under a predictable export
103
- * key so downstream consumers (Phase 2, Phase 3 eject) can resolve the
104
- * managed zone without knowing the registrar discriminant. The `Zone` and
102
+ * Re-publish the domain's effective zone name under a predictable output
103
+ * key (mirrored byte-for-byte by the eject template) so a stack reader can
104
+ * name the zone without knowing the registrar discriminant. The `Zone` and
105
105
  * `HostedZoneId` outputs on the inner `HostedZone` construct cover the
106
106
  * Route53-backed paths; this output covers external-records mode where no
107
107
  * hosted zone exists.
@@ -7,12 +7,15 @@ export interface ExternalRecordsPatternResult {
7
7
  }
8
8
  /**
9
9
  * Composition for `registrar: "external-records"`. No Route53 hosted zone is
10
- * created; certificates still use ACM DNS validation (the user must paste the
11
- * validation records at their external registrar) and any user-declared
12
- * records are emitted as `ManualRecord` entries with per-record `CfnOutput`s
13
- * so Phase 2 can resolve token values after deploy.
10
+ * created. Certificates use ACM DNS validation with no zone to write to, so
11
+ * their validation CNAMEs must be created at the external registrar — and
12
+ * those records do not exist at synth: ACM chooses them when CloudFormation
13
+ * creates the certificate, and `AWS::CertificateManager::Certificate`
14
+ * returns only its ARN. They are therefore never `ManualRecord` entries:
15
+ * they are read from ACM (`DescribeCertificate`) once the certificate is
16
+ * pending, while CloudFormation waits for the records to resolve.
14
17
  *
15
- * Ordering invariant: certificate-validation entries come first so the user
16
- * adds them before the app records (ACM can't issue until validation resolves).
18
+ * User-declared records become `ManualRecord` entries, each with a
19
+ * `CfnOutput` triple keyed by its position in `records`.
17
20
  */
18
21
  export declare function composeExternalRecords(scope: Construct, props: ExternalRecordsProps): ExternalRecordsPatternResult;
@@ -6,58 +6,35 @@ import { resolveRecordFqdn } from "../../utils/domainTypes.js";
6
6
  const MANUAL_RECORD_TTL_SECONDS = 300;
7
7
  /**
8
8
  * Composition for `registrar: "external-records"`. No Route53 hosted zone is
9
- * created; certificates still use ACM DNS validation (the user must paste the
10
- * validation records at their external registrar) and any user-declared
11
- * records are emitted as `ManualRecord` entries with per-record `CfnOutput`s
12
- * so Phase 2 can resolve token values after deploy.
9
+ * created. Certificates use ACM DNS validation with no zone to write to, so
10
+ * their validation CNAMEs must be created at the external registrar — and
11
+ * those records do not exist at synth: ACM chooses them when CloudFormation
12
+ * creates the certificate, and `AWS::CertificateManager::Certificate`
13
+ * returns only its ARN. They are therefore never `ManualRecord` entries:
14
+ * they are read from ACM (`DescribeCertificate`) once the certificate is
15
+ * pending, while CloudFormation waits for the records to resolve.
13
16
  *
14
- * Ordering invariant: certificate-validation entries come first so the user
15
- * adds them before the app records (ACM can't issue until validation resolves).
17
+ * User-declared records become `ManualRecord` entries, each with a
18
+ * `CfnOutput` triple keyed by its position in `records`.
16
19
  */
17
20
  export function composeExternalRecords(scope, props) {
18
21
  const safeZone = toPascalCase(getSafeZoneName(props.zoneName));
19
22
  const certificates = new Map();
20
23
  const manualRecords = [];
21
- // 1. Certificates — ACM DNS validation emits CNAMEs the user must paste at
22
- // their registrar. Capture the validation records as ManualRecord
23
- // entries (cert-validation first per ordering invariant).
24
24
  const normalisedCerts = (props.certificates ?? []).map(normaliseCertificate);
25
25
  normalisedCerts.forEach((cert, index) => {
26
26
  const safeCertName = toPascalCase(cert.domainName.split(".").join(""));
27
27
  const acm = new AcmCertificate(scope, `${safeZone}${safeCertName}Cert${index}`, {
28
28
  domainName: cert.domainName,
29
29
  subjectAlternativeNames: cert.subjectAlternativeNames,
30
- // No hosted zone to validate against — ACM emits tokens for the user
31
- // to resolve via `describeStackResources` or deploy output.
32
30
  validation: CertificateValidation.fromDns(),
33
31
  transparencyLoggingEnabled: cert.transparencyLogging ?? true
34
32
  });
35
33
  certificates.set(cert.domainName, acm);
36
- const certNames = [
37
- cert.domainName,
38
- ...(cert.subjectAlternativeNames ?? [])
39
- ];
40
- certNames.forEach((name, nameIndex) => {
41
- // `domainValidationOption` returns a token CNAME name/value pair. Using
42
- // `attrDomainValidationOptions` returns an array token; per-name lookup
43
- // uses index. CDK exposes the first validation option via
44
- // `certificate.certificateArn` only — we fall back to emitting a
45
- // predictable CfnOutput pointing to the attribute for Phase 2.
46
- const record = {
47
- name: `_acme-challenge.${name}`,
48
- type: "CNAME",
49
- value: `<ACM-validation-token-for-${name}>`,
50
- ttl: MANUAL_RECORD_TTL_SECONDS,
51
- reason: "certificate-validation"
52
- };
53
- manualRecords.push(record);
54
- emitManualRecordOutputs(scope, safeZone, manualRecords.length - 1, record, `CertValidation${safeCertName}${nameIndex}`);
55
- });
56
34
  });
57
- // 2. User-declared records. Typed targets resolve to token strings via
58
- // `resolveTargetToDnsName`. Apex ALIAS was already rejected by
59
- // `validateDomainProps`, so all records here are safe to emit as manual
60
- // entries.
35
+ // Typed targets resolve to token strings via `resolveTargetToDnsName`.
36
+ // Apex ALIAS was already rejected by `validateDomainProps`, so all records
37
+ // here are safe to emit as manual entries.
61
38
  const records = props.records ?? [];
62
39
  records.forEach((record, index) => {
63
40
  const fqdn = resolveRecordFqdn(record.name, props.zoneName);
@@ -67,11 +44,10 @@ export function composeExternalRecords(scope, props) {
67
44
  name: fqdn,
68
45
  type: "CNAME",
69
46
  value,
70
- ttl: MANUAL_RECORD_TTL_SECONDS,
71
- reason: "user-declared"
47
+ ttl: MANUAL_RECORD_TTL_SECONDS
72
48
  };
73
49
  manualRecords.push(manual);
74
- emitManualRecordOutputs(scope, safeZone, manualRecords.length - 1, manual, `UserRecord${index}`);
50
+ emitManualRecordOutputs(scope, safeZone, index, manual);
75
51
  return;
76
52
  }
77
53
  const rawValue = record.value;
@@ -80,11 +56,10 @@ export function composeExternalRecords(scope, props) {
80
56
  name: fqdn,
81
57
  type: narrowManualType(record.type),
82
58
  value: joined,
83
- ttl: record.ttl ?? MANUAL_RECORD_TTL_SECONDS,
84
- reason: "user-declared"
59
+ ttl: record.ttl ?? MANUAL_RECORD_TTL_SECONDS
85
60
  };
86
61
  manualRecords.push(manual);
87
- emitManualRecordOutputs(scope, safeZone, manualRecords.length - 1, manual, `UserRecord${index}`);
62
+ emitManualRecordOutputs(scope, safeZone, index, manual);
88
63
  });
89
64
  return { certificates, manualRecords };
90
65
  }
@@ -115,17 +90,18 @@ function narrowManualType(type) {
115
90
  return "TXT";
116
91
  }
117
92
  }
118
- function emitManualRecordOutputs(scope, safeZone, index, record, discriminator) {
119
- new CfnOutput(scope, `${safeZone}ManualRecord${discriminator}Name`, {
93
+ function emitManualRecordOutputs(scope, safeZone, index, record) {
94
+ const idPrefix = `${safeZone}ManualRecordUserRecord${index}`;
95
+ new CfnOutput(scope, `${idPrefix}Name`, {
120
96
  key: `${safeZone}ManualRecord${index}Name`,
121
97
  value: record.name,
122
- description: `External-records manual DNS entry (${record.reason})`
98
+ description: "External-records manual DNS entry (user-declared)"
123
99
  });
124
- new CfnOutput(scope, `${safeZone}ManualRecord${discriminator}Type`, {
100
+ new CfnOutput(scope, `${idPrefix}Type`, {
125
101
  key: `${safeZone}ManualRecord${index}Type`,
126
102
  value: record.type
127
103
  });
128
- new CfnOutput(scope, `${safeZone}ManualRecord${discriminator}Value`, {
104
+ new CfnOutput(scope, `${idPrefix}Value`, {
129
105
  key: `${safeZone}ManualRecord${index}Value`,
130
106
  value: record.value
131
107
  });
@@ -158,15 +158,18 @@ export interface ExternalRecordsProps extends DomainCommonProps {
158
158
  }
159
159
  export type DomainProps = Route53ApexProps | ExternalDelegatedProps | ExternalRecordsProps;
160
160
  /**
161
- * Manual record emitted by `registrar: "external-records"` mode for the user
162
- * to paste at their external registrar. For typed targets the `value` is a
163
- * CloudFormation token (`Fn::ImportValue`) at synth time; Phase 2 resolves the
164
- * concrete value via `describeStackResources` after deploy.
161
+ * A user-declared record that `registrar: "external-records"` mode reports
162
+ * for the user to create at their external registrar, one per `records`
163
+ * entry, in declaration order. For typed targets the `value` is a
164
+ * CloudFormation token (`Fn::ImportValue`) at synth time; the concrete host
165
+ * is the deployed stack's `ManualRecord<index>Value` output.
166
+ *
167
+ * Certificate-validation records are never manual records: ACM chooses them
168
+ * at deploy time, so no synth-time value exists (see `composeExternalRecords`).
165
169
  */
166
170
  export interface ManualRecord {
167
171
  readonly name: string;
168
172
  readonly type: "A" | "AAAA" | "CNAME" | "TXT";
169
173
  readonly value: string;
170
174
  readonly ttl: number;
171
- readonly reason: "certificate-validation" | "user-declared";
172
175
  }
@@ -18,6 +18,7 @@ import { type ICertificate } from "aws-cdk-lib/aws-certificatemanager";
18
18
  import { type Duration } from "aws-cdk-lib";
19
19
  import { type Construct } from "constructs";
20
20
  import type { HstsConfig } from "../../../resources/aws/cdn/responseHeadersPolicy.js";
21
+ import type { OpenNextBucketKey } from "@fjall/util/patterns";
21
22
  import { type RelationalDatabase, type DynamoDBDatabase, type ProxyConfig, type ReadReplicaConfig, type CredentialsConfig, type EncryptionConfig, type AuroraEncryptionConfig, type AuroraWriterConfig, type AuroraReadersConfig, type DatabaseInsightsConfig } from "../database.js";
22
23
  import { type LambdaCompute } from "../compute.js";
23
24
  import { type Storage } from "../storage.js";
@@ -210,22 +211,20 @@ export interface PatternStorageBucketConfig {
210
211
  versioned?: boolean;
211
212
  }
212
213
  /**
213
- * Full storage configuration for patterns.
214
- * Allows per-bucket configuration for each S3 bucket in the pattern.
214
+ * Full storage configuration for patterns: one optional entry per bucket
215
+ * the OpenNext construct builds — `assets` (static files), `cache` (the ISR
216
+ * cache) and `media` (uploads) — keyed by `OPENNEXT_BUCKET_KEYS` in
217
+ * `@fjall/util/patterns`, the vocabulary the generator's config schema is
218
+ * pinned to as well.
215
219
  *
216
220
  * @example
217
221
  * storage: {
218
222
  * media: { versioned: true } // force on below the resilient tier
219
223
  * }
220
224
  */
221
- export interface PayloadStorageConfig {
222
- /** Assets bucket (static files) */
223
- assets?: PatternStorageBucketConfig;
224
- /** Cache bucket (ISR cache) */
225
- cache?: PatternStorageBucketConfig;
226
- /** Media bucket (uploads) */
227
- media?: PatternStorageBucketConfig;
228
- }
225
+ export type PayloadStorageConfig = {
226
+ [Bucket in OpenNextBucketKey]?: PatternStorageBucketConfig;
227
+ };
229
228
  /**
230
229
  * SQS queue configuration for pattern messaging.
231
230
  */
@@ -28,6 +28,16 @@ export interface IQueueProps {
28
28
  maxReceiveCount?: number;
29
29
  };
30
30
  encryption?: "SSE_SQS" | "SSE_KMS" | "NONE";
31
+ /**
32
+ * ARN of a customer-managed KMS key to encrypt the queue (and its
33
+ * auto-created DLQ) with. Requires `encryption: "SSE_KMS"` — the resource
34
+ * layer refuses the pair rather than ignoring the key. Absent with
35
+ * `"SSE_KMS"`, the queue takes the stack's shared key.
36
+ *
37
+ * An ARN rather than an `IKey` because this is the plan vocabulary the
38
+ * scaffold and the codemod write, the same as `S3Props.kmsKeyArn`; a key
39
+ * you hold in `infrastructure.ts` passes as `key.keyArn`.
40
+ */
31
41
  kmsKeyArn?: string;
32
42
  contentBasedDeduplication?: boolean;
33
43
  fifoThroughputLimit?: "perQueue" | "perMessageGroupId";
@@ -1,3 +1,4 @@
1
+ import { Key } from "aws-cdk-lib/aws-kms";
1
2
  import { EventBus as CdkEventBus } from "aws-cdk-lib/aws-events";
2
3
  import { SQSQueue, SQS_LIMITS } from "../../resources/aws/messaging/sqs.js";
3
4
  import { SNSTopic } from "../../resources/aws/messaging/sns.js";
@@ -49,11 +50,10 @@ function validateSQSProps(props) {
49
50
  "This setting will be ignored for standard queues.");
50
51
  }
51
52
  }
52
- // Validate KMS encryption
53
- if (props.kmsKeyArn && props.encryption !== "SSE_KMS") {
54
- FjallLogger.warn("'kmsKeyArn' provided but 'encryption' is not SSE_KMS. " +
55
- "The KMS key will be ignored.");
56
- }
53
+ // `kmsKeyArn` beside a non-KMS encryption used to warn here and then be
54
+ // ignored. `SQSQueue` refuses it instead — a warning that the queue is
55
+ // about to discard the key it was handed is not a gate, and the refusal
56
+ // states both cures.
57
57
  }
58
58
  /**
59
59
  * Validates SNS Topic props.
@@ -85,7 +85,11 @@ export class QueueMessaging extends SQSQueue {
85
85
  deliveryDelay: props.deliveryDelay,
86
86
  deadLetterQueue: props.deadLetterQueue,
87
87
  encryption: props.encryption,
88
- kmsKeyArn: props.kmsKeyArn,
88
+ // Resolved in the parent scope for the same reason `alertsTopic` is:
89
+ // `this` does not exist before super().
90
+ masterKey: props.kmsKeyArn === undefined
91
+ ? undefined
92
+ : Key.fromKeyArn(scope, `${id}KmsKey`, props.kmsKeyArn),
89
93
  contentBasedDeduplication: props.contentBasedDeduplication,
90
94
  fifoThroughputLimit: props.fifoThroughputLimit,
91
95
  deduplicationScope: props.deduplicationScope,