@fjall/components-infrastructure 2.1.1 → 2.2.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.
@@ -3436,7 +3436,7 @@ ${values.join("\n")}` : `${blockName} :`;
3436
3436
  };
3437
3437
  LocalBooleanValueBlock.NAME = "BooleanValueBlock";
3438
3438
  var _a$s;
3439
- var Boolean = class extends BaseBlock {
3439
+ var Boolean2 = class extends BaseBlock {
3440
3440
  getValue() {
3441
3441
  return this.valueBlock.value;
3442
3442
  }
@@ -3452,11 +3452,11 @@ ${values.join("\n")}` : `${blockName} :`;
3452
3452
  return `${this.constructor.NAME} : ${this.getValue}`;
3453
3453
  }
3454
3454
  };
3455
- _a$s = Boolean;
3455
+ _a$s = Boolean2;
3456
3456
  (() => {
3457
3457
  typeStore.Boolean = _a$s;
3458
3458
  })();
3459
- Boolean.NAME = "BOOLEAN";
3459
+ Boolean2.NAME = "BOOLEAN";
3460
3460
  var LocalOctetStringValueBlock = class extends HexBlock(LocalConstructedValueBlock) {
3461
3461
  constructor({ isConstructed = false, ...parameters } = {}) {
3462
3462
  super(parameters);
@@ -5420,7 +5420,7 @@ ${values.join("\n")}` : `${blockName} :`;
5420
5420
  exports2.BaseStringBlock = BaseStringBlock;
5421
5421
  exports2.BitString = BitString;
5422
5422
  exports2.BmpString = BmpString;
5423
- exports2.Boolean = Boolean;
5423
+ exports2.Boolean = Boolean2;
5424
5424
  exports2.CharacterString = CharacterString;
5425
5425
  exports2.Choice = Choice;
5426
5426
  exports2.Constructed = Constructed;
@@ -17814,7 +17814,8 @@ var { webcrypto: crypto2, createHash } = require("node:crypto");
17814
17814
  var x509 = require_x509_cjs();
17815
17815
  var {
17816
17816
  SecretsManagerClient,
17817
- PutSecretValueCommand
17817
+ PutSecretValueCommand,
17818
+ DeleteSecretCommand
17818
17819
  } = require("@aws-sdk/client-secrets-manager");
17819
17820
  x509.cryptoProvider.set(crypto2);
17820
17821
  var ECDSA_P256 = Object.freeze({
@@ -17898,6 +17899,27 @@ ${body}
17898
17899
  }
17899
17900
  exports.handler = async (event) => {
17900
17901
  if (event.RequestType === "Delete") {
17902
+ const props2 = event.ResourceProperties || {};
17903
+ const arns = [props2.caSecretArn, props2.serverSecretArn].filter(Boolean);
17904
+ if (arns.length > 0) {
17905
+ const sm2 = new SecretsManagerClient({});
17906
+ for (const SecretId of arns) {
17907
+ try {
17908
+ await sm2.send(
17909
+ new DeleteSecretCommand({
17910
+ SecretId,
17911
+ ForceDeleteWithoutRecovery: true
17912
+ })
17913
+ );
17914
+ } catch (err) {
17915
+ console.warn("TlsCertGenerator: force-DeleteSecret failed", {
17916
+ SecretId,
17917
+ name: err && err.name,
17918
+ message: err && err.message
17919
+ });
17920
+ }
17921
+ }
17922
+ }
17901
17923
  return { PhysicalResourceId: event.PhysicalResourceId || "tls-cert" };
17902
17924
  }
17903
17925
  const props = event.ResourceProperties || {};
@@ -211,6 +211,10 @@ export declare class ClickHouseDatabase extends Construct implements IClickHouse
211
211
  * carries the IAM grant via the standard `secretsImport` framework path.
212
212
  */
213
213
  getMigrationContributions(): MigrationContributions;
214
+ getSchemaGateContribution(): {
215
+ readonly environment: Record<string, string>;
216
+ readonly secretsImport: Record<string, SecretImport>;
217
+ } | undefined;
214
218
  /**
215
219
  * Wires a task definition to connect to ClickHouse as `opts.user` (if
216
220
  * supplied). Adds `CLICKHOUSE_URL` / `CLICKHOUSE_DATABASE` env vars to the
@@ -1,4 +1,4 @@
1
- import { CLICKHOUSE_MANAGED_USERS_ENV, pickLatestClickHouseMigration } from "@fjall/util/migration";
1
+ import { CLICKHOUSE_MANAGED_USERS_ENV, pickLatestClickHouseMigration, SCHEMA_ADMIN_PASSWORD_ENV, SCHEMA_ADMIN_USER_ENV } from "@fjall/util/migration";
2
2
  import { Annotations, CfnOutput, Duration, Fn, Stack } from "aws-cdk-lib";
3
3
  import { Connections, Port, UserData } from "aws-cdk-lib/aws-ec2";
4
4
  import { Monitoring } from "aws-cdk-lib/aws-autoscaling";
@@ -262,12 +262,13 @@ export class ClickHouseDatabase extends Construct {
262
262
  region: Stack.of(this).region
263
263
  }
264
264
  }),
265
- tlsActive,
266
265
  ...(tlsActive &&
267
266
  tlsCaSecret !== undefined &&
268
267
  tlsServerSecret !== undefined && {
269
- caSecretArn: tlsCaSecret.secretArn,
270
- serverSecretArn: tlsServerSecret.secretArn
268
+ tls: {
269
+ caSecretArn: tlsCaSecret.secretArn,
270
+ serverSecretArn: tlsServerSecret.secretArn
271
+ }
271
272
  })
272
273
  }));
273
274
  // Source.data wraps the content in a zip; the default `extract: true`
@@ -653,7 +654,7 @@ export class ClickHouseDatabase extends Construct {
653
654
  }
654
655
  grantConnect(grantee) {
655
656
  this.connections.allowDefaultPortFrom(grantee);
656
- this.connections.allowFrom(grantee, Port.tcp(CLICKHOUSE_NATIVE_PORT));
657
+ this.connections.allowFrom(grantee, Port.tcp(this.#nativePort));
657
658
  }
658
659
  /**
659
660
  * Migration contributions for a task connecting to this ClickHouse cluster
@@ -679,11 +680,13 @@ export class ClickHouseDatabase extends Construct {
679
680
  getMigrationContributions() {
680
681
  const environment = {
681
682
  CLICKHOUSE_URL: this.getUrl(),
682
- CLICKHOUSE_DATABASE: this.getDatabaseName()
683
+ CLICKHOUSE_DATABASE: this.getDatabaseName(),
684
+ [SCHEMA_ADMIN_USER_ENV]: this.#schemaAdmin.name
683
685
  };
684
686
  const secretsImport = {};
685
687
  const adminSecret = this.getUser(this.#schemaAdmin.name);
686
- secretsImport.SCHEMA_ADMIN_PASSWORD = adminSecret.getImport("password");
688
+ secretsImport[SCHEMA_ADMIN_PASSWORD_ENV] =
689
+ adminSecret.getImport("password");
687
690
  const caCertImport = this.getTlsCaCertImport();
688
691
  if (caCertImport !== undefined) {
689
692
  secretsImport.CLICKHOUSE_CA_CERT = caCertImport;
@@ -715,6 +718,19 @@ export class ClickHouseDatabase extends Construct {
715
718
  }
716
719
  return { environment, secretsImport, egress };
717
720
  }
721
+ getSchemaGateContribution() {
722
+ if (this.#migrationsConfig === undefined)
723
+ return undefined;
724
+ const adminSecret = this.getUser(this.#schemaAdmin.name);
725
+ return {
726
+ environment: {
727
+ [SCHEMA_ADMIN_USER_ENV]: this.#schemaAdmin.name
728
+ },
729
+ secretsImport: {
730
+ [SCHEMA_ADMIN_PASSWORD_ENV]: adminSecret.getImport("password")
731
+ }
732
+ };
733
+ }
718
734
  /**
719
735
  * Wires a task definition to connect to ClickHouse as `opts.user` (if
720
736
  * supplied). Adds `CLICKHOUSE_URL` / `CLICKHOUSE_DATABASE` env vars to the
@@ -35,6 +35,14 @@ export interface ClickHouseNoTls {
35
35
  readonly acknowledgement: "I understand plaintext-only ClickHouse fails SOC2 DP5";
36
36
  }
37
37
  export type ClickHouseTlsOptions = ClickHouseSelfSignedTls | ClickHouseImportedTls | ClickHouseNoTls;
38
+ /**
39
+ * Canonical fixture for the plaintext-only escape hatch. Spelling the
40
+ * acknowledgement literal once and importing it everywhere prevents drift
41
+ * between the type definition and consumers. Pass via `tls:` to opt out of
42
+ * TLS-by-default; the construct still requires the explicit acknowledgement
43
+ * for the same reason the type does — silent plaintext is the SOC2 trap.
44
+ */
45
+ export declare const CLICKHOUSE_TLS_NONE: ClickHouseNoTls;
38
46
  /**
39
47
  * The resolved TLS surface a `ClickHouseDatabase` carries after dispatch.
40
48
  * Every field is `undefined` in `mode: "none"`. `certGeneratorFunction`
@@ -1 +1,11 @@
1
- export {};
1
+ /**
2
+ * Canonical fixture for the plaintext-only escape hatch. Spelling the
3
+ * acknowledgement literal once and importing it everywhere prevents drift
4
+ * between the type definition and consumers. Pass via `tls:` to opt out of
5
+ * TLS-by-default; the construct still requires the explicit acknowledgement
6
+ * for the same reason the type does — silent plaintext is the SOC2 trap.
7
+ */
8
+ export const CLICKHOUSE_TLS_NONE = {
9
+ mode: "none",
10
+ acknowledgement: "I understand plaintext-only ClickHouse fails SOC2 DP5"
11
+ };
@@ -4,6 +4,7 @@ import { type IGrantable, type IRole, Grant } from "aws-cdk-lib/aws-iam";
4
4
  import { type IApplicationLoadBalancer, type ApplicationListener } from "aws-cdk-lib/aws-elasticloadbalancingv2";
5
5
  import { Construct } from "constructs";
6
6
  import { type IEcsCompute } from "./interfaces/compute.js";
7
+ import { type SecretImport } from "../../resources/aws/secrets/index.js";
7
8
  import EcsCluster, { type EcsClusterProps } from "../../resources/aws/compute/ecs.js";
8
9
  export { ScalingType } from "./computeEcsTypes.js";
9
10
  export type { EcsCapacityProvider, Ec2CapacityConfig, RemoteConnectionSpec, EcsCapacityProviderConfig, EcsContainerConfig, ContainerDependency, ContainerVolume, EcsScheduledTaskConfig, EcsLifecycleHookMigrationsConfig, EcsPostDeployMigrationsConfig, EcsHookMigrationsConfig, EcsMigrationsConfig, EcsMigrationsMode, EcsCircuitBreakerConfig, EcsScalingConfig, EcsClusterConfig, EcsRoutingConfig, EcsServiceConfig, EcsComputeProps } from "./computeEcsTypes.js";
@@ -48,7 +49,10 @@ export declare function expandMigrationsSugar(service: EcsServiceConfig, userCon
48
49
  * themselves and the resolved value differs.
49
50
  * @internal Exported for testing only
50
51
  */
51
- export declare function buildContainerConfigs(service: EcsServiceConfig, schemaVersionEnv?: Record<string, string>, annotationsScope?: Construct, chSchemaVersionEnv?: Record<string, string>): EcsClusterProps["services"][number]["containers"];
52
+ export declare function buildContainerConfigs(service: EcsServiceConfig, schemaVersionEnv?: Record<string, string>, annotationsScope?: Construct, chGate?: {
53
+ environment: Record<string, string>;
54
+ secretsImport: Record<string, SecretImport>;
55
+ }): EcsClusterProps["services"][number]["containers"];
52
56
  /**
53
57
  * Resolved scaling configuration for an ECS service.
54
58
  * @internal Exported for testing only
@@ -94,13 +98,17 @@ export declare class EcsCompute extends Construct implements IEcsCompute {
94
98
  private resolveSchemaVersionEnv;
95
99
  /**
96
100
  * ClickHouse mirror of `resolveSchemaVersionEnv`. Returns the
97
- * `EXPECTED_CH_SCHEMA_VERSION` env entry, or `undefined` when the service is
98
- * not gated.
101
+ * `EXPECTED_CH_SCHEMA_VERSION` env entry alongside the schema-admin
102
+ * credentials the boot gate needs to authenticate against
103
+ * `_schema_migrations`, or `undefined` when the service is not gated.
99
104
  *
100
105
  * - `schemaGate: false` → returns `undefined` (auditable opt-out — same flag
101
106
  * covers BOTH PG and CH gates)
102
107
  * - No migrated CH in connections → returns `undefined`
103
- * - Exactly one migrated CH → returns `{ EXPECTED_CH_SCHEMA_VERSION }`
108
+ * - Exactly one migrated CH → returns env `EXPECTED_CH_SCHEMA_VERSION` +
109
+ * `SCHEMA_ADMIN_USER`, plus `secretsImport.SCHEMA_ADMIN_PASSWORD`. The
110
+ * creds piggyback on the same gate resolution so adding the gate to a
111
+ * service can't ship without the creds it needs to use it.
104
112
  * - Two or more migrated CHs → throws via `resolveClickHouseDatabaseForService`
105
113
  */
106
114
  private resolveClickHouseSchemaVersionEnv;
@@ -16,6 +16,7 @@ import { createScheduledTaskDefinition, createMigrationTaskDefinition } from "..
16
16
  import { EcsLifecycleHookMigration } from "../../resources/aws/compute/ecsLifecycleHookMigration.js";
17
17
  import { Role } from "../../resources/aws/iam/role.js";
18
18
  import { LogGroup } from "../../resources/aws/logging/logGroup.js";
19
+ import { Schedule } from "../../resources/aws/messaging/schedule.js";
19
20
  import { SecurityGroup } from "../../resources/aws/networking/securityGroup.js";
20
21
  import { vpcHasNatGateways } from "../../utils/vpcUtils.js";
21
22
  import { toPascalCase } from "../../utils/capitaliseString.js";
@@ -467,13 +468,15 @@ function mergeContributionsIntoSeparateTaskDef(separateTaskDef, contributions) {
467
468
  * themselves and the resolved value differs.
468
469
  * @internal Exported for testing only
469
470
  */
470
- export function buildContainerConfigs(service, schemaVersionEnv, annotationsScope, chSchemaVersionEnv) {
471
+ export function buildContainerConfigs(service, schemaVersionEnv, annotationsScope, chGate) {
471
472
  const userContainers = service.containers && service.containers.length > 0
472
473
  ? service.containers
473
474
  : undefined;
474
475
  const expanded = service.migrations
475
476
  ? expandMigrationsSugar(service, userContainers)
476
477
  : userContainers;
478
+ const chSchemaVersionEnv = chGate?.environment;
479
+ const chGateSecretsImport = chGate?.secretsImport;
477
480
  const mergeSchemaEnv = (authored) => {
478
481
  if (schemaVersionEnv === undefined)
479
482
  return authored;
@@ -517,12 +520,29 @@ export function buildContainerConfigs(service, schemaVersionEnv, annotationsScop
517
520
  `ClickHouse database's \`migrations:\` config ('${resolvedValue}'). The ` +
518
521
  `author value wins; set \`schemaGate: false\` to silence this warning.`);
519
522
  }
