From d6e00c3806bbdcc8a894f6a8a1c5769c572ee4da Mon Sep 17 00:00:00 2001 From: Paul Albert Date: Wed, 10 Jun 2026 09:12:31 -0400 Subject: [PATCH] feat(infra): #353 wire SCHOLARS_CLOUDFRONT_DISTRIBUTION_ID to enable edge invalidation Activates the durable CloudFront-invalidation path shipped dormant in #823/#826. Wires envConfig.cloudFrontDistributionId into the app web task and the cdn-reconcile task environment, so the synchronous post-commit purge (lib/edit/revalidation.ts) and the outbox reconciler both run. Distribution id was deliberately omitted before to keep the path dormant. Per-env: staging -> E17NRWINXLP3B3, prod -> E28NKDFXC7K2ZL (config.ts). The web + cdn-reconcile task roles already carry the matching cloudfront:CreateInvalidation grant (distribution-scoped). The synth-time guard test flips from asserting absence to asserting the prod distribution id. DRAFT / gated: only merge + deploy at the #502 prod edge cutover (CloudFront stays as CDN). The env var needs an explicit cdk deploy Sps-App- + Sps-Etl- (the GitHub workflow only re-rolls the image). Deploy staging first as a canary, then prod. Refs #353 --- cdk/lib/app-stack.ts | 6 +++++ cdk/lib/etl-stack.ts | 19 ++++++++------ cdk/test/__snapshots__/app-stack.test.ts.snap | 8 ++++++ cdk/test/__snapshots__/etl-stack.test.ts.snap | 8 ++++++ cdk/test/etl-stack.test.ts | 25 +++++++++++-------- 5 files changed, 48 insertions(+), 18 deletions(-) diff --git a/cdk/lib/app-stack.ts b/cdk/lib/app-stack.ts index e2fbdfd6..a84903b8 100644 --- a/cdk/lib/app-stack.ts +++ b/cdk/lib/app-stack.ts @@ -884,6 +884,12 @@ export class AppStack extends Stack { OPENSEARCH_NODE: `https://${Fn.importValue( `Sps-Data-${env}-OpenSearchDomainEndpoint`, )}`, + // #353 -- CloudFront distribution id for the synchronous post-commit + // edge purge (lib/edit/revalidation.ts invalidateCloudFront). Populated + // in both envs; before this the var was unset so the purge no-opped. + // The web task role carries the matching cloudfront:CreateInvalidation + // grant (TaskRoleCloudFrontPolicy). Enable rides the #502 edge cutover. + SCHOLARS_CLOUDFRONT_DISTRIBUTION_ID: envConfig.cloudFrontDistributionId, // SAML SP non-secret config (#466). Without these, getSamlEnv()'s // requireEnv throws on the first missing var and every SAML route // 503s ("SAML SP is not configured"); SP-initiated sign-in is dead. diff --git a/cdk/lib/etl-stack.ts b/cdk/lib/etl-stack.ts index bd3c4b6f..d28b7c83 100644 --- a/cdk/lib/etl-stack.ts +++ b/cdk/lib/etl-stack.ts @@ -1295,11 +1295,12 @@ export class EtlStack extends Stack { // outage / SDK error; the ~5 min SLA is that recovery floor, not everyday // edge-cache purge latency. // - // Dormant-safe: the task injects NO SCHOLARS_CLOUDFRONT_DISTRIBUTION_ID, so - // the worker no-ops without touching the DB (empty-queue-safe) until the - // operator supplies the distribution id at enable time -- exactly as the - // synchronous invalidation path is dormant pre-launch. This keeps the - // reconciler decoupled from the #502-frozen EdgeStack distribution. + // #353 enabled: the task injects SCHOLARS_CLOUDFRONT_DISTRIBUTION_ID from + // envConfig.cloudFrontDistributionId so the reconciler drains the + // cdn_invalidation outbox against the EdgeStack distribution. Empty-queue- + // safe, so it stays a no-op whenever the outbox is empty. Enable lands with + // the #502 prod edge cutover (CloudFront stays as CDN); deploy via + // `cdk deploy Sps-Etl-` (the env var needs CDK, not just an image roll). // ------------------------------------------------------------------ const cdnReconcileLogGroup = new logs.LogGroup( this, @@ -1402,9 +1403,11 @@ export class EtlStack extends Stack { }), environment: { NODE_ENV: "production", - // NO SCHOLARS_CLOUDFRONT_DISTRIBUTION_ID: dormant-safe -- the worker - // no-ops without touching the DB until the operator supplies it at - // enable time. No OPENSEARCH_* either (this worker never reads it). + // #353 enable -- wire the EdgeStack distribution id so the reconciler + // drains the cdn_invalidation outbox. envConfig.cloudFrontDistributionId + // is populated in both envs; this was omitted while the path was + // dormant. No OPENSEARCH_* (this worker never reads it). + SCHOLARS_CLOUDFRONT_DISTRIBUTION_ID: envConfig.cloudFrontDistributionId, }, secrets: { // db.read + db.write collapse onto this single DSN (no diff --git a/cdk/test/__snapshots__/app-stack.test.ts.snap b/cdk/test/__snapshots__/app-stack.test.ts.snap index ebdb6956..7f53d121 100644 --- a/cdk/test/__snapshots__/app-stack.test.ts.snap +++ b/cdk/test/__snapshots__/app-stack.test.ts.snap @@ -291,6 +291,10 @@ exports[`AppStack prod matches the snapshot 1`] = ` ], }, }, + { + "Name": "SCHOLARS_CLOUDFRONT_DISTRIBUTION_ID", + "Value": "E28NKDFXC7K2ZL", + }, { "Name": "SAML_IDP_ENTITY_ID", "Value": "https://login-proxy.weill.cornell.edu/idp", @@ -3295,6 +3299,10 @@ exports[`AppStack staging matches the snapshot 1`] = ` ], }, }, + { + "Name": "SCHOLARS_CLOUDFRONT_DISTRIBUTION_ID", + "Value": "E17NRWINXLP3B3", + }, { "Name": "SAML_IDP_ENTITY_ID", "Value": "https://login-proxy.weill.cornell.edu/idp", diff --git a/cdk/test/__snapshots__/etl-stack.test.ts.snap b/cdk/test/__snapshots__/etl-stack.test.ts.snap index dee1368d..5ad6e993 100644 --- a/cdk/test/__snapshots__/etl-stack.test.ts.snap +++ b/cdk/test/__snapshots__/etl-stack.test.ts.snap @@ -925,6 +925,10 @@ exports[`EtlStack prod matches the snapshot 1`] = ` "Name": "NODE_ENV", "Value": "production", }, + { + "Name": "SCHOLARS_CLOUDFRONT_DISTRIBUTION_ID", + "Value": "E28NKDFXC7K2ZL", + }, ], "Essential": true, "Image": { @@ -5914,6 +5918,10 @@ exports[`EtlStack staging matches the snapshot 1`] = ` "Name": "NODE_ENV", "Value": "production", }, + { + "Name": "SCHOLARS_CLOUDFRONT_DISTRIBUTION_ID", + "Value": "E17NRWINXLP3B3", + }, ], "Essential": true, "Image": { diff --git a/cdk/test/etl-stack.test.ts b/cdk/test/etl-stack.test.ts index 274a1810..6ee7a7f8 100644 --- a/cdk/test/etl-stack.test.ts +++ b/cdk/test/etl-stack.test.ts @@ -682,7 +682,7 @@ describe("EtlStack", () => { expect(td.Properties?.Memory).toBe("512"); }); - it("injects ONLY the DATABASE_URL secret (no OPENSEARCH_*, no SCHOLARS_*) and omits SCHOLARS_CLOUDFRONT_DISTRIBUTION_ID (dormant-safe)", () => { + it("injects ONLY the DATABASE_URL secret (no OPENSEARCH_*/SCHOLARS_* secrets) and wires the SCHOLARS_CLOUDFRONT_DISTRIBUTION_ID env to the distribution id (#353 enabled)", () => { const td = cdnReconcileTaskDef(); const container = ( td.Properties?.ContainerDefinitions as @@ -703,16 +703,21 @@ describe("EtlStack", () => { /^OPENSEARCH_/.test(n ?? ""), ); expect(leaked).toEqual([]); - // Dormant-safe: no distribution id is hardcoded onto the task; the - // worker no-ops until the operator supplies it at enable time. - const envNames = ( - container?.Environment as Array<{ Name?: string }> | undefined - )?.map((e) => e.Name); - expect(envNames ?? []).not.toContain( - "SCHOLARS_CLOUDFRONT_DISTRIBUTION_ID", + // #353 enabled: the distribution id is wired (plaintext env, not a + // secret) so the reconciler drains the outbox; the prod template gets + // the prod EdgeStack distribution. Was omitted while the path was dormant. + const envEntries = + (container?.Environment as + | Array<{ Name?: string; Value?: string }> + | undefined) ?? []; + const distEntry = envEntries.find( + (e) => e.Name === "SCHOLARS_CLOUDFRONT_DISTRIBUTION_ID", ); - // No OpenSearch endpoint either. - expect(envNames ?? []).not.toContain("OPENSEARCH_NODE"); + // Prod EdgeStack distribution (config.ts prod.cloudFrontDistributionId); + // staging's is E17NRWINXLP3B3. + expect(distEntry?.Value).toBe("E28NKDFXC7K2ZL"); + // No OpenSearch endpoint either -- this worker never reads it. + expect(envEntries.map((e) => e.Name)).not.toContain("OPENSEARCH_NODE"); }); it("the cdn reconcile exec role lists EXACTLY ONE secret ARN (db/etl; no opensearch, no *)", () => {