523
+ return mergeAuthorWins(authored, chSchemaVersionEnv);
524
+ }
525
+ return mergeAuthorWins(authored, chSchemaVersionEnv);
526
+ };
527
+ const mergeAuthorWins = (authored, framework) => {
528
+ const merged = { ...(authored ?? {}) };
529
+ for (const [k, v] of Object.entries(framework)) {
530
+ if (merged[k] === undefined)
531
+ merged[k] = v;
532
+ }
533
+ return merged;
534
+ };
535
+ const mergeChGateSecretsImport = (authored) => {
536
+ if (chGateSecretsImport === undefined)
520
537
  return authored;
538
+ if (Object.keys(chGateSecretsImport).length === 0)
539
+ return authored;
540
+ const merged = { ...(authored ?? {}) };
541
+ for (const [k, v] of Object.entries(chGateSecretsImport)) {
542
+ if (merged[k] === undefined)
543
+ merged[k] = v;
521
544
  }
522
- return {
523
- ...(authored ?? {}),
524
- [EXPECTED_CH_SCHEMA_VERSION_ENV]: resolvedValue
525
- };
545
+ return merged;
526
546
  };
527
547
  const mergeAllSchemaEnv = (authored) => mergeChSchemaEnv(mergeSchemaEnv(authored));
528
548
  if (expanded) {
@@ -533,7 +553,7 @@ export function buildContainerConfigs(service, schemaVersionEnv, annotationsScop
533
553
  port: c.port,
534
554
  environment: mergeAllSchemaEnv(c.environment),
535
555
  secrets: c.secrets,
536
- secretsImport: c.secretsImport,
556
+ secretsImport: mergeChGateSecretsImport(c.secretsImport),
537
557
  command: c.command,
538
558
  entryPoint: c.entryPoint,
539
559
  essential: c.essential,
@@ -546,10 +566,14 @@ export function buildContainerConfigs(service, schemaVersionEnv, annotationsScop
546
566
  });
547
567
  }
548
568
  const fallbackEnv = mergeAllSchemaEnv(undefined);
569
+ const fallbackSecretsImport = mergeChGateSecretsImport(undefined);
549
570
  return [
550
571
  {
551
572
  name: `${service.name}Container`,
552
- ...(fallbackEnv !== undefined && { environment: fallbackEnv })
573
+ ...(fallbackEnv !== undefined && { environment: fallbackEnv }),
574
+ ...(fallbackSecretsImport !== undefined && {
575
+ secretsImport: fallbackSecretsImport
576
+ })
553
577
  }
554
578
  ];
555
579
  }
@@ -617,8 +641,8 @@ export class EcsCompute extends Construct {
617
641
  this.appName = props.appName;
618
642
  const services = props.services.map((service) => {
619
643
  const schemaVersionEnv = this.resolveSchemaVersionEnv(service);
620
- const chSchemaVersionEnv = this.resolveClickHouseSchemaVersionEnv(service);
621
- const containers = buildContainerConfigs(service, schemaVersionEnv, this, chSchemaVersionEnv);
644
+ const chGate = this.resolveClickHouseSchemaVersionEnv(service);
645
+ const containers = buildContainerConfigs(service, schemaVersionEnv, this, chGate);
622
646
  const { scalingType, minCapacity, maxCapacity } = resolveScalingConfig(service.scaling);
623
647
  const cloudMapService = service.serviceDiscovery !== undefined
624
648
  ? App.getInstance().registerService({
@@ -728,13 +752,17 @@ export class EcsCompute extends Construct {
728
752
  }
729
753
  /**
730
754
  * ClickHouse mirror of `resolveSchemaVersionEnv`. Returns the
731
- * `EXPECTED_CH_SCHEMA_VERSION` env entry, or `undefined` when the service is
732
- * not gated.
755
+ * `EXPECTED_CH_SCHEMA_VERSION` env entry alongside the schema-admin
756
+ * credentials the boot gate needs to authenticate against
757
+ * `_schema_migrations`, or `undefined` when the service is not gated.
733
758
  *
734
759
  * - `schemaGate: false` → returns `undefined` (auditable opt-out — same flag
735
760
  * covers BOTH PG and CH gates)
736
761
  * - No migrated CH in connections → returns `undefined`
737
- * - Exactly one migrated CH → returns `{ EXPECTED_CH_SCHEMA_VERSION }`
762
+ * - Exactly one migrated CH → returns env `EXPECTED_CH_SCHEMA_VERSION` +
763
+ * `SCHEMA_ADMIN_USER`, plus `secretsImport.SCHEMA_ADMIN_PASSWORD`. The
764
+ * creds piggyback on the same gate resolution so adding the gate to a
765
+ * service can't ship without the creds it needs to use it.
738
766
  * - Two or more migrated CHs → throws via `resolveClickHouseDatabaseForService`
739
767
  */
740
768
  resolveClickHouseSchemaVersionEnv(service) {
@@ -746,9 +774,16 @@ export class EcsCompute extends Construct {
746
774
  const version = db.getExpectedSchemaVersion();
747
775
  if (version === undefined)
748
776
  return undefined;
749
- return {
777
+ const environment = {
750
778
  [EXPECTED_CH_SCHEMA_VERSION_ENV]: version
751
779
  };
780
+ const secretsImport = {};
781
+ const gateContribution = db.getSchemaGateContribution();
782
+ if (gateContribution !== undefined) {
783
+ Object.assign(environment, gateContribution.environment);
784
+ Object.assign(secretsImport, gateContribution.secretsImport);
785
+ }
786
+ return { environment, secretsImport };
752
787
  }
753
788
  materialiseScheduledTasks(id, props) {
754
789
  const scheduledTasks = props.cluster?.scheduledTasks;
@@ -758,14 +793,18 @@ export class EcsCompute extends Construct {
758
793
  for (const entry of scheduledTasks) {
759
794
  const taskDef = this.buildScheduledTaskDefinition(id, entry);
760
795
  this.ecsCluster.registerScheduledTaskDefinition(entry.name, taskDef);
761
- app.addSchedule(`${id}${toPascalCase(entry.name)}Schedule`, {
796
+ // Construct under `this` (NOT app.addSchedule) so the schedule lives
797
+ // in EcsCompute's stack. Crossing stacks (e.g. when this EcsCompute is
798
+ // nested in ClickHouseDatabase) forces a CFN export-update freeze on
799
+ // task-def replacement.
800
+ new Schedule(this, `${id}${toPascalCase(entry.name)}Schedule`, {
762
801
  schedule: entry.schedule,
763
802
  target: {
764
803
  ecs: this,
765
804
  serviceName: entry.name,
766
805
  taskCount: 1
767
806
  },
768
- stackPlacement: "compute"
807
+ applicationId: app.getName()
769
808
  });
770
809
  }
771
810
  }
@@ -22,3 +22,4 @@ export * from "./messaging.js";
22
22
  export * from "./pattern.js";
23
23
  export * from "./vpcPeer.js";
24
24
  export * from "./vpcPeerAccepter.js";
25
+ export * from "./clickhouseTls/index.js";
@@ -29,3 +29,4 @@ export * from "./messaging.js";
29
29
  export * from "./pattern.js";
30
30
  export * from "./vpcPeer.js";
31
31
  export * from "./vpcPeerAccepter.js";
32
+ export * from "./clickhouseTls/index.js";
@@ -19,11 +19,11 @@ import { type TaskDefinition } from "aws-cdk-lib/aws-ecs";
19
19
  import { type IGrantable, type Grant, type PolicyStatement } from "aws-cdk-lib/aws-iam";
20
20
  import { type IBucket } from "aws-cdk-lib/aws-s3";
21
21
  import { type Construct } from "constructs";
22
- import { type Secret } from "../../../resources/aws/secrets/index.js";
22
+ import { type Secret, type SecretImport } from "../../../resources/aws/secrets/index.js";
23
23
  import { type SnapshotTarget } from "../../../utils/databaseTypes.js";
24
24
  import { type IMigrationContributor } from "./migrationContributor.js";
25
25
  export { type SnapshotTarget } from "../../../utils/databaseTypes.js";
26
- export { MIGRATION_SNAPSHOT_NAME_PREFIX, EXPECTED_SCHEMA_VERSION_ENV, EXPECTED_SCHEMA_VERSION_TOOL_ENV, EXPECTED_CH_SCHEMA_VERSION_ENV } from "@fjall/util";
26
+ export { MIGRATION_SNAPSHOT_NAME_PREFIX, EXPECTED_SCHEMA_VERSION_ENV, EXPECTED_SCHEMA_VERSION_TOOL_ENV, EXPECTED_CH_SCHEMA_VERSION_ENV, SCHEMA_ADMIN_USER_ENV, SCHEMA_ADMIN_PASSWORD_ENV } from "@fjall/util";
27
27
  /**
28
28
  * Declarative migration configuration on a relational database. Drives:
29
29
  * - the schema-version resolver at synth (filesystem scan of `dir`)
@@ -300,6 +300,25 @@ export interface IClickHouseDatabase extends IDatabase, IConnectable, IMigration
300
300
  * loud, never silent.
301
301
  */
302
302
  getExpectedSchemaVersion(): string | undefined;
303
+ /**
304
+ * Schema-admin credentials for the boot-time schema-version gate run by
305
+ * every container that connects to this database. Returns the username
306
+ * (plaintext env) and the password-secret import (Secrets-Manager-backed
307
+ * env) so callers can authenticate against `_schema_migrations`. Returns
308
+ * `undefined` when no `migrations:` config is declared on this database —
309
+ * the gate is the only consumer, so without migrations there's nothing
310
+ * for it to verify.
311
+ *
312
+ * Distinct from `getMigrationContributions()`: that's the per-migration
313
+ * task contract (env + secrets + IAM + egress for running migrations);
314
+ * this is the narrower per-runtime-service contract (env + secret only)
315
+ * needed by the boot gate. Both contributions name the same secret —
316
+ * synth-time merging is the caller's concern.
317
+ */
318
+ getSchemaGateContribution(): {
319
+ readonly environment: Record<string, string>;
320
+ readonly secretsImport: Record<string, SecretImport>;
321
+ } | undefined;
303
322
  /**
304
323
  * Grant connect permissions to a grantee.
305
324
  * Adds the grantee to the database security group on both ports.
@@ -13,7 +13,7 @@
13
13
  * dynamo.getTableName(); // ✓ Available on IDynamoDBDatabase
14
14
  * dynamo.getHostEndpoint(); // ✗ Compile error - not on IDynamoDBDatabase
15
15
  */
16
- export { MIGRATION_SNAPSHOT_NAME_PREFIX, EXPECTED_SCHEMA_VERSION_ENV, EXPECTED_SCHEMA_VERSION_TOOL_ENV, EXPECTED_CH_SCHEMA_VERSION_ENV } from "@fjall/util";
16
+ export { MIGRATION_SNAPSHOT_NAME_PREFIX, EXPECTED_SCHEMA_VERSION_ENV, EXPECTED_SCHEMA_VERSION_TOOL_ENV, EXPECTED_CH_SCHEMA_VERSION_ENV, SCHEMA_ADMIN_USER_ENV, SCHEMA_ADMIN_PASSWORD_ENV } from "@fjall/util";
17
17
  /**
18
18
  * Relational database type discriminator.
19
19
  */
@@ -110,7 +110,9 @@ export function addLoadBalancerListener(ctx, loadBalancer, certificate) {
110
110
  : [];
111
111
  return rules.length > 1;
112
112
  });
113
- const defaultAction = willHaveMultipleRoutes
113
+ // CDK rejects listeners with neither a default action nor target groups.
114
+ const noServicePorts = servicesWithPorts.length === 0;
115
+ const defaultAction = willHaveMultipleRoutes || noServicePorts
114
116
  ? ListenerAction.fixedResponse(404, {
115
117
  contentType: "text/plain",
116
118
  messageBody: "Not Found"
@@ -20,26 +20,30 @@ export interface BuildClickHouseUserDataOptions {
20
20
  region: string;
21
21
  };
22
22
  /**
23
- * When true, the server config emits `<https_port>`, `<tcp_port_secure>`,
24
- * and an `<openSSL><server>` block referencing the cert files materialised
25
- * at `CLICKHOUSE_TLS_CERT_MOUNT_PATH` by the user-data bootstrap step.
26
- * Plaintext `<http_port>` is omitted to avoid mixed-protocol exposure.
27
- * Default: false. When true, `caSecretArn` and `serverSecretArn` MUST be
28
- * supplied so the bootstrap step can materialise the cert PEMs.
23
+ * TLS bootstrap configuration. When provided, the server config emits
24
+ * `<https_port>`, `<tcp_port_secure>`, and an `<openSSL><server>` block
25
+ * referencing the cert files materialised at `CLICKHOUSE_TLS_CERT_MOUNT_PATH`
26
+ * by the user-data bootstrap step (plaintext `<http_port>` is omitted to
27
+ * avoid mixed-protocol exposure). When absent, plaintext `<http_port>` is
28
+ * emitted and no cert materialisation runs.
29
+ *
30
+ * Both ARNs are required together (CA + server) — there is no half-TLS
31
+ * mode. The discriminated shape makes the "TLS-on but one ARN missing"
32
+ * misuse type-unrepresentable.
29
33
  */
30
- tlsActive?: boolean;
31
- /**
32
- * Secrets Manager ARN holding the CA cert as raw PEM. Required when
33
- * `tlsActive` is true. Fetched at instance boot via the EC2 instance role
34
- * and written to `${dataMount}/server-certs/ca.crt`.
35
- */
36
- caSecretArn?: string;
37
- /**
38
- * Secrets Manager ARN holding the server cert + key as JSON
39
- * `{ "cert": "<pem>", "key": "<pem>" }`. Required when `tlsActive` is true.
40
- * Fetched at instance boot and written to `${dataMount}/server-certs/server.{crt,key}`.
41
- */
42
- serverSecretArn?: string;
34
+ tls?: {
35
+ /**
36
+ * Secrets Manager ARN holding the CA cert as raw PEM. Fetched at instance
37
+ * boot via the EC2 instance role and written to `${dataMount}/server-certs/ca.crt`.
38
+ */
39
+ caSecretArn: string;
40
+ /**
41
+ * Secrets Manager ARN holding the server cert + key as JSON
42
+ * `{ "cert": "<pem>", "key": "<pem>" }`. Fetched at instance boot and
43
+ * written to `${dataMount}/server-certs/server.{crt,key}`.
44
+ */
45
+ serverSecretArn: string;
46
+ };
43
47
  }
44
48
  export declare function generateServerConfigXml(options: BuildClickHouseUserDataOptions): string;
45
49
  export interface GenerateUsersConfigXmlOptions {
@@ -2,7 +2,7 @@ import { CLICKHOUSE_DATA_MOUNT_PATH, CLICKHOUSE_EBS_DEVICE_NAME, CLICKHOUSE_CONF
2
2
  import { renderUsersXml } from "./clickhouseXmlRenderer.js";
3
3
  export function generateServerConfigXml(options) {
4
4
  const { backupBucketName, backupBucketRegion, coldTier } = options;
5
- const tlsActive = options.tlsActive ?? false;
5
+ const tlsActive = options.tls !== undefined;
6
6
  const storageBlock = coldTier !== undefined
7
7
  ? ` <storage_configuration>
8
8
  <!-- Same CH 26 rule as the no-cold-tier branch: a <local_ssd> disk
@@ -260,12 +260,8 @@ function compactUserDataScript(script) {
260
260
  */
261
261
  export function buildClickHouseUserData(options) {
262
262
  const serverConfigXml = generateServerConfigXml(options);
263
- const tlsActive = options.tlsActive ?? false;
264
- if (tlsActive &&
265
- (options.caSecretArn === undefined || options.serverSecretArn === undefined)) {
266
- throw new Error("buildClickHouseUserData: tlsActive=true requires both caSecretArn and serverSecretArn");
267
- }
268
- const tlsBootstrap = tlsActive
263
+ const tls = options.tls;
264
+ const tlsBootstrap = tls !== undefined
269
265
  ? `
270
266
  # Materialise TLS certs from Secrets Manager. The EC2 instance role carries
271
267
  # the secretsmanager:GetSecretValue grant; certs land on the EBS-backed mount
@@ -280,13 +276,27 @@ mkdir -p "$MOUNT_POINT/server-certs"
280
276
  command -v jq >/dev/null 2>&1 || dnf install -y jq || yum install -y jq
281
277
 
282
278
  # Fetch CA (raw PEM SecretString). Quoting prevents word-splitting on multi-line PEM.
283
- CA_PEM=$(aws secretsmanager get-secret-value --secret-id "${options.caSecretArn ?? ""}" --query SecretString --output text)
284
- printf '%s\\n' "$CA_PEM" > "$MOUNT_POINT/server-certs/ca.crt"
279
+ # tmp + mv matches the in-file users.d/fjall.xml pattern below. Even though
280
+ # pipefail plus the container starting only after this script exits means
281
+ # there is no concurrent reader at write time today, a future change that
282
+ # introduces a watcher or moves cert materialisation closer to container
283
+ # start would silently regress to partial-write exposure. Atomic-replace
284
+ # preserves the invariant by construction.
285
+ CA_PEM=$(aws secretsmanager get-secret-value --secret-id "${tls.caSecretArn}" --query SecretString --output text)
286
+ printf '%s\\n' "$CA_PEM" > "$MOUNT_POINT/server-certs/ca.crt.new"
287
+ mv "$MOUNT_POINT/server-certs/ca.crt.new" "$MOUNT_POINT/server-certs/ca.crt"
285
288
 
286
289
  # Fetch server bundle (JSON { "cert": "<pem>", "key": "<pem>" }).
287
- SERVER_JSON=$(aws secretsmanager get-secret-value --secret-id "${options.serverSecretArn ?? ""}" --query SecretString --output text)
288
- printf '%s' "$SERVER_JSON" | jq -r .cert > "$MOUNT_POINT/server-certs/server.crt"
289
- printf '%s' "$SERVER_JSON" | jq -r .key > "$MOUNT_POINT/server-certs/server.key"
290
+ SERVER_JSON=$(aws secretsmanager get-secret-value --secret-id "${tls.serverSecretArn}" --query SecretString --output text)
291
+ printf '%s' "$SERVER_JSON" | jq -r .cert > "$MOUNT_POINT/server-certs/server.crt.new"
292
+ mv "$MOUNT_POINT/server-certs/server.crt.new" "$MOUNT_POINT/server-certs/server.crt"
293
+ # chmod BEFORE mv so the private key is 0600 the instant it lands at its
294
+ # final path. Without this, server.key briefly carries the umask-default mode
295
+ # (0644) between mv and the trailing \`chmod 600\` below — short window but
296
+ # observable to any non-root reader on the host during user-data execution.
297
+ printf '%s' "$SERVER_JSON" | jq -r .key > "$MOUNT_POINT/server-certs/server.key.new"
298
+ chmod 600 "$MOUNT_POINT/server-certs/server.key.new"
299
+ mv "$MOUNT_POINT/server-certs/server.key.new" "$MOUNT_POINT/server-certs/server.key"
290
300
 
291
301
  # Strict-verify client config consumed by docker-exec reload SSM script and any
292
302
  # in-container clickhouse-client invocation. RejectCertificateHandler + strict
@@ -1,4 +1,5 @@
1
1
  import { Construct } from "constructs";
2
+ import { PhysicalName } from "aws-cdk-lib";
2
3
  import { Code, Runtime } from "aws-cdk-lib/aws-lambda";
3
4
  import { PolicyStatement, Effect } from "aws-cdk-lib/aws-iam";
4
5
  import { LambdaFunction } from "../compute/lambda.js";
@@ -23,6 +24,8 @@ export class CostAllocationTagActivator extends Construct {
23
24
  constructor(scope, id) {
24
25
  super(scope, id);
25
26
  this.lambda = new LambdaFunction(this, "TagActivatorFunction", {
27
+ // Referenced cross-environment by the App messaging stack's schedule rule.
28
+ functionName: PhysicalName.GENERATE_IF_NEEDED,
26
29
  runtime: Runtime.NODEJS_22_X,
27
30
  handler: "index.handler",
28
31
  code: Code.fromInline(HANDLER_CODE),
@@ -5,8 +5,11 @@ export interface TlsCaSecretProps {
5
5
  readonly appName: string;
6
6
  }
7
7
  /**
8
- * ClickHouse TLS CA cert (public — clients pin against this). Retained on
9
- * delete so client-side trust anchors survive accidental stack teardown.
8
+ * ClickHouse TLS CA cert (public — clients pin against this). The cert-generator
9
+ * Lambda mints a fresh CA on each create and force-deletes on stack-delete
10
+ * (bypassing the AWS recovery window) so the deterministic name is reusable
11
+ * immediately on redeploy. Use CFN stack termination protection to guard
12
+ * production teardown.
10
13
  */
11
14
  export declare class TlsCaSecret extends Secret {
12
15
  constructor(scope: Construct, id: string, props: TlsCaSecretProps);
@@ -1,8 +1,10 @@
1
- import { RemovalPolicy } from "aws-cdk-lib";
2
1
  import { Secret } from "./secret.js";
3
2
  /**
4
- * ClickHouse TLS CA cert (public — clients pin against this). Retained on
5
- * delete so client-side trust anchors survive accidental stack teardown.
3
+ * ClickHouse TLS CA cert (public — clients pin against this). The cert-generator
4
+ * Lambda mints a fresh CA on each create and force-deletes on stack-delete
5
+ * (bypassing the AWS recovery window) so the deterministic name is reusable
6
+ * immediately on redeploy. Use CFN stack termination protection to guard
7
+ * production teardown.
6
8
  */
7
9
  export class TlsCaSecret extends Secret {
8
10
  constructor(scope, id, props) {
@@ -10,6 +12,5 @@ export class TlsCaSecret extends Secret {
10
12
  secretName: `fjall-${props.appName}-clickhouse-tls-ca`,
11
13
  description: "ClickHouse TLS CA cert (public — clients pin against this)"
12
14
  });
13
- this.secret.applyRemovalPolicy(RemovalPolicy.RETAIN);
14
15
  }
15
16
  }
@@ -5,10 +5,12 @@ export interface TlsServerSecretProps {
5
5
  readonly appName: string;
6
6
  }
7
7
  /**
8
- * ClickHouse TLS server cert + key (JSON: `{ cert, key }`). Retained on
9
- * delete so cert material survives accidental stack teardown; the private
10
- * key only leaves Secrets Manager via ECS executionRole + the TLS init
11
- * container that materialises it onto the shared volume.
8
+ * ClickHouse TLS server cert + key (JSON: `{ cert, key }`). The cert-generator
9
+ * Lambda mints a fresh leaf on each create and force-deletes on stack-delete
10
+ * (bypassing the AWS recovery window) so the deterministic name is reusable
11
+ * immediately on redeploy. The private key only leaves Secrets Manager via
12
+ * ECS executionRole + the TLS init container that materialises it onto the
13
+ * shared volume.
12
14
  */
13
15
  export declare class TlsServerSecret extends Secret {
14
16
  constructor(scope: Construct, id: string, props: TlsServerSecretProps);
@@ -1,10 +1,11 @@
1
- import { RemovalPolicy } from "aws-cdk-lib";
2
1
  import { Secret } from "./secret.js";
3
2
  /**
4
- * ClickHouse TLS server cert + key (JSON: `{ cert, key }`). Retained on
5
- * delete so cert material survives accidental stack teardown; the private
6
- * key only leaves Secrets Manager via ECS executionRole + the TLS init
7
- * container that materialises it onto the shared volume.
3
+ * ClickHouse TLS server cert + key (JSON: `{ cert, key }`). The cert-generator
4
+ * Lambda mints a fresh leaf on each create and force-deletes on stack-delete
5
+ * (bypassing the AWS recovery window) so the deterministic name is reusable
6
+ * immediately on redeploy. The private key only leaves Secrets Manager via
7
+ * ECS executionRole + the TLS init container that materialises it onto the
8
+ * shared volume.
8
9
  */
9
10
  export class TlsServerSecret extends Secret {
10
11
  constructor(scope, id, props) {
@@ -12,6 +13,5 @@ export class TlsServerSecret extends Secret {
12
13
  secretName: `fjall-${props.appName}-clickhouse-tls-server`,
13
14
  description: "ClickHouse TLS server cert + key (JSON: { cert, key })"
14
15
  });
15
- this.secret.applyRemovalPolicy(RemovalPolicy.RETAIN);
16
16
  }
17
17
  }
@@ -20,6 +20,13 @@ export interface TlsCertGeneratorProps {
20
20
  * raw PEM (public — clients pin against it); the server secret carries a
21
21
  * JSON `{ cert, key }` payload consumed by the TLS init container.
22
22
  *
23
+ * On stack-delete, the Lambda force-deletes both secrets via
24
+ * `DeleteSecret(ForceDeleteWithoutRecovery=true)` so the deterministic names
25
+ * (`fjall-<app>-clickhouse-tls-{ca,server}`) become reusable immediately on
26
+ * redeploy. AWS's default 7-30 day recovery window would otherwise block
27
+ * recreation under the same name. CFN exposes no lever for this — the
28
+ * AWS::SecretsManager::Secret resource has no `RecoveryWindowInDays` field.
29
+ *
23
30
  * `caCertSha256` is exposed for downstream consumers to wire into their
24
31
  * container `environment` block as `CA_CERT_SHA256` — its CFN value
25
32
  * changes whenever the cert regenerates, forcing task-def replacement so
@@ -23,6 +23,13 @@ const LAMBDA_TIMEOUT = Duration.minutes(2);
23
23
  * raw PEM (public — clients pin against it); the server secret carries a
24
24
  * JSON `{ cert, key }` payload consumed by the TLS init container.
25
25
  *
26
+ * On stack-delete, the Lambda force-deletes both secrets via
27
+ * `DeleteSecret(ForceDeleteWithoutRecovery=true)` so the deterministic names
28
+ * (`fjall-<app>-clickhouse-tls-{ca,server}`) become reusable immediately on
29
+ * redeploy. AWS's default 7-30 day recovery window would otherwise block
30
+ * recreation under the same name. CFN exposes no lever for this — the
31
+ * AWS::SecretsManager::Secret resource has no `RecoveryWindowInDays` field.
32
+ *
26
33
  * `caCertSha256` is exposed for downstream consumers to wire into their
27
34
  * container `environment` block as `CA_CERT_SHA256` — its CFN value
28
35
  * changes whenever the cert regenerates, forcing task-def replacement so
@@ -43,17 +50,29 @@ export class TlsCertGenerator extends Construct {
43
50
  const serverSecretArnPattern = `arn:${stack.partition}:secretsmanager:${stack.region}:${stack.account}:secret:fjall-${props.appName}-clickhouse-tls-server-*`;
44
51
  const writePolicy = new PolicyStatement({
45
52
  effect: Effect.ALLOW,
46
- actions: ["secretsmanager:PutSecretValue"],
53
+ actions: ["secretsmanager:PutSecretValue", "secretsmanager:DeleteSecret"],
47
54
  resources: [caSecretArnPattern, serverSecretArnPattern]
48
55
  });
56
+ // PutSecretValue against a CMK-encrypted secret needs kms:GenerateDataKey
57
+ // on the CMK — Secrets Manager calls KMS server-side as the caller.
58
+ const caCmk = this.caSecret.secretsCustomerManagedKey;
59
+ const serverCmk = this.serverSecret.secretsCustomerManagedKey;
60
+ if (caCmk === undefined || serverCmk === undefined) {
61
+ throw new Error("TlsCertGenerator: TlsCaSecret/TlsServerSecret must expose their CMK; importExisting=true is not supported here");
62
+ }
63
+ const kmsPolicy = new PolicyStatement({
64
+ effect: Effect.ALLOW,
65
+ actions: ["kms:GenerateDataKey", "kms:Decrypt"],
66
+ resources: [caCmk.key.keyArn, serverCmk.key.keyArn]
67
+ });
49
68
  const cr = new CustomResource(this, "Generator", {
50
69
  runtime: Runtime.NODEJS_22_X,
51
70
  timeout: LAMBDA_TIMEOUT,
52
71
  codePath: LAMBDA_ASSET_DIR,
53
72
  handler: "index.handler",
54
73
  lambdaDescription: `${id} ClickHouse TLS cert generator`,
55
- roleDescription: `Execution role for ${id} cert generator (PutSecretValue on two cert secrets)`,
56
- inlinePolicy: [writePolicy],
74
+ roleDescription: `Execution role for ${id} cert generator (PutSecretValue + force-DeleteSecret on two cert secrets)`,
75
+ inlinePolicy: [writePolicy, kmsPolicy],
57
76
  properties: {
58
77
  caSecretArn: this.caSecret.secret.secretArn,
59
78
  serverSecretArn: this.serverSecret.secret.secretArn,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fjall/components-infrastructure",
3
- "version": "2.1.1",
3
+ "version": "2.2.0",
4
4
  "license": "SEE LICENSE IN LICENSE",
5
5
  "type": "module",
6
6
  "bin": {
@@ -63,8 +63,8 @@
63
63
  },
64
64
  "dependencies": {
65
65
  "@aws-sdk/client-organizations": "^3.1038.0",
66
- "@fjall/generator": "^2.1.1",
67
- "@fjall/util": "^2.1.1",
66
+ "@fjall/generator": "^2.2.0",
67
+ "@fjall/util": "^2.2.0",
68
68
  "constructs": "^10.0.0",
69
69
  "uuid": "^14.0.0"
70
70
  },
@@ -79,5 +79,5 @@
79
79
  "engines": {
80
80
  "node": ">=18.0.0"
81
81
  },
82
- "gitHead": "971d9ed50c6a21d24e67c9c83b9c471a632bf284"
82
+ "gitHead": "cd83079d6ed53b131e4c97b5966dc0d8715c8735"
83
83
  }