From cdf817414881bd9c53efe2bcfb9cf0cb57397e30 Mon Sep 17 00:00:00 2001 From: Akash Bhardwaj Date: Fri, 19 Jun 2026 01:03:03 +0530 Subject: [PATCH 01/20] feat(llmo): add edge-optimize-bootstrap-url endpoint POST /sites/:siteId/llmo/edge-optimize-bootstrap-url returns a CloudFormation quick-create URL with a server-side presigned template URL, so a customer can create the cross-account Edge Optimize connector role in their own AWS account without a public S3 bucket and without any S3 access of their own. Presigning is done with the service execution role. Includes route + capability registration, OpenAPI spec, and unit tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/openapi/api.yaml | 2 + docs/openapi/llmo-api.yaml | 77 ++++++++++++++++++++++++++ src/controllers/llmo/llmo.js | 86 +++++++++++++++++++++++++++++ src/routes/index.js | 1 + src/routes/required-capabilities.js | 1 + test/controllers/llmo/llmo.test.js | 65 ++++++++++++++++++++++ 6 files changed, 232 insertions(+) diff --git a/docs/openapi/api.yaml b/docs/openapi/api.yaml index eaf8a73e86..76e355be79 100644 --- a/docs/openapi/api.yaml +++ b/docs/openapi/api.yaml @@ -558,6 +558,8 @@ paths: $ref: './llmo-api.yaml#/site-llmo-edge-optimize-config' /sites/{siteId}/llmo/edge-optimize-config/stage: $ref: './llmo-api.yaml#/site-llmo-edge-optimize-config-stage' + /sites/{siteId}/llmo/edge-optimize-bootstrap-url: + $ref: './llmo-api.yaml#/site-llmo-edge-optimize-bootstrap-url' /sites/{siteId}/llmo/edge-optimize-status: $ref: './llmo-api.yaml#/llmo-edge-optimize-status' /sites/{siteId}/llmo/probes/edge-optimize: diff --git a/docs/openapi/llmo-api.yaml b/docs/openapi/llmo-api.yaml index c458d372c6..0de96fdbfe 100644 --- a/docs/openapi/llmo-api.yaml +++ b/docs/openapi/llmo-api.yaml @@ -2116,6 +2116,83 @@ llmo-edge-optimize-status: security: - api_key: [ ] +site-llmo-edge-optimize-bootstrap-url: + post: + tags: + - llmo + summary: Generate a CloudFormation quick-create URL for the Edge Optimize connector role + description: | + Returns a one-click AWS CloudFormation quick-create URL (with a server-side + presigned template URL) that the customer uses to create the cross-account + connector role in their own AWS account. The template bucket stays private — + presigning is done with the service execution role — so no public S3 endpoint + is required and the customer needs no S3 access of their own. + operationId: getEdgeOptimizeBootstrapUrl + parameters: + - $ref: './parameters.yaml#/siteId' + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - accountId + properties: + accountId: + type: string + description: The 12-digit AWS account ID hosting the customer's CloudFront distribution + pattern: '^[0-9]{12}$' + example: '682033462621' + responses: + '200': + description: Bootstrap URL generated successfully + content: + application/json: + schema: + type: object + required: + - externalId + - roleArn + - quickCreateUrl + properties: + externalId: + type: string + description: Per-session external ID baked into the role's trust policy + roleName: + type: string + roleArn: + type: string + trustedPrincipalArn: + type: string + stackName: + type: string + quickCreateUrl: + type: string + description: One-click CloudFormation quick-create URL (presigned template) + presignTtlSeconds: + type: integer + example: + externalId: '7ff9518a-cf59-40b4-aa53-68a3cb2e24a5' + roleName: AdobeLLMOptimizerCloudFrontConnectorRole + roleArn: 'arn:aws:iam::682033462621:role/AdobeLLMOptimizerCloudFrontConnectorRole' + trustedPrincipalArn: 'arn:aws:iam::120569600543:root' + stackName: adobe-edgeoptimize-connector-role + quickCreateUrl: 'https://us-east-1.console.aws.amazon.com/cloudformation/home?region=us-east-1#/stacks/quickcreate?templateURL=...' + presignTtlSeconds: 3600 + '400': + $ref: './responses.yaml#/400' + '401': + $ref: './responses.yaml#/401' + '403': + $ref: './responses.yaml#/403' + '404': + $ref: './responses.yaml#/404' + '500': + $ref: './responses.yaml#/500' + security: + - api_key: [ ] + llmo-probe-edge-optimize: get: tags: diff --git a/src/controllers/llmo/llmo.js b/src/controllers/llmo/llmo.js index d832242a95..4afaafe25e 100644 --- a/src/controllers/llmo/llmo.js +++ b/src/controllers/llmo/llmo.js @@ -2005,7 +2005,93 @@ function LlmoController(ctx) { return ok(result); }; + /** + * POST /sites/{siteId}/llmo/edge-optimize-bootstrap-url + * Builds a one-click CloudFormation quick-create URL (with a server-side + * presigned template URL) the customer uses to create the cross-account + * connector role in their own AWS account. Presigning runs with the service + * execution role, so the template bucket stays private (no public endpoint) + * and the customer needs no S3 access. + * @param {object} context - Request context + * @returns {Promise} Bootstrap details + CloudFormation quick-create URL + */ + const getEdgeOptimizeBootstrapUrl = async (context) => { + const { + log, dataAccess, env, s3, + } = context; + const { siteId } = context.params; + const { Site } = dataAccess; + const accountId = String(context.data?.accountId || '').replace(/\D/g, ''); + + if (accountId.length !== 12) { + return badRequest('accountId must be a 12-digit AWS account ID'); + } + + try { + const site = await Site.findById(siteId); + if (!site) { + return notFound('Site not found'); + } + if (!await accessControlUtil.hasAccess(site)) { + return forbidden('User does not have access to this site'); + } + if (!accessControlUtil.isLLMOAdministrator()) { + return forbidden('Only LLMO administrators can generate the edge optimize bootstrap URL'); + } + + const bucket = env.EDGE_OPTIMIZE_TEMPLATE_BUCKET; + if (!hasText(bucket) || !s3?.s3Client) { + return badRequest('Edge optimize template hosting is not configured for this environment'); + } + + const key = env.EDGE_OPTIMIZE_TEMPLATE_KEY || 'customer-bootstrap-role.yaml'; + const region = 'us-east-1'; + const roleName = env.EDGE_OPTIMIZE_ROLE_NAME || 'AdobeLLMOptimizerCloudFrontConnectorRole'; + const stackName = env.EDGE_OPTIMIZE_STACK_NAME || 'adobe-edgeoptimize-connector-role'; + const presignTtlSeconds = Number(env.EDGE_OPTIMIZE_PRESIGN_TTL || 3600); + const externalId = crypto.randomUUID(); + const roleArn = `arn:aws:iam::${accountId}:role/${roleName}`; + const trustedPrincipalArn = env.EDGE_OPTIMIZE_TRUSTED_PRINCIPAL_ARN + || `arn:aws:iam::${accountId}:root`; + + // Presign the (private) template so the customer's CloudFormation can read it + // cross-account via the signature — no public bucket, no customer S3 access. + const templateUrl = await s3.getSignedUrl( + s3.s3Client, + new s3.GetObjectCommand({ Bucket: bucket, Key: key }), + { expiresIn: presignTtlSeconds }, + ); + + const params = { + TrustedPrincipalArn: trustedPrincipalArn, + ExternalId: externalId, + RoleName: roleName, + }; + const qs = new URLSearchParams(); + qs.set('templateURL', templateUrl); + qs.set('stackName', stackName); + Object.entries(params).forEach(([k, v]) => qs.set(`param_${k}`, v)); + const quickCreateUrl = `https://${region}.console.aws.amazon.com/cloudformation/home?region=${region}#/stacks/quickcreate?${qs.toString()}`; + + log.info(`[edge-optimize-bootstrap-url] Generated bootstrap URL for site ${siteId}, account ${accountId}`); + + return ok({ + externalId, + roleName, + roleArn, + trustedPrincipalArn, + stackName, + quickCreateUrl, + presignTtlSeconds, + }); + } catch (error) { + log.error(`Failed to generate edge optimize bootstrap URL for site ${siteId}:`, error); + return badRequest(cleanupHeaderValue(error.message)); + } + }; + return { + getEdgeOptimizeBootstrapUrl, getLlmoSheetData, queryLlmoSheetData, getLlmoGlobalSheetData, diff --git a/src/routes/index.js b/src/routes/index.js index 3ea56fec6e..982432ea34 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -437,6 +437,7 @@ export default function getRouteHandlers( 'POST /sites/:siteId/llmo/edge-optimize-config': llmoController.createOrUpdateEdgeConfig, 'GET /sites/:siteId/llmo/edge-optimize-config': llmoController.getEdgeConfig, 'POST /sites/:siteId/llmo/edge-optimize-config/stage': llmoController.createOrUpdateStageEdgeConfig, + 'POST /sites/:siteId/llmo/edge-optimize-bootstrap-url': llmoController.getEdgeOptimizeBootstrapUrl, 'GET /sites/:siteId/llmo/strategy': llmoController.getStrategy, 'PUT /sites/:siteId/llmo/strategy': llmoController.saveStrategy, 'GET /sites/:siteId/llmo/edge-optimize-status': llmoController.checkEdgeOptimizeStatus, diff --git a/src/routes/required-capabilities.js b/src/routes/required-capabilities.js index 66d94a6120..9ea01303f7 100644 --- a/src/routes/required-capabilities.js +++ b/src/routes/required-capabilities.js @@ -126,6 +126,7 @@ export const INTERNAL_ROUTES = [ 'POST /sites/:siteId/llmo/offboard', 'POST /sites/:siteId/llmo/edge-optimize-config', 'POST /sites/:siteId/llmo/edge-optimize-config/stage', + 'POST /sites/:siteId/llmo/edge-optimize-bootstrap-url', 'PUT /sites/:siteId/llmo/opportunities-reviewed', // PLG onboarding - IMS token auth, self-service flow, not S2S diff --git a/test/controllers/llmo/llmo.test.js b/test/controllers/llmo/llmo.test.js index 4e2e216b59..90746721bc 100644 --- a/test/controllers/llmo/llmo.test.js +++ b/test/controllers/llmo/llmo.test.js @@ -7158,6 +7158,71 @@ describe('LlmoController', () => { }); }); + describe('getEdgeOptimizeBootstrapUrl', () => { + let bootstrapContext; + let getSignedUrlStub; + + beforeEach(() => { + getSignedUrlStub = sinon.stub().resolves('https://llmo-edgeoptimize-cf-template-stage.s3.us-east-1.amazonaws.com/customer-bootstrap-role.yaml?X-Amz-Signature=abc'); + bootstrapContext = { + ...mockContext, + params: { siteId: TEST_SITE_ID }, + data: { accountId: '682033462621' }, + env: { EDGE_OPTIMIZE_TEMPLATE_BUCKET: 'llmo-edgeoptimize-cf-template-stage' }, + s3: { + s3Client: {}, + getSignedUrl: getSignedUrlStub, + GetObjectCommand: class GetObjectCommand {}, + }, + }; + }); + + it('returns a quick-create URL with a presigned template for a valid account', async () => { + const result = await controller.getEdgeOptimizeBootstrapUrl(bootstrapContext); + + expect(result.status).to.equal(200); + const body = await result.json(); + expect(body.quickCreateUrl).to.include('stacks/quickcreate'); + expect(body.quickCreateUrl).to.include('templateURL='); + expect(body.quickCreateUrl).to.include('param_RoleName=AdobeLLMOptimizerCloudFrontConnectorRole'); + expect(body.roleArn).to.equal('arn:aws:iam::682033462621:role/AdobeLLMOptimizerCloudFrontConnectorRole'); + expect(body.externalId).to.be.a('string'); + expect(getSignedUrlStub.calledOnce).to.equal(true); + }); + + it('returns 400 for an invalid account id', async () => { + const result = await controller.getEdgeOptimizeBootstrapUrl({ ...bootstrapContext, data: { accountId: '123' } }); + + expect(result.status).to.equal(400); + const body = await result.json(); + expect(body.message).to.include('12-digit'); + }); + + it('returns 400 when the template bucket is not configured', async () => { + const result = await controller.getEdgeOptimizeBootstrapUrl({ ...bootstrapContext, env: {} }); + + expect(result.status).to.equal(400); + const body = await result.json(); + expect(body.message).to.include('not configured'); + }); + + it('returns 404 when the site is not found', async () => { + mockDataAccess.Site.findById.resolves(null); + + const result = await controller.getEdgeOptimizeBootstrapUrl(bootstrapContext); + + expect(result.status).to.equal(404); + }); + + it('returns 403 when the user lacks access to the site', async () => { + const deniedController = controllerWithAccessDenied(mockContext); + + const result = await deniedController.getEdgeOptimizeBootstrapUrl(bootstrapContext); + + expect(result.status).to.equal(403); + }); + }); + describe('getStrategy', () => { const mockStrategyData = { opportunities: [ From ee482c293f5a6ff7d159b76a249faed973145533 Mon Sep 17 00:00:00 2001 From: Akash Bhardwaj Date: Fri, 19 Jun 2026 01:42:00 +0530 Subject: [PATCH 02/20] test: register edge-optimize-bootstrap-url route in route-handler test The getRouteHandlers "segregates static and dynamic routes" test asserts the exact set of routes; add the new dynamic route to the expected list. Co-Authored-By: Claude Opus 4.8 (1M context) --- test/routes/index.test.js | 1 + 1 file changed, 1 insertion(+) diff --git a/test/routes/index.test.js b/test/routes/index.test.js index 227f712766..eabb70caf5 100755 --- a/test/routes/index.test.js +++ b/test/routes/index.test.js @@ -1061,6 +1061,7 @@ describe('getRouteHandlers', () => { 'POST /sites/:siteId/llmo/edge-optimize-config', 'GET /sites/:siteId/llmo/edge-optimize-config', 'POST /sites/:siteId/llmo/edge-optimize-config/stage', + 'POST /sites/:siteId/llmo/edge-optimize-bootstrap-url', 'GET /sites/:siteId/llmo/edge-optimize-status', 'GET /sites/:siteId/llmo/probes/edge-optimize', 'GET /sites/:siteId/llmo/strategy', From 76db1533c1a14021b3507610099751a60d2da39b Mon Sep 17 00:00:00 2001 From: Akash Bhardwaj Date: Fri, 19 Jun 2026 12:32:40 +0530 Subject: [PATCH 03/20] chore(llmo): TEMP testing defaults for edge-optimize bootstrap (dev/ci) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hardcode EDGE_OPTIMIZE_TEMPLATE_BUCKET and EDGE_OPTIMIZE_TRUSTED_PRINCIPAL_ARN fallbacks so the dev/ci branch deploy returns a quick-create URL before those env vars are wired into Vault/secrets. Marked TEMPORARY / TODO REMOVE — revert before merge/prod (values must come from env config). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/controllers/llmo/llmo.js | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/controllers/llmo/llmo.js b/src/controllers/llmo/llmo.js index cc57de6314..22226a0d3f 100644 --- a/src/controllers/llmo/llmo.js +++ b/src/controllers/llmo/llmo.js @@ -2098,7 +2098,10 @@ function LlmoController(ctx) { return forbidden('Only LLMO administrators can generate the edge optimize bootstrap URL'); } - const bucket = env.EDGE_OPTIMIZE_TEMPLATE_BUCKET; + // TEMPORARY (testing only): hardcoded fallback so the dev/ci deploy returns a URL + // before EDGE_OPTIMIZE_TEMPLATE_BUCKET is wired into Vault/secrets. + // TODO: REMOVE this default before merge/prod — the value must come from env config. + const bucket = env.EDGE_OPTIMIZE_TEMPLATE_BUCKET || 'llmo-edgeoptimize-cf-template-stage'; if (!hasText(bucket) || !s3?.s3Client) { return badRequest('Edge optimize template hosting is not configured for this environment'); } @@ -2110,8 +2113,11 @@ function LlmoController(ctx) { const presignTtlSeconds = Number(env.EDGE_OPTIMIZE_PRESIGN_TTL || 3600); const externalId = crypto.randomUUID(); const roleArn = `arn:aws:iam::${accountId}:role/${roleName}`; + // TEMPORARY (testing only): default the trust to the dev signer account so the + // cross-account test works (dev signs, stage is the customer where the role is made). + // TODO: REMOVE this default before merge/prod — set EDGE_OPTIMIZE_TRUSTED_PRINCIPAL_ARN via env. const trustedPrincipalArn = env.EDGE_OPTIMIZE_TRUSTED_PRINCIPAL_ARN - || `arn:aws:iam::${accountId}:root`; + || 'arn:aws:iam::682033462621:root'; // Presign the (private) template so the customer's CloudFormation can read it // cross-account via the signature — no public bucket, no customer S3 access. From 9b12537f2ef9df51d075da84cd05b0bdc0f8e67d Mon Sep 17 00:00:00 2001 From: Akash Bhardwaj Date: Fri, 19 Jun 2026 12:34:42 +0530 Subject: [PATCH 04/20] chore(llmo): point temp testing bucket default to the dev-account bucket Use llmo-edgeoptimize-cf-template (in 682033462621, where the service deploys and signs) so the dev role reads it same-account; stage customer fetches via the presigned URL. Still TEMPORARY / TODO REMOVE before merge. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/controllers/llmo/llmo.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/controllers/llmo/llmo.js b/src/controllers/llmo/llmo.js index 22226a0d3f..80293d53e8 100644 --- a/src/controllers/llmo/llmo.js +++ b/src/controllers/llmo/llmo.js @@ -2099,9 +2099,11 @@ function LlmoController(ctx) { } // TEMPORARY (testing only): hardcoded fallback so the dev/ci deploy returns a URL - // before EDGE_OPTIMIZE_TEMPLATE_BUCKET is wired into Vault/secrets. + // before EDGE_OPTIMIZE_TEMPLATE_BUCKET is wired into Vault/secrets. This bucket lives + // in the dev account (682033462621) where the service deploys and signs, so the dev + // role reads it same-account; the stage customer fetches it via the presigned URL. // TODO: REMOVE this default before merge/prod — the value must come from env config. - const bucket = env.EDGE_OPTIMIZE_TEMPLATE_BUCKET || 'llmo-edgeoptimize-cf-template-stage'; + const bucket = env.EDGE_OPTIMIZE_TEMPLATE_BUCKET || 'llmo-edgeoptimize-cf-template'; if (!hasText(bucket) || !s3?.s3Client) { return badRequest('Edge optimize template hosting is not configured for this environment'); } From edb097ce7c0e91e54a0263bbbd7cb6399c878bae Mon Sep 17 00:00:00 2001 From: Akash Bhardwaj Date: Fri, 19 Jun 2026 12:52:28 +0530 Subject: [PATCH 05/20] fix(llmo): shorten temp-default comment to satisfy max-len lint Co-Authored-By: Claude Opus 4.8 (1M context) --- src/controllers/llmo/llmo.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/controllers/llmo/llmo.js b/src/controllers/llmo/llmo.js index 80293d53e8..3026392d1f 100644 --- a/src/controllers/llmo/llmo.js +++ b/src/controllers/llmo/llmo.js @@ -2117,7 +2117,7 @@ function LlmoController(ctx) { const roleArn = `arn:aws:iam::${accountId}:role/${roleName}`; // TEMPORARY (testing only): default the trust to the dev signer account so the // cross-account test works (dev signs, stage is the customer where the role is made). - // TODO: REMOVE this default before merge/prod — set EDGE_OPTIMIZE_TRUSTED_PRINCIPAL_ARN via env. + // TODO: REMOVE before merge/prod — set EDGE_OPTIMIZE_TRUSTED_PRINCIPAL_ARN via env. const trustedPrincipalArn = env.EDGE_OPTIMIZE_TRUSTED_PRINCIPAL_ARN || 'arn:aws:iam::682033462621:root'; From fb857d4dec5ddeb4ff50c2d1b9d839393a97239b Mon Sep 17 00:00:00 2001 From: Akash Bhardwaj Date: Fri, 19 Jun 2026 13:12:08 +0530 Subject: [PATCH 06/20] test(llmo): fix bootstrap-url not-configured test for temp bucket default The TEMPORARY hardcoded EDGE_OPTIMIZE_TEMPLATE_BUCKET default makes the bucket always set, so the 'not configured' guard can no longer be hit via an empty env. Exercise the same guard via the missing S3 client instead. TODO: restore the empty-bucket variant when the temp default is removed. Co-Authored-By: Claude Opus 4.8 (1M context) --- test/controllers/llmo/llmo.test.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/test/controllers/llmo/llmo.test.js b/test/controllers/llmo/llmo.test.js index c1c455f477..f6ff316f2e 100644 --- a/test/controllers/llmo/llmo.test.js +++ b/test/controllers/llmo/llmo.test.js @@ -7594,8 +7594,11 @@ describe('LlmoController', () => { expect(body.message).to.include('12-digit'); }); - it('returns 400 when the template bucket is not configured', async () => { - const result = await controller.getEdgeOptimizeBootstrapUrl({ ...bootstrapContext, env: {} }); + it('returns 400 when template hosting is not configured (no S3 client)', async () => { + // While the TEMPORARY hardcoded bucket default is in place the bucket is always + // set, so the "not configured" guard is exercised via the missing S3 client. + // TODO: restore the `env: {}` (empty-bucket) variant once the temp default is removed. + const result = await controller.getEdgeOptimizeBootstrapUrl({ ...bootstrapContext, s3: {} }); expect(result.status).to.equal(400); const body = await result.json(); From d77eaa16ca9a0f937a9d9a22c673502d0b5dfd58 Mon Sep 17 00:00:00 2001 From: Akash Bhardwaj Date: Fri, 19 Jun 2026 14:36:01 +0530 Subject: [PATCH 07/20] fix(llmo): lower edge-optimize presign TTL default to 900s (15m) Tighten the default lifetime of the bootstrap template presigned URL from 1h to 15m. The customer opens the quick-create link immediately, so a shorter TTL shrinks the leak window. A leaked URL only grants GetObject on the single template object until expiry; still override via EDGE_OPTIMIZE_PRESIGN_TTL. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/openapi/llmo-api.yaml | 2 +- src/controllers/llmo/llmo.js | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/openapi/llmo-api.yaml b/docs/openapi/llmo-api.yaml index f202bb430b..877bd3a195 100644 --- a/docs/openapi/llmo-api.yaml +++ b/docs/openapi/llmo-api.yaml @@ -2395,7 +2395,7 @@ site-llmo-edge-optimize-bootstrap-url: trustedPrincipalArn: 'arn:aws:iam::120569600543:root' stackName: adobe-edgeoptimize-connector-role quickCreateUrl: 'https://us-east-1.console.aws.amazon.com/cloudformation/home?region=us-east-1#/stacks/quickcreate?templateURL=...' - presignTtlSeconds: 3600 + presignTtlSeconds: 900 '400': $ref: './responses.yaml#/400' '401': diff --git a/src/controllers/llmo/llmo.js b/src/controllers/llmo/llmo.js index 3026392d1f..1d5136289f 100644 --- a/src/controllers/llmo/llmo.js +++ b/src/controllers/llmo/llmo.js @@ -2112,7 +2112,10 @@ function LlmoController(ctx) { const region = 'us-east-1'; const roleName = env.EDGE_OPTIMIZE_ROLE_NAME || 'AdobeLLMOptimizerCloudFrontConnectorRole'; const stackName = env.EDGE_OPTIMIZE_STACK_NAME || 'adobe-edgeoptimize-connector-role'; - const presignTtlSeconds = Number(env.EDGE_OPTIMIZE_PRESIGN_TTL || 3600); + // Short-lived presign: the customer opens the link immediately, so a tight TTL + // shrinks the exposure window if the URL leaks (it only grants GetObject on this + // one template object until expiry — see security notes). Override via env. + const presignTtlSeconds = Number(env.EDGE_OPTIMIZE_PRESIGN_TTL || 900); const externalId = crypto.randomUUID(); const roleArn = `arn:aws:iam::${accountId}:role/${roleName}`; // TEMPORARY (testing only): default the trust to the dev signer account so the From f7c227612f08379d58b16ca9f1101d0bf5c0bdae Mon Sep 17 00:00:00 2001 From: Akash Bhardwaj Date: Sat, 20 Jun 2026 08:41:50 +0530 Subject: [PATCH 08/20] feat(llmo): CloudFront wizard connect + list-distributions endpoints (Phase 2) Backend for the CloudFront 'Deploy routing' wizard's read steps. The api-service assumes the customer's cross-account connector role server-side (no AWS creds in the browser): - New src/support/edge-optimize.js: assumeConnectorRole (STS AssumeRole with the per-session external ID) + listCloudFrontDistributions. - POST /sites/:siteId/llmo/edge-optimize/connect - verifies the role is assumable (returns { connected } so the UI can poll while the customer creates the role); POST .../edge-optimize/distributions - lists the account's CloudFront distributions. Both gated by site access + LLMO admin and added to INTERNAL_ROUTES (not exposed to S2S). - Adds @aws-sdk/client-sts and @aws-sdk/client-cloudfront. - Unit tests for the support module (mocked SDK) and both handlers; route + capability lists updated. Co-Authored-By: Claude Opus 4.8 (1M context) --- package-lock.json | 271 ++++++++++++++++++++++------ package.json | 2 + src/controllers/llmo/llmo.js | 91 ++++++++++ src/routes/index.js | 2 + src/routes/required-capabilities.js | 2 + src/support/edge-optimize.js | 96 ++++++++++ test/controllers/llmo/llmo.test.js | 180 ++++++++++++++++++ test/routes/index.test.js | 4 + test/support/edge-optimize.test.js | 181 +++++++++++++++++++ 9 files changed, 778 insertions(+), 51 deletions(-) create mode 100644 src/support/edge-optimize.js create mode 100644 test/support/edge-optimize.test.js diff --git a/package-lock.json b/package-lock.json index 3d2ae1c847..443e97eb2f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,10 +34,12 @@ "@adobe/spacecat-shared-tokowaka-client": "1.19.0", "@adobe/spacecat-shared-utils": "1.119.2", "@adobe/spacecat-shared-vault-secrets": "1.3.5", + "@aws-sdk/client-cloudfront": "3.1045.0", "@aws-sdk/client-s3": "3.1045.0", "@aws-sdk/client-secrets-manager": "3.1045.0", "@aws-sdk/client-sfn": "3.1045.0", "@aws-sdk/client-sqs": "3.1045.0", + "@aws-sdk/client-sts": "3.1045.0", "@aws-sdk/s3-request-presigner": "3.1045.0", "@bufbuild/protobuf": "2.7.0", "@connectrpc/connect": "^2.1.1", @@ -364,6 +366,58 @@ "node": ">=20.0.0" } }, + "node_modules/@adobe/helix-deploy/node_modules/@aws-sdk/client-sts": { + "version": "3.1042.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.1042.0.tgz", + "integrity": "sha512-CTpdtuLEdClT31afs8eabb9Yhm1fRU8DRLak/+5VTrVSZcWzEvzVhA4ZVHvf4w7EPah2yve//14zwyDYa4IxVQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.8", + "@aws-sdk/credential-provider-node": "^3.972.39", + "@aws-sdk/middleware-host-header": "^3.972.10", + "@aws-sdk/middleware-logger": "^3.972.10", + "@aws-sdk/middleware-recursion-detection": "^3.972.11", + "@aws-sdk/middleware-user-agent": "^3.972.38", + "@aws-sdk/region-config-resolver": "^3.972.13", + "@aws-sdk/signature-v4-multi-region": "^3.996.25", + "@aws-sdk/types": "^3.973.8", + "@aws-sdk/util-endpoints": "^3.996.8", + "@aws-sdk/util-user-agent-browser": "^3.972.10", + "@aws-sdk/util-user-agent-node": "^3.973.24", + "@smithy/config-resolver": "^4.4.17", + "@smithy/core": "^3.23.17", + "@smithy/fetch-http-handler": "^5.3.17", + "@smithy/hash-node": "^4.2.14", + "@smithy/invalid-dependency": "^4.2.14", + "@smithy/middleware-content-length": "^4.2.14", + "@smithy/middleware-endpoint": "^4.4.32", + "@smithy/middleware-retry": "^4.5.7", + "@smithy/middleware-serde": "^4.2.20", + "@smithy/middleware-stack": "^4.2.14", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/node-http-handler": "^4.6.1", + "@smithy/protocol-http": "^5.3.14", + "@smithy/smithy-client": "^4.12.13", + "@smithy/types": "^4.14.1", + "@smithy/url-parser": "^4.2.14", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-body-length-browser": "^4.2.2", + "@smithy/util-body-length-node": "^4.2.3", + "@smithy/util-defaults-mode-browser": "^4.3.49", + "@smithy/util-defaults-mode-node": "^4.2.54", + "@smithy/util-endpoints": "^3.4.2", + "@smithy/util-middleware": "^4.2.14", + "@smithy/util-retry": "^4.3.6", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@adobe/helix-deploy/node_modules/@aws-sdk/middleware-host-header": { "version": "3.972.13", "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.13.tgz", @@ -708,7 +762,6 @@ "resolved": "https://registry.npmjs.org/@adobe/helix-universal/-/helix-universal-5.4.1.tgz", "integrity": "sha512-wgmjwo0xJkYhFQUmv6GTPvCFjDYBoT7zP3OuAxLN+FlHgS6kDkbJOtKxwQn9SrWbhoIfM8GdCnRDpBn6BmkASw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@adobe/fetch": "4.3.0", "aws4": "1.13.2" @@ -8986,6 +9039,40 @@ } } }, + "node_modules/@adobe/spacecat-shared-tokowaka-client/node_modules/@aws-sdk/client-cloudfront": { + "version": "3.1057.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-cloudfront/-/client-cloudfront-3.1057.0.tgz", + "integrity": "sha512-SSDMS+oxch09VWLBeQDEnHcQ7v1Q/YVBrA/YarjYKnqWzJO9bgoLOIN0mYG9SdcRXnyH8Bn6aIEtosanGjU5wQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.15", + "@aws-sdk/credential-provider-node": "^3.972.47", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.5", + "@smithy/fetch-http-handler": "^5.4.5", + "@smithy/node-http-handler": "^4.7.5", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@adobe/spacecat-shared-tokowaka-client/node_modules/@aws-sdk/client-cloudfront/node_modules/@aws-sdk/types": { + "version": "3.973.13", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.13.tgz", + "integrity": "sha512-pEHZqRkAlHfnfAU9tK+WpKv/gBNjGJrHMgA3A0iYRGyswBS2t0pfez+lWlwktb3Bqa0ovh7w/QJTFwp3fDxLNg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@adobe/spacecat-shared-tokowaka-client/node_modules/@aws-sdk/client-s3": { "version": "3.1057.0", "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1057.0.tgz", @@ -10335,20 +10422,103 @@ } }, "node_modules/@aws-sdk/client-cloudfront": { - "version": "3.1057.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-cloudfront/-/client-cloudfront-3.1057.0.tgz", - "integrity": "sha512-SSDMS+oxch09VWLBeQDEnHcQ7v1Q/YVBrA/YarjYKnqWzJO9bgoLOIN0mYG9SdcRXnyH8Bn6aIEtosanGjU5wQ==", + "version": "3.1045.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-cloudfront/-/client-cloudfront-3.1045.0.tgz", + "integrity": "sha512-84RIiLrMXcinBK1JXnP1bOavvQ+jxTxN4xsB20e39MfOZMyr7wIxMNn35kTYTg8UTJgN7zwEgzGJo64sU3mtPw==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.15", - "@aws-sdk/credential-provider-node": "^3.972.47", - "@aws-sdk/types": "^3.973.9", - "@smithy/core": "^3.24.5", - "@smithy/fetch-http-handler": "^5.4.5", - "@smithy/node-http-handler": "^4.7.5", - "@smithy/types": "^4.14.2", + "@aws-sdk/core": "^3.974.8", + "@aws-sdk/credential-provider-node": "^3.972.39", + "@aws-sdk/middleware-host-header": "^3.972.10", + "@aws-sdk/middleware-logger": "^3.972.10", + "@aws-sdk/middleware-recursion-detection": "^3.972.11", + "@aws-sdk/middleware-user-agent": "^3.972.38", + "@aws-sdk/region-config-resolver": "^3.972.13", + "@aws-sdk/types": "^3.973.8", + "@aws-sdk/util-endpoints": "^3.996.8", + "@aws-sdk/util-user-agent-browser": "^3.972.10", + "@aws-sdk/util-user-agent-node": "^3.973.24", + "@smithy/config-resolver": "^4.4.17", + "@smithy/core": "^3.23.17", + "@smithy/fetch-http-handler": "^5.3.17", + "@smithy/hash-node": "^4.2.14", + "@smithy/invalid-dependency": "^4.2.14", + "@smithy/middleware-content-length": "^4.2.14", + "@smithy/middleware-endpoint": "^4.4.32", + "@smithy/middleware-retry": "^4.5.7", + "@smithy/middleware-serde": "^4.2.20", + "@smithy/middleware-stack": "^4.2.14", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/node-http-handler": "^4.6.1", + "@smithy/protocol-http": "^5.3.14", + "@smithy/smithy-client": "^4.12.13", + "@smithy/types": "^4.14.1", + "@smithy/url-parser": "^4.2.14", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-body-length-browser": "^4.2.2", + "@smithy/util-body-length-node": "^4.2.3", + "@smithy/util-defaults-mode-browser": "^4.3.49", + "@smithy/util-defaults-mode-node": "^4.2.54", + "@smithy/util-endpoints": "^3.4.2", + "@smithy/util-middleware": "^4.2.14", + "@smithy/util-retry": "^4.3.6", + "@smithy/util-stream": "^4.5.25", + "@smithy/util-utf8": "^4.2.2", + "@smithy/util-waiter": "^4.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-cloudfront/node_modules/@aws-sdk/middleware-host-header": { + "version": "3.972.23", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.23.tgz", + "integrity": "sha512-sWeojvaTI86wGRIt5HQb9o50V6AExi+NV3VrH2AwBlzV47a+m4JAejaC8CObaLXGVqSRqGt1B9AGYONp+NcTXg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.22", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-cloudfront/node_modules/@aws-sdk/middleware-logger": { + "version": "3.972.22", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.972.22.tgz", + "integrity": "sha512-opYE2yUoQKJJz5QrGylnZeaBRmUaRzUSxlW40cX4LnmwO5XI61IWC3k/LDbJwF15AvmlNbome+glhIlFJzCWJw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.22", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-cloudfront/node_modules/@aws-sdk/middleware-recursion-detection": { + "version": "3.972.24", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.972.24.tgz", + "integrity": "sha512-M0gO9RgAppZx7yDvFRB1pdnyzd34Gnt/JjpT9HoMZxxDYnAkYNa3eabbjsf2A6ffxDFAvZKFdkjthrQxlQgcRg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.22", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-cloudfront/node_modules/@aws-sdk/region-config-resolver": { + "version": "3.972.26", + "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.972.26.tgz", + "integrity": "sha512-wDR6i4YBvJTGGElCQlXQd42f6AraatbU9ckvrjsKu1wFBwH0vTUBxFo6kqRWfuleKhDkMqSkQujCL1eWbFFLtw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.22", "tslib": "^2.6.2" }, "engines": { @@ -10368,6 +10538,30 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/client-cloudfront/node_modules/@aws-sdk/util-endpoints": { + "version": "3.996.21", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.21.tgz", + "integrity": "sha512-mZbKi9+3h3BtGkph4eYPMCoxiyYHM4q1rYZILfCE4a1uszKaXCRrI0n8nPqcOQDGc2fTwpXm7jLAs/sok4MxVg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.22", + "@smithy/core": "^3.24.6", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-cloudfront/node_modules/@aws-sdk/util-user-agent-browser": { + "version": "3.972.23", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.972.23.tgz", + "integrity": "sha512-5jF9hUGr6vplVanji97KRmbbTUlStXOp/WSz7xArD9fIWxId7tZoq0fPVRNJC5xOuaGzkwO3jHZn8BicrwfwSw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.22", + "tslib": "^2.6.2" + } + }, "node_modules/@aws-sdk/client-dynamodb": { "version": "3.1069.0", "resolved": "https://registry.npmjs.org/@aws-sdk/client-dynamodb/-/client-dynamodb-3.1069.0.tgz", @@ -10399,6 +10593,7 @@ "integrity": "sha512-pEHZqRkAlHfnfAU9tK+WpKv/gBNjGJrHMgA3A0iYRGyswBS2t0pfez+lWlwktb3Bqa0ovh7w/QJTFwp3fDxLNg==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@smithy/types": "^4.14.3", "tslib": "^2.6.2" @@ -11601,10 +11796,9 @@ } }, "node_modules/@aws-sdk/client-sts": { - "version": "3.1042.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.1042.0.tgz", - "integrity": "sha512-CTpdtuLEdClT31afs8eabb9Yhm1fRU8DRLak/+5VTrVSZcWzEvzVhA4ZVHvf4w7EPah2yve//14zwyDYa4IxVQ==", - "dev": true, + "version": "3.1045.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.1045.0.tgz", + "integrity": "sha512-oDJJ7rM1osvfBdfZuhQ5DM6lHD9iuypL9m2LsEiA/lB8xuE5uPYsftNDcS0J9VRXFSvYTqC14K7Y5vMMKMg0vw==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", @@ -11656,7 +11850,6 @@ "version": "3.972.13", "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.13.tgz", "integrity": "sha512-EA3+u2LD3kGcfRNmCSjyJuzX4XvG4zYv57i4ZksH+1IEciuSyHQGvzivEz7vZ+jbRPdAAe7WWKy/4M8InCKDcw==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/core": "^3.974.12", @@ -11670,7 +11863,6 @@ "version": "3.972.12", "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.972.12.tgz", "integrity": "sha512-NxB2dS4/mV3380hNkC72TkhMaLLjWGGBeTAEucqlJptVVovTbNmQWZLwaMC74ICo9NZHmFiBVVTHzDfAh/3y6Q==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/core": "^3.974.12", @@ -11684,7 +11876,6 @@ "version": "3.972.14", "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.972.14.tgz", "integrity": "sha512-bqL+upATpOJ/7px4IVfMVxcM6Lyt9uRizmEx3mNg4N6+IQlnOaYXXOJ4TNX6P0mKPPW0lwn9ZW8QEhXwQuCH9A==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/core": "^3.974.12", @@ -11698,7 +11889,6 @@ "version": "3.972.16", "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.972.16.tgz", "integrity": "sha512-/YaivCvKUkEeMN9VTKBSvBn5w/4osAM1YboM58DKaLF/vqFGf/FdJCLmppqiPPJWZaXcASqByVjc3evE7KHKdA==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/core": "^3.974.12", @@ -11712,7 +11902,6 @@ "version": "3.973.8", "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.8.tgz", "integrity": "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.14.1", @@ -11726,7 +11915,6 @@ "version": "3.996.11", "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.11.tgz", "integrity": "sha512-BUMJ6VoL54r6Udj/wKy8uKRIndL04rGbaS/wTIV0dM1ewxSrR8yARBHdvZKQsK55ZSW2JrmAPk3KP15kBDxJMw==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/core": "^3.974.12", @@ -11741,7 +11929,6 @@ "version": "3.972.13", "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.972.13.tgz", "integrity": "sha512-wfk9ZdVwh187gdGXB1EyAoprwjSMt/bSfVtva+OaZx+LyNdKD7smlZf611yMd42UpfQ9vaS8NOftjSajgpdd+w==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/core": "^3.974.12", @@ -11749,9 +11936,9 @@ } }, "node_modules/@aws-sdk/core": { - "version": "3.974.21", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.21.tgz", - "integrity": "sha512-P5JAHvn4dTi96UsAGS67LVOqqpUNNRhnfFXqzCYtdBIGZtqBue4CXvRr9YenOO7PALj/Pn8uuyw53FBCiCYw8w==", + "version": "3.974.22", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.22.tgz", + "integrity": "sha512-YofH63shc6YRdXjz80BJkpJW+Bkn0Cuu2dn4Rv7s9G2Idt58tgtzQEWxrR2xVljlVfIBeUjPuULnSVYLke3sUQ==", "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "^3.973.13", @@ -12207,6 +12394,7 @@ "integrity": "sha512-wfAWZ6oIrsDOFyYm9bDQNva/WCmvIrVqP3dSCePN5YYWCGWWXkikn5YC0wPSxF92M8kQFPfdVpMaTTV1mRk4Lw==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@aws-sdk/core": "^3.974.21", "@smithy/core": "^3.24.6", @@ -12223,6 +12411,7 @@ "integrity": "sha512-bBmkG0Dnhfq0/T4Z0PpUr7HkncBVaWvvCbvafeaUM+yC9wa8GGjLJmonq0QL17REB9WivgGeYgWQ5A80Uw5UnQ==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "mnemonist": "0.38.3", "tslib": "^2.6.2" @@ -12286,6 +12475,7 @@ "integrity": "sha512-FMgyzUq3Jh+ONRYxryBRNdBd+FUX8PwRl07ccQknNdoms6KCeAEusCkl6whqpDrPQ6OH0ddeSifKyqYSs2DLIw==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@aws-sdk/endpoint-cache": "^3.972.8", "@aws-sdk/types": "^3.973.13", @@ -12303,6 +12493,7 @@ "integrity": "sha512-pEHZqRkAlHfnfAU9tK+WpKv/gBNjGJrHMgA3A0iYRGyswBS2t0pfez+lWlwktb3Bqa0ovh7w/QJTFwp3fDxLNg==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@smithy/types": "^4.14.3", "tslib": "^2.6.2" @@ -12958,8 +13149,7 @@ "version": "2.7.0", "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.7.0.tgz", "integrity": "sha512-qn6tAIZEw5i/wiESBF4nQxZkl86aY4KoO0IkUa2Lh+rya64oTOdJQFlZuMwI1Qz9VBJQrQC4QlSA2DNek5gCOA==", - "license": "(Apache-2.0 AND BSD-3-Clause)", - "peer": true + "license": "(Apache-2.0 AND BSD-3-Clause)" }, "node_modules/@cfworker/json-schema": { "version": "4.1.1", @@ -12983,7 +13173,6 @@ "resolved": "https://registry.npmjs.org/@connectrpc/connect/-/connect-2.1.1.tgz", "integrity": "sha512-JzhkaTvM73m2K1URT6tv53k2RwngSmCXLZJgK580qNQOXRzZRR/BCMfZw3h+90JpnG6XksP5bYT+cz0rpUzUWQ==", "license": "Apache-2.0", - "peer": true, "peerDependencies": { "@bufbuild/protobuf": "^2.7.0" } @@ -13961,7 +14150,6 @@ "resolved": "https://registry.npmjs.org/@langchain/core/-/core-0.3.80.tgz", "integrity": "sha512-vcJDV2vk1AlCwSh3aBm/urQ1ZrlXFFBocv11bz/NBUfLWD5/UDNMzwPdaAd2dKvNmTWa9FM2lirLU3+JCf4cRA==", "license": "MIT", - "peer": true, "dependencies": { "@cfworker/json-schema": "^4.0.2", "ansi-styles": "^5.0.0", @@ -14259,7 +14447,6 @@ "resolved": "https://registry.npmjs.org/@octokit/core/-/core-7.0.6.tgz", "integrity": "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==", "license": "MIT", - "peer": true, "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.3", @@ -14466,7 +14653,6 @@ "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=8.0.0" } @@ -14631,7 +14817,6 @@ "integrity": "sha512-r86ut4T1e8vNwB35CqCcKd45yzqH6/6Wzvpk2/cZB8PsPLlZFTvrh8yfOS3CYZYcUmAx4hHTZJ8AO8Dj8nrdhw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/resources": "2.6.1", @@ -16649,7 +16834,6 @@ "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", "license": "MIT", - "peer": true, "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", @@ -16897,7 +17081,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -16944,7 +17127,6 @@ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -17409,7 +17591,6 @@ "resolved": "https://registry.npmjs.org/aws-xray-sdk-core/-/aws-xray-sdk-core-3.12.0.tgz", "integrity": "sha512-lwalRdxXRy+Sn49/vN7W507qqmBRk5Fy2o0a9U6XTjL9IV+oR5PUiiptoBrOcaYCiVuGld8OEbNqhm6wvV3m6A==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/types": "^3.4.1", "@smithy/service-error-classification": "^2.0.4", @@ -18159,7 +18340,6 @@ "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -20389,7 +20569,6 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -24711,7 +24890,6 @@ "integrity": "sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A==", "dev": true, "license": "MIT", - "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -25768,6 +25946,7 @@ "integrity": "sha512-2K9QYubXx/NAjv4VLq1d1Ly8pWNC5L3BrixtdkyTegXWJIqY+zLNDhhX/A+ZwWt70tB1S8H4BE8FLYEFyNoOBw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "obliterator": "^1.6.1" } @@ -25778,7 +25957,6 @@ "integrity": "sha512-UczzB+0nnwGotYSgllfARAqWCJ5e/skuV2K/l+Zyck/H6pJIhLXuBnz+6vn2i211o7DtbE78HQtsYEKICHGI+g==", "dev": true, "license": "MIT", - "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/mobx" @@ -28359,7 +28537,6 @@ "dev": true, "inBundle": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -28833,7 +29010,8 @@ "resolved": "https://registry.npmjs.org/obliterator/-/obliterator-1.6.1.tgz", "integrity": "sha512-9WXswnqINnnhOG/5SLimUlzuU1hFJUc8zkwyD59Sd+dPOMf05PmnYG/d6Q7HZ+KmgkZJa1PxRso6QdM3sTNHig==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/on-finished": { "version": "2.4.1", @@ -28887,7 +29065,6 @@ "resolved": "https://registry.npmjs.org/openai/-/openai-5.12.2.tgz", "integrity": "sha512-xqzHHQch5Tws5PcKR2xsZGX9xtch+JQFz5zb14dGqlshmmDAFBFEWmeIpf7wVqWV+w7Emj7jRgkNJakyKE0tYQ==", "license": "Apache-2.0", - "peer": true, "bin": { "openai": "bin/cli" }, @@ -30075,7 +30252,6 @@ "integrity": "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==", "devOptional": true, "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -30086,7 +30262,6 @@ "integrity": "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -30850,7 +31025,6 @@ "integrity": "sha512-WRgl5GcypwramYX4HV+eQGzUbD7UUbljVmS+5G1uMwX/wLgYuJAxGeerXJDMO2xshng4+FXqCgyB5QfClV6WjA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@semantic-release/commit-analyzer": "^13.0.1", "@semantic-release/error": "^4.0.0", @@ -32215,7 +32389,6 @@ "integrity": "sha512-ADu2dF53esUzzM4I0ewxhxFtsDd6v4V6dNkg3vG0iFKhnt06sJneTZnRvujAosZwW0XD58IKgGMQoqri4wHRqg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@emotion/is-prop-valid": "1.4.0", "css-to-react-native": "3.2.0", @@ -33222,7 +33395,6 @@ "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", "license": "MIT", - "peer": true, "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", @@ -34029,7 +34201,6 @@ "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", "license": "MIT", - "peer": true, "engines": { "node": ">=10.0.0" }, @@ -34321,7 +34492,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -34331,7 +34501,6 @@ "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.0.tgz", "integrity": "sha512-HvWtU2UG41LALjajJrML6uQejQhNJx+JBO9IflpSja4R03iNWfKXrj6W2h7ljuLyc1nKS+9yDyL/9tD1U/yBnQ==", "license": "ISC", - "peer": true, "peerDependencies": { "zod": "^3.25 || ^4" } diff --git a/package.json b/package.json index 61e3aad518..05d5019f10 100644 --- a/package.json +++ b/package.json @@ -98,10 +98,12 @@ "@adobe/spacecat-shared-tokowaka-client": "1.19.0", "@adobe/spacecat-shared-utils": "1.119.2", "@adobe/spacecat-shared-vault-secrets": "1.3.5", + "@aws-sdk/client-cloudfront": "3.1045.0", "@aws-sdk/client-s3": "3.1045.0", "@aws-sdk/client-secrets-manager": "3.1045.0", "@aws-sdk/client-sfn": "3.1045.0", "@aws-sdk/client-sqs": "3.1045.0", + "@aws-sdk/client-sts": "3.1045.0", "@aws-sdk/s3-request-presigner": "3.1045.0", "@bufbuild/protobuf": "2.7.0", "@connectrpc/connect": "^2.1.1", diff --git a/src/controllers/llmo/llmo.js b/src/controllers/llmo/llmo.js index 1d5136289f..5c157407ae 100644 --- a/src/controllers/llmo/llmo.js +++ b/src/controllers/llmo/llmo.js @@ -33,6 +33,7 @@ import { Entitlement as EntitlementModel } from '@adobe/spacecat-shared-data-acc import TokowakaClient, { calculateForwardedHost } from '@adobe/spacecat-shared-tokowaka-client'; import { ImsClient } from '@adobe/spacecat-shared-ims-client'; import AccessControlUtil from '../../support/access-control-util.js'; +import { assumeConnectorRole, listCloudFrontDistributions } from '../../support/edge-optimize.js'; import { UnauthorizedProductError } from '../../support/errors.js'; import { cachedOk } from '../../support/cached-response.js'; import { @@ -2160,8 +2161,98 @@ function LlmoController(ctx) { } }; + // Shared access gate for the CloudFront "Deploy routing" wizard endpoints: the caller + // must have access to the site and be an LLMO administrator. Returns { error } (a Response) + // when denied, or {} when allowed. + const gateEdgeOptimizeWizard = async (siteId, Site, action) => { + const site = await Site.findById(siteId); + if (!site) { + return { error: notFound('Site not found') }; + } + if (!await accessControlUtil.hasAccess(site)) { + return { error: forbidden('User does not have access to this site') }; + } + if (!accessControlUtil.isLLMOAdministrator()) { + return { error: forbidden(`Only LLMO administrators can ${action}`) }; + } + return {}; + }; + + // Verify the customer's cross-account connector role is assumable. Used by the wizard's + // "Allow access" step, which polls this after the customer creates the role via CloudFormation. + const connectEdgeOptimize = async (context) => { + const { log, dataAccess, env } = context; + const { siteId } = context.params; + const { Site } = dataAccess; + const accountId = String(context.data?.accountId || '').replace(/\D/g, ''); + const externalId = String(context.data?.externalId || '').trim(); + const roleName = env?.EDGE_OPTIMIZE_ROLE_NAME || undefined; + + if (accountId.length !== 12) { + return badRequest('accountId must be a 12-digit AWS account ID'); + } + if (!hasText(externalId)) { + return badRequest('externalId is required'); + } + + try { + const { error } = await gateEdgeOptimizeWizard(siteId, Site, 'connect the edge optimize role'); + if (error) { + return error; + } + + try { + const { roleArn } = await assumeConnectorRole({ accountId, externalId, roleName }); + log.info(`[edge-optimize-connect] Connected site ${siteId} to account ${accountId}`); + return ok({ connected: true, accountId, roleArn }); + } catch (assumeError) { + // The role may not exist yet (customer still creating it) or the external ID may not + // match — surface as not-connected so the wizard can keep polling rather than erroring. + log.info(`[edge-optimize-connect] Role not yet assumable for site ${siteId}: ${assumeError.message}`); + return ok({ connected: false, reason: cleanupHeaderValue(assumeError.message) }); + } + } catch (error) { + log.error(`Failed to connect edge optimize role for site ${siteId}:`, error); + return badRequest(cleanupHeaderValue(error.message)); + } + }; + + // List the customer's CloudFront distributions (read-only) via the connector role, so the + // wizard's "Choose distribution" step can let the customer pick one to configure. + const getEdgeOptimizeDistributions = async (context) => { + const { log, dataAccess, env } = context; + const { siteId } = context.params; + const { Site } = dataAccess; + const accountId = String(context.data?.accountId || '').replace(/\D/g, ''); + const externalId = String(context.data?.externalId || '').trim(); + const roleName = env?.EDGE_OPTIMIZE_ROLE_NAME || undefined; + + if (accountId.length !== 12) { + return badRequest('accountId must be a 12-digit AWS account ID'); + } + if (!hasText(externalId)) { + return badRequest('externalId is required'); + } + + try { + const { error } = await gateEdgeOptimizeWizard(siteId, Site, 'list CloudFront distributions'); + if (error) { + return error; + } + + const { credentials } = await assumeConnectorRole({ accountId, externalId, roleName }); + const distributions = await listCloudFrontDistributions(credentials); + return ok({ distributions }); + } catch (error) { + log.error(`Failed to list CloudFront distributions for site ${siteId}:`, error); + return badRequest(cleanupHeaderValue(error.message)); + } + }; + return { getEdgeOptimizeBootstrapUrl, + connectEdgeOptimize, + getEdgeOptimizeDistributions, getLlmoSheetData, queryLlmoSheetData, getLlmoGlobalSheetData, diff --git a/src/routes/index.js b/src/routes/index.js index ced21a9aef..03bbb0a3f4 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -481,6 +481,8 @@ export default function getRouteHandlers( 'GET /sites/:siteId/llmo/edge-optimize-config': llmoController.getEdgeConfig, 'POST /sites/:siteId/llmo/edge-optimize-config/stage': llmoController.createOrUpdateStageEdgeConfig, 'POST /sites/:siteId/llmo/edge-optimize-bootstrap-url': llmoController.getEdgeOptimizeBootstrapUrl, + 'POST /sites/:siteId/llmo/edge-optimize/connect': llmoController.connectEdgeOptimize, + 'POST /sites/:siteId/llmo/edge-optimize/distributions': llmoController.getEdgeOptimizeDistributions, 'GET /sites/:siteId/llmo/strategy': llmoController.getStrategy, 'PUT /sites/:siteId/llmo/strategy': llmoController.saveStrategy, 'GET /sites/:siteId/llmo/edge-optimize-status': llmoController.checkEdgeOptimizeStatus, diff --git a/src/routes/required-capabilities.js b/src/routes/required-capabilities.js index d7c9e3d4e4..ef7572de15 100644 --- a/src/routes/required-capabilities.js +++ b/src/routes/required-capabilities.js @@ -128,6 +128,8 @@ export const INTERNAL_ROUTES = [ 'POST /sites/:siteId/llmo/edge-optimize-config', 'POST /sites/:siteId/llmo/edge-optimize-config/stage', 'POST /sites/:siteId/llmo/edge-optimize-bootstrap-url', + 'POST /sites/:siteId/llmo/edge-optimize/connect', + 'POST /sites/:siteId/llmo/edge-optimize/distributions', 'PUT /sites/:siteId/llmo/opportunities-reviewed', // PLG onboarding - IMS token auth, self-service flow, not S2S diff --git a/src/support/edge-optimize.js b/src/support/edge-optimize.js new file mode 100644 index 0000000000..6507ab5aa1 --- /dev/null +++ b/src/support/edge-optimize.js @@ -0,0 +1,96 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { STSClient, AssumeRoleCommand } from '@aws-sdk/client-sts'; +import { CloudFrontClient, ListDistributionsCommand } from '@aws-sdk/client-cloudfront'; +import { hasText } from '@adobe/spacecat-shared-utils'; + +// CloudFront is a global service; its control plane lives in us-east-1. +export const EDGE_OPTIMIZE_REGION = 'us-east-1'; +export const EDGE_OPTIMIZE_DEFAULT_ROLE_NAME = 'AdobeLLMOptimizerCloudFrontConnectorRole'; +const SESSION_NAME = 'llmo-edge-optimize'; +const SESSION_DURATION_SECONDS = 900; + +/** + * Assume the customer's cross-account connector role and return short-lived credentials. + * + * The api-service Lambda execution role (the default credential chain) assumes the role the + * customer created via the CloudFormation bootstrap, scoped by the per-session external ID. + * Credentials are short-lived — callers should use them immediately for a single operation + * and never persist them in the browser. + * + * @param {object} params + * @param {string} params.accountId - 12-digit customer AWS account ID. + * @param {string} params.externalId - external ID baked into the connector role trust policy. + * @param {string} [params.roleName] - connector role name (defaults to the standard name). + * @param {string} [params.region] - STS region. + * @returns {Promise<{roleArn: string, accountId: string, credentials: object}>} + */ +export async function assumeConnectorRole({ + accountId, + externalId, + roleName = EDGE_OPTIMIZE_DEFAULT_ROLE_NAME, + region = EDGE_OPTIMIZE_REGION, +}) { + if (!/^[0-9]{12}$/.test(String(accountId))) { + throw new Error('accountId must be a 12-digit AWS account ID'); + } + if (!hasText(externalId)) { + throw new Error('externalId is required'); + } + + const roleArn = `arn:aws:iam::${accountId}:role/${roleName}`; + const sts = new STSClient({ region }); + const response = await sts.send(new AssumeRoleCommand({ + RoleArn: roleArn, + RoleSessionName: SESSION_NAME, + ExternalId: externalId, + DurationSeconds: SESSION_DURATION_SECONDS, + })); + + const creds = response?.Credentials; + if (!creds?.AccessKeyId || !creds?.SecretAccessKey || !creds?.SessionToken) { + throw new Error('Failed to assume connector role: no credentials returned'); + } + + return { + roleArn, + accountId: String(accountId), + credentials: { + accessKeyId: creds.AccessKeyId, + secretAccessKey: creds.SecretAccessKey, + sessionToken: creds.SessionToken, + expiration: creds.Expiration, + }, + }; +} + +/** + * List the CloudFront distributions in the customer account using assumed-role credentials. + * + * @param {object} credentials - temporary credentials from {@link assumeConnectorRole}. + * @param {string} [region] - CloudFront control-plane region. + * @returns {Promise>} distributions projected to the fields the wizard needs. + */ +export async function listCloudFrontDistributions(credentials, region = EDGE_OPTIMIZE_REGION) { + const client = new CloudFrontClient({ region, credentials }); + const response = await client.send(new ListDistributionsCommand({})); + const items = response?.DistributionList?.Items || []; + return items.map((dist) => ({ + id: dist.Id, + domainName: dist.DomainName, + aliases: dist.Aliases?.Items || [], + status: dist.Status, + enabled: dist.Enabled === true, + comment: dist.Comment || '', + })); +} diff --git a/test/controllers/llmo/llmo.test.js b/test/controllers/llmo/llmo.test.js index f6ff316f2e..54ac17fc92 100644 --- a/test/controllers/llmo/llmo.test.js +++ b/test/controllers/llmo/llmo.test.js @@ -86,6 +86,8 @@ describe('LlmoController', () => { let llmoConfigSchemaStub; let triggerBrandProfileAgentStub; let updateModifiedByDetailsStub; + let assumeConnectorRoleStub; + let listCloudFrontDistributionsStub; let mockTokowakaClient; let readStrategyStub; let writeStrategyStub; @@ -194,6 +196,8 @@ describe('LlmoController', () => { this.timeout(120000); triggerBrandProfileAgentStub = sinon.stub().resolves('exec-123'); updateModifiedByDetailsStub = sinon.stub(); + assumeConnectorRoleStub = sinon.stub(); + listCloudFrontDistributionsStub = sinon.stub(); // Initialize mock TokowakaClient mockTokowakaClient = { @@ -262,6 +266,10 @@ describe('LlmoController', () => { getImsTokenFromPromiseToken: (...args) => getImsTokenFromPromiseTokenStub(...args), authorizeEdgeCdnRouting: (...args) => authorizeEdgeCdnRoutingStub(...args), }, + '../../../src/support/edge-optimize.js': { + assumeConnectorRole: (...args) => assumeConnectorRoleStub(...args), + listCloudFrontDistributions: (...args) => listCloudFrontDistributionsStub(...args), + }, '@adobe/spacecat-shared-ims-client': { ImsClient: function MockImsClient() { this.getServicePrincipalAccessToken = (...args) => ( @@ -7622,6 +7630,178 @@ describe('LlmoController', () => { }); }); + describe('connectEdgeOptimize', () => { + let connectContext; + + beforeEach(() => { + assumeConnectorRoleStub = sinon.stub().resolves({ + roleArn: 'arn:aws:iam::120569600543:role/AdobeLLMOptimizerCloudFrontConnectorRole', + accountId: '120569600543', + credentials: { accessKeyId: 'AKIA', secretAccessKey: 'secret', sessionToken: 'token' }, + }); + connectContext = { + ...mockContext, + params: { siteId: TEST_SITE_ID }, + data: { accountId: '120569600543', externalId: '7ff9518a-cf59-40b4-aa53-68a3cb2e24a5' }, + env: {}, + }; + }); + + it('returns connected true when the connector role is assumable', async () => { + const result = await controller.connectEdgeOptimize(connectContext); + + expect(result.status).to.equal(200); + const body = await result.json(); + expect(body.connected).to.equal(true); + expect(body.accountId).to.equal('120569600543'); + expect(body.roleArn).to.include('AdobeLLMOptimizerCloudFrontConnectorRole'); + expect(assumeConnectorRoleStub.calledOnce).to.equal(true); + }); + + it('returns connected false (not erroring) when the role is not yet assumable', async () => { + assumeConnectorRoleStub = sinon.stub().rejects(new Error('AccessDenied: not authorized to assume')); + + const result = await controller.connectEdgeOptimize(connectContext); + + expect(result.status).to.equal(200); + const body = await result.json(); + expect(body.connected).to.equal(false); + expect(body.reason).to.include('AccessDenied'); + }); + + it('returns 400 for an invalid account id', async () => { + const result = await controller.connectEdgeOptimize({ ...connectContext, data: { accountId: '123', externalId: 'ext' } }); + + expect(result.status).to.equal(400); + const body = await result.json(); + expect(body.message).to.include('12-digit'); + }); + + it('returns 400 when the external id is missing', async () => { + const result = await controller.connectEdgeOptimize({ ...connectContext, data: { accountId: '120569600543' } }); + + expect(result.status).to.equal(400); + const body = await result.json(); + expect(body.message).to.include('externalId'); + }); + + it('returns 404 when the site is not found', async () => { + mockDataAccess.Site.findById.resolves(null); + + const result = await controller.connectEdgeOptimize(connectContext); + + expect(result.status).to.equal(404); + }); + + it('returns 403 when the user lacks access to the site', async () => { + const deniedController = controllerWithAccessDenied(mockContext); + + const result = await deniedController.connectEdgeOptimize(connectContext); + + expect(result.status).to.equal(403); + }); + + it('returns 403 when the user is not an LLMO administrator', async () => { + const LlmoControllerNoAdmin = await esmock('../../../src/controllers/llmo/llmo.js', { + '../../../src/support/access-control-util.js': createMockAccessControlUtil(true, true, false), + '@adobe/spacecat-shared-http-utils': mockHttpUtils, + '../../../src/support/cached-response.js': mockCachedResponse, + ...getCommonMocks(), + }); + + const result = await LlmoControllerNoAdmin(mockContext).connectEdgeOptimize(connectContext); + + expect(result.status).to.equal(403); + }); + + it('returns 400 when an unexpected error occurs', async () => { + mockDataAccess.Site.findById.rejects(new Error('db boom')); + + const result = await controller.connectEdgeOptimize(connectContext); + + expect(result.status).to.equal(400); + const body = await result.json(); + expect(body.message).to.include('db boom'); + }); + }); + + describe('getEdgeOptimizeDistributions', () => { + let distributionsContext; + + beforeEach(() => { + assumeConnectorRoleStub = sinon.stub().resolves({ + roleArn: 'arn:aws:iam::120569600543:role/AdobeLLMOptimizerCloudFrontConnectorRole', + accountId: '120569600543', + credentials: { accessKeyId: 'AKIA', secretAccessKey: 'secret', sessionToken: 'token' }, + }); + listCloudFrontDistributionsStub = sinon.stub().resolves([ + { + id: 'E2EXAMPLE123', + domainName: 'd111111abcdef8.cloudfront.net', + aliases: ['www.example.com'], + status: 'Deployed', + enabled: true, + comment: '', + }, + ]); + distributionsContext = { + ...mockContext, + params: { siteId: TEST_SITE_ID }, + data: { accountId: '120569600543', externalId: '7ff9518a-cf59-40b4-aa53-68a3cb2e24a5' }, + env: {}, + }; + }); + + it('returns the list of CloudFront distributions', async () => { + const result = await controller.getEdgeOptimizeDistributions(distributionsContext); + + expect(result.status).to.equal(200); + const body = await result.json(); + expect(body.distributions).to.have.length(1); + expect(body.distributions[0].id).to.equal('E2EXAMPLE123'); + expect(assumeConnectorRoleStub.calledOnce).to.equal(true); + expect(listCloudFrontDistributionsStub.calledOnce).to.equal(true); + }); + + it('returns 400 for an invalid account id', async () => { + const result = await controller.getEdgeOptimizeDistributions({ ...distributionsContext, data: { accountId: '123', externalId: 'ext' } }); + + expect(result.status).to.equal(400); + }); + + it('returns 400 when the external id is missing', async () => { + const result = await controller.getEdgeOptimizeDistributions({ ...distributionsContext, data: { accountId: '120569600543' } }); + + expect(result.status).to.equal(400); + }); + + it('returns 400 when the AWS call fails', async () => { + listCloudFrontDistributionsStub = sinon.stub().rejects(new Error('ListDistributions failed')); + + const result = await controller.getEdgeOptimizeDistributions(distributionsContext); + + expect(result.status).to.equal(400); + const body = await result.json(); + expect(body.message).to.include('ListDistributions failed'); + }); + + it('returns 404 when the site is not found', async () => { + mockDataAccess.Site.findById.resolves(null); + + const result = await controller.getEdgeOptimizeDistributions(distributionsContext); + + expect(result.status).to.equal(404); + }); + + it('returns 403 when the user lacks access to the site', async () => { + const deniedController = controllerWithAccessDenied(mockContext); + + const result = await deniedController.getEdgeOptimizeDistributions(distributionsContext); + + expect(result.status).to.equal(403); + }); + }); + describe('getStrategy', () => { const mockStrategyData = { opportunities: [ diff --git a/test/routes/index.test.js b/test/routes/index.test.js index eabb70caf5..1c3579579a 100755 --- a/test/routes/index.test.js +++ b/test/routes/index.test.js @@ -327,6 +327,8 @@ describe('getRouteHandlers', () => { getLlmoRationale: () => null, getBrandClaims: () => null, createOrUpdateEdgeConfig: () => null, + connectEdgeOptimize: () => null, + getEdgeOptimizeDistributions: () => null, getEdgeConfig: () => null, createOrUpdateStageEdgeConfig: () => null, checkEdgeOptimizeStatus: () => null, @@ -1062,6 +1064,8 @@ describe('getRouteHandlers', () => { 'GET /sites/:siteId/llmo/edge-optimize-config', 'POST /sites/:siteId/llmo/edge-optimize-config/stage', 'POST /sites/:siteId/llmo/edge-optimize-bootstrap-url', + 'POST /sites/:siteId/llmo/edge-optimize/connect', + 'POST /sites/:siteId/llmo/edge-optimize/distributions', 'GET /sites/:siteId/llmo/edge-optimize-status', 'GET /sites/:siteId/llmo/probes/edge-optimize', 'GET /sites/:siteId/llmo/strategy', diff --git a/test/support/edge-optimize.test.js b/test/support/edge-optimize.test.js new file mode 100644 index 0000000000..6dcbf5ca55 --- /dev/null +++ b/test/support/edge-optimize.test.js @@ -0,0 +1,181 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { expect } from 'chai'; +import sinon from 'sinon'; +import esmock from 'esmock'; + +describe('edge-optimize support', () => { + let stsSendStub; + let cfSendStub; + let edgeOptimize; + + beforeEach(async function setup() { + this.timeout(30000); + stsSendStub = sinon.stub(); + cfSendStub = sinon.stub(); + edgeOptimize = await esmock('../../src/support/edge-optimize.js', { + '@aws-sdk/client-sts': { + STSClient: function STSClient() { + this.send = (cmd) => stsSendStub(cmd); + }, + AssumeRoleCommand: function AssumeRoleCommand(input) { + this.input = input; + }, + }, + '@aws-sdk/client-cloudfront': { + CloudFrontClient: function CloudFrontClient(config) { + this.config = config; + this.send = (cmd) => cfSendStub(cmd); + }, + ListDistributionsCommand: function ListDistributionsCommand(input) { + this.input = input; + }, + }, + }); + }); + + afterEach(() => { + sinon.restore(); + }); + + describe('assumeConnectorRole', () => { + it('assumes the role and returns mapped credentials', async () => { + stsSendStub.resolves({ + Credentials: { + AccessKeyId: 'AKIA', + SecretAccessKey: 'secret', + SessionToken: 'token', + Expiration: new Date('2030-01-01T00:00:00Z'), + }, + }); + + const result = await edgeOptimize.assumeConnectorRole({ + accountId: '120569600543', + externalId: 'ext-123', + }); + + expect(result.roleArn).to.equal('arn:aws:iam::120569600543:role/AdobeLLMOptimizerCloudFrontConnectorRole'); + expect(result.accountId).to.equal('120569600543'); + expect(result.credentials.accessKeyId).to.equal('AKIA'); + expect(result.credentials.secretAccessKey).to.equal('secret'); + expect(result.credentials.sessionToken).to.equal('token'); + expect(stsSendStub.calledOnce).to.equal(true); + }); + + it('uses a custom role name when provided', async () => { + stsSendStub.resolves({ + Credentials: { AccessKeyId: 'A', SecretAccessKey: 'S', SessionToken: 'T' }, + }); + + const result = await edgeOptimize.assumeConnectorRole({ + accountId: '120569600543', + externalId: 'ext', + roleName: 'CustomRole', + }); + + expect(result.roleArn).to.equal('arn:aws:iam::120569600543:role/CustomRole'); + }); + + it('throws for an invalid account id', async () => { + let error; + try { + await edgeOptimize.assumeConnectorRole({ accountId: '123', externalId: 'ext' }); + } catch (e) { + error = e; + } + expect(error).to.be.an('error'); + expect(error.message).to.include('12-digit'); + expect(stsSendStub.called).to.equal(false); + }); + + it('throws when the external id is missing', async () => { + let error; + try { + await edgeOptimize.assumeConnectorRole({ accountId: '120569600543', externalId: '' }); + } catch (e) { + error = e; + } + expect(error).to.be.an('error'); + expect(error.message).to.include('externalId'); + }); + + it('throws when STS returns no credentials', async () => { + stsSendStub.resolves({}); + let error; + try { + await edgeOptimize.assumeConnectorRole({ accountId: '120569600543', externalId: 'ext' }); + } catch (e) { + error = e; + } + expect(error).to.be.an('error'); + expect(error.message).to.include('no credentials'); + }); + }); + + describe('listCloudFrontDistributions', () => { + it('maps the distribution list to the wizard projection', async () => { + cfSendStub.resolves({ + DistributionList: { + Items: [ + { + Id: 'E123', + DomainName: 'd.cloudfront.net', + Aliases: { Items: ['www.example.com'] }, + Status: 'Deployed', + Enabled: true, + Comment: 'prod', + }, + ], + }, + }); + + const result = await edgeOptimize.listCloudFrontDistributions({ + accessKeyId: 'A', secretAccessKey: 'S', sessionToken: 'T', + }); + + expect(result).to.have.length(1); + expect(result[0]).to.deep.equal({ + id: 'E123', + domainName: 'd.cloudfront.net', + aliases: ['www.example.com'], + status: 'Deployed', + enabled: true, + comment: 'prod', + }); + }); + + it('returns an empty array when there are no distributions', async () => { + cfSendStub.resolves({ DistributionList: {} }); + + const result = await edgeOptimize.listCloudFrontDistributions({}); + + expect(result).to.deep.equal([]); + }); + + it('defaults aliases and comment when absent and reflects disabled state', async () => { + cfSendStub.resolves({ + DistributionList: { + Items: [{ + Id: 'E2', DomainName: 'd2.cloudfront.net', Status: 'InProgress', Enabled: false, + }], + }, + }); + + const result = await edgeOptimize.listCloudFrontDistributions({}); + + expect(result[0].aliases).to.deep.equal([]); + expect(result[0].comment).to.equal(''); + expect(result[0].enabled).to.equal(false); + }); + }); +}); From d58cd4d822058cea5fb03feefae1b0f51b0c65d6 Mon Sep 17 00:00:00 2001 From: Akash Bhardwaj Date: Sat, 20 Jun 2026 08:50:28 +0530 Subject: [PATCH 09/20] docs(llmo): OpenAPI for CloudFront wizard edge-optimize endpoints Document the already-shipped connect and distributions endpoints plus the new read-only prerequisites, origins, and behaviors endpoints for the CloudFront "Deploy routing" wizard. Adds shared connector/distribution request schemas and per-endpoint response definitions. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/openapi/api.yaml | 10 ++ docs/openapi/llmo-api.yaml | 351 +++++++++++++++++++++++++++++++++++++ 2 files changed, 361 insertions(+) diff --git a/docs/openapi/api.yaml b/docs/openapi/api.yaml index 4d4f5997e4..b51d813118 100644 --- a/docs/openapi/api.yaml +++ b/docs/openapi/api.yaml @@ -625,6 +625,16 @@ paths: $ref: './llmo-api.yaml#/site-llmo-edge-optimize-config-stage' /sites/{siteId}/llmo/edge-optimize-bootstrap-url: $ref: './llmo-api.yaml#/site-llmo-edge-optimize-bootstrap-url' + /sites/{siteId}/llmo/edge-optimize/connect: + $ref: './llmo-api.yaml#/site-llmo-edge-optimize-connect' + /sites/{siteId}/llmo/edge-optimize/distributions: + $ref: './llmo-api.yaml#/site-llmo-edge-optimize-distributions' + /sites/{siteId}/llmo/edge-optimize/prerequisites: + $ref: './llmo-api.yaml#/site-llmo-edge-optimize-prerequisites' + /sites/{siteId}/llmo/edge-optimize/origins: + $ref: './llmo-api.yaml#/site-llmo-edge-optimize-origins' + /sites/{siteId}/llmo/edge-optimize/behaviors: + $ref: './llmo-api.yaml#/site-llmo-edge-optimize-behaviors' /sites/{siteId}/llmo/edge-optimize-status: $ref: './llmo-api.yaml#/llmo-edge-optimize-status' /sites/{siteId}/llmo/probes/edge-optimize: diff --git a/docs/openapi/llmo-api.yaml b/docs/openapi/llmo-api.yaml index 877bd3a195..6465412fdd 100644 --- a/docs/openapi/llmo-api.yaml +++ b/docs/openapi/llmo-api.yaml @@ -2409,6 +2409,357 @@ site-llmo-edge-optimize-bootstrap-url: security: - api_key: [ ] +site-llmo-edge-optimize-connect: + post: + tags: + - llmo + summary: Verify the Edge Optimize cross-account connector role is assumable + description: | + Used by the CloudFront "Deploy routing" wizard's "Allow access" step. Attempts to assume + the customer's cross-account connector role (created via the CloudFormation bootstrap) using + the per-session external ID. Returns `connected: true` when the role is assumable; otherwise + returns `connected: false` with a `reason` so the wizard can keep polling while the customer + finishes creating the role. + operationId: connectEdgeOptimize + parameters: + - $ref: './parameters.yaml#/siteId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/edge-optimize-connector-request' + responses: + '200': + description: Connection probe result (inspect the `connected` field). + content: + application/json: + schema: + type: object + required: + - connected + properties: + connected: + type: boolean + description: Whether the connector role was assumable. + accountId: + type: string + description: The 12-digit AWS account ID (present when connected). + roleArn: + type: string + description: The connector role ARN (present when connected). + reason: + type: string + description: Why the role was not assumable (present when not connected). + example: + connected: true + accountId: '682033462621' + roleArn: 'arn:aws:iam::682033462621:role/AdobeLLMOptimizerCloudFrontConnectorRole' + '400': + $ref: './responses.yaml#/400' + '401': + $ref: './responses.yaml#/401' + '403': + $ref: './responses.yaml#/403' + '404': + $ref: './responses.yaml#/404' + '500': + $ref: './responses.yaml#/500' + security: + - api_key: [ ] + +site-llmo-edge-optimize-distributions: + post: + tags: + - llmo + summary: List the customer's CloudFront distributions + description: | + Used by the CloudFront "Deploy routing" wizard's "Choose distribution" step. Assumes the + cross-account connector role and lists the customer's CloudFront distributions (read-only) + so the wizard can let the customer pick one to configure. + operationId: getEdgeOptimizeDistributions + parameters: + - $ref: './parameters.yaml#/siteId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/edge-optimize-connector-request' + responses: + '200': + description: CloudFront distributions in the customer account. + content: + application/json: + schema: + type: object + required: + - distributions + properties: + distributions: + type: array + items: + type: object + properties: + id: + type: string + domainName: + type: string + aliases: + type: array + items: + type: string + status: + type: string + enabled: + type: boolean + comment: + type: string + example: + distributions: + - id: E2EXAMPLE123 + domainName: d111111abcdef8.cloudfront.net + aliases: + - www.example.com + status: Deployed + enabled: true + comment: prod distribution + '400': + $ref: './responses.yaml#/400' + '401': + $ref: './responses.yaml#/401' + '403': + $ref: './responses.yaml#/403' + '404': + $ref: './responses.yaml#/404' + '500': + $ref: './responses.yaml#/500' + security: + - api_key: [ ] + +site-llmo-edge-optimize-prerequisites: + post: + tags: + - llmo + summary: Run the Edge Optimize wizard pre-flight checks + description: | + Used by the CloudFront "Deploy routing" wizard to confirm the connector role is assumable + and grants CloudFront read access. Each check reports its `ok` status individually (with a + `detail` message on failure) so the wizard can show a per-check status rather than failing + the whole step. Always returns HTTP 200 — inspect each check's `ok` field. + operationId: checkEdgeOptimizePrerequisites + parameters: + - $ref: './parameters.yaml#/siteId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/edge-optimize-connector-request' + responses: + '200': + description: Prerequisite check results (inspect each check's `ok` field). + content: + application/json: + schema: + type: object + required: + - checks + properties: + checks: + type: array + items: + type: object + required: + - name + - ok + properties: + name: + type: string + ok: + type: boolean + detail: + type: string + description: Failure detail (present when `ok` is false). + example: + checks: + - name: connectorRole + ok: true + - name: cloudFrontRead + ok: true + '400': + $ref: './responses.yaml#/400' + '401': + $ref: './responses.yaml#/401' + '403': + $ref: './responses.yaml#/403' + '404': + $ref: './responses.yaml#/404' + '500': + $ref: './responses.yaml#/500' + security: + - api_key: [ ] + +site-llmo-edge-optimize-origins: + post: + tags: + - llmo + summary: Read the origins configured on a CloudFront distribution + description: | + Used by the CloudFront "Deploy routing" wizard's "Review origins" step. Assumes the connector + role, reads the distribution config (read-only), and returns its origins plus a flag + indicating whether an Edge Optimize origin already exists. + operationId: getEdgeOptimizeOrigins + parameters: + - $ref: './parameters.yaml#/siteId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/edge-optimize-distribution-request' + responses: + '200': + description: The distribution's origins. + content: + application/json: + schema: + type: object + required: + - origins + - hasEdgeOptimizeOrigin + properties: + origins: + type: array + items: + type: object + properties: + id: + type: string + domainName: + type: string + originPath: + type: string + hasEdgeOptimizeOrigin: + type: boolean + description: Whether an Edge Optimize origin is already configured. + example: + origins: + - id: origin-aem + domainName: origin.example.com + originPath: /content + hasEdgeOptimizeOrigin: false + '400': + $ref: './responses.yaml#/400' + '401': + $ref: './responses.yaml#/401' + '403': + $ref: './responses.yaml#/403' + '404': + $ref: './responses.yaml#/404' + '500': + $ref: './responses.yaml#/500' + security: + - api_key: [ ] + +site-llmo-edge-optimize-behaviors: + post: + tags: + - llmo + summary: Read the cache behaviors configured on a CloudFront distribution + description: | + Used by the CloudFront "Deploy routing" wizard's "Review routing" step. Assumes the connector + role, reads the distribution config (read-only), and returns the default cache behavior + followed by each ordered cache behavior so the wizard can show how traffic is currently + routed. + operationId: getEdgeOptimizeBehaviors + parameters: + - $ref: './parameters.yaml#/siteId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/edge-optimize-distribution-request' + responses: + '200': + description: The distribution's cache behaviors. + content: + application/json: + schema: + type: object + required: + - behaviors + properties: + behaviors: + type: array + items: + type: object + properties: + pathPattern: + type: string + targetOriginId: + type: string + isDefault: + type: boolean + example: + behaviors: + - pathPattern: Default (*) + targetOriginId: origin-aem + isDefault: true + - pathPattern: /api/* + targetOriginId: origin-api + isDefault: false + '400': + $ref: './responses.yaml#/400' + '401': + $ref: './responses.yaml#/401' + '403': + $ref: './responses.yaml#/403' + '404': + $ref: './responses.yaml#/404' + '500': + $ref: './responses.yaml#/500' + security: + - api_key: [ ] + +edge-optimize-connector-request: + type: object + required: + - accountId + - externalId + properties: + accountId: + type: string + description: The 12-digit AWS account ID hosting the customer's CloudFront distribution. + pattern: '^[0-9]{12}$' + example: '682033462621' + externalId: + type: string + description: Per-session external ID baked into the connector role's trust policy. + example: '7ff9518a-cf59-40b4-aa53-68a3cb2e24a5' + +edge-optimize-distribution-request: + type: object + required: + - accountId + - externalId + - distributionId + properties: + accountId: + type: string + description: The 12-digit AWS account ID hosting the customer's CloudFront distribution. + pattern: '^[0-9]{12}$' + example: '682033462621' + externalId: + type: string + description: Per-session external ID baked into the connector role's trust policy. + example: '7ff9518a-cf59-40b4-aa53-68a3cb2e24a5' + distributionId: + type: string + description: The CloudFront distribution ID to inspect. + example: E2EXAMPLE123 + llmo-probe-edge-optimize: get: tags: From 9a4f0495fdc1dfd69f822ba29eafeaa6976554f9 Mon Sep 17 00:00:00 2001 From: Akash Bhardwaj Date: Sat, 20 Jun 2026 08:50:45 +0530 Subject: [PATCH 10/20] feat(llmo): CloudFront wizard prerequisites, origins, behaviors endpoints (Phase 2) Add three read-only CloudFront wizard endpoints, mirroring the existing connect/distributions handlers (12-digit accountId + externalId validation, site/access/LLMO-admin gate, assumed-role calls, badRequest on failure): - POST /sites/:siteId/llmo/edge-optimize/prerequisites -> checkEdgeOptimizePrerequisites reports connectorRole + cloudFrontRead checks (ok/false + detail, never 500) - POST /sites/:siteId/llmo/edge-optimize/origins -> getEdgeOptimizeOrigins returns origins + hasEdgeOptimizeOrigin detection - POST /sites/:siteId/llmo/edge-optimize/behaviors -> getEdgeOptimizeBehaviors returns default + ordered cache behaviors Adds getDistributionConfig() support fn (GetDistributionConfigCommand) and unit tests for the support fn, controller handlers, and route registration. All three routes added to INTERNAL_ROUTES (admin/IMS-only, not S2S). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/controllers/llmo/llmo.js | 142 ++++++++++- src/routes/index.js | 3 + src/routes/required-capabilities.js | 3 + src/support/edge-optimize.js | 50 +++- test/controllers/llmo/llmo.test.js | 358 ++++++++++++++++++++++++++++ test/routes/index.test.js | 6 + test/support/edge-optimize.test.js | 62 +++++ 7 files changed, 622 insertions(+), 2 deletions(-) diff --git a/src/controllers/llmo/llmo.js b/src/controllers/llmo/llmo.js index 5c157407ae..bafc9b4636 100644 --- a/src/controllers/llmo/llmo.js +++ b/src/controllers/llmo/llmo.js @@ -33,7 +33,11 @@ import { Entitlement as EntitlementModel } from '@adobe/spacecat-shared-data-acc import TokowakaClient, { calculateForwardedHost } from '@adobe/spacecat-shared-tokowaka-client'; import { ImsClient } from '@adobe/spacecat-shared-ims-client'; import AccessControlUtil from '../../support/access-control-util.js'; -import { assumeConnectorRole, listCloudFrontDistributions } from '../../support/edge-optimize.js'; +import { + assumeConnectorRole, + listCloudFrontDistributions, + getDistributionConfig, +} from '../../support/edge-optimize.js'; import { UnauthorizedProductError } from '../../support/errors.js'; import { cachedOk } from '../../support/cached-response.js'; import { @@ -2249,10 +2253,146 @@ function LlmoController(ctx) { } }; + // Run the wizard's pre-flight checks: confirm the connector role is assumable and that it grants + // CloudFront read access. Each check reports ok/false individually so the wizard can show a + // per-check status rather than failing the whole step on a single problem. + const checkEdgeOptimizePrerequisites = async (context) => { + const { log, dataAccess, env } = context; + const { siteId } = context.params; + const { Site } = dataAccess; + const accountId = String(context.data?.accountId || '').replace(/\D/g, ''); + const externalId = String(context.data?.externalId || '').trim(); + const roleName = env?.EDGE_OPTIMIZE_ROLE_NAME || undefined; + + if (accountId.length !== 12) { + return badRequest('accountId must be a 12-digit AWS account ID'); + } + if (!hasText(externalId)) { + return badRequest('externalId is required'); + } + + try { + const { error } = await gateEdgeOptimizeWizard(siteId, Site, 'check edge optimize prerequisites'); + if (error) { + return error; + } + + const connectorRoleCheck = { name: 'connectorRole', ok: true }; + const cloudFrontReadCheck = { name: 'cloudFrontRead', ok: true }; + + try { + const { credentials } = await assumeConnectorRole({ accountId, externalId, roleName }); + try { + await listCloudFrontDistributions(credentials); + } catch (listError) { + cloudFrontReadCheck.ok = false; + cloudFrontReadCheck.detail = cleanupHeaderValue(listError.message); + } + } catch (assumeError) { + connectorRoleCheck.ok = false; + connectorRoleCheck.detail = cleanupHeaderValue(assumeError.message); + // Can't read CloudFront without the role, so mark it failed too. + cloudFrontReadCheck.ok = false; + cloudFrontReadCheck.detail = 'connector role not assumable'; + } + + // TODO: also validate the Edge Optimize API key here (was part of the standalone wizard). + return ok({ checks: [connectorRoleCheck, cloudFrontReadCheck] }); + } catch (error) { + log.error(`Failed to check edge optimize prerequisites for site ${siteId}:`, error); + return badRequest(cleanupHeaderValue(error.message)); + } + }; + + // Read the origins configured on a customer's CloudFront distribution so the wizard's + // "Review origins" step can show them and flag whether an Edge Optimize origin already exists. + const getEdgeOptimizeOrigins = async (context) => { + const { log, dataAccess, env } = context; + const { siteId } = context.params; + const { Site } = dataAccess; + const accountId = String(context.data?.accountId || '').replace(/\D/g, ''); + const externalId = String(context.data?.externalId || '').trim(); + const distributionId = String(context.data?.distributionId || '').trim(); + const roleName = env?.EDGE_OPTIMIZE_ROLE_NAME || undefined; + + if (accountId.length !== 12) { + return badRequest('accountId must be a 12-digit AWS account ID'); + } + if (!hasText(externalId)) { + return badRequest('externalId is required'); + } + if (!hasText(distributionId)) { + return badRequest('distributionId is required'); + } + + try { + const { error } = await gateEdgeOptimizeWizard(siteId, Site, 'read CloudFront origins'); + if (error) { + return error; + } + + const { credentials } = await assumeConnectorRole({ accountId, externalId, roleName }); + const { origins } = await getDistributionConfig(credentials, distributionId); + const hasEdgeOptimizeOrigin = origins.some((origin) => /edgeoptimize/i.test(origin.id) + || /edgeoptimize/i.test(origin.domainName || '')); + return ok({ origins, hasEdgeOptimizeOrigin }); + } catch (error) { + log.error(`Failed to read CloudFront origins for site ${siteId}:`, error); + return badRequest(cleanupHeaderValue(error.message)); + } + }; + + // Read the cache behaviors (default + ordered) configured on a customer's CloudFront + // distribution so the wizard's "Review routing" step can show how traffic is currently routed. + const getEdgeOptimizeBehaviors = async (context) => { + const { log, dataAccess, env } = context; + const { siteId } = context.params; + const { Site } = dataAccess; + const accountId = String(context.data?.accountId || '').replace(/\D/g, ''); + const externalId = String(context.data?.externalId || '').trim(); + const distributionId = String(context.data?.distributionId || '').trim(); + const roleName = env?.EDGE_OPTIMIZE_ROLE_NAME || undefined; + + if (accountId.length !== 12) { + return badRequest('accountId must be a 12-digit AWS account ID'); + } + if (!hasText(externalId)) { + return badRequest('externalId is required'); + } + if (!hasText(distributionId)) { + return badRequest('distributionId is required'); + } + + try { + const { error } = await gateEdgeOptimizeWizard(siteId, Site, 'read CloudFront behaviors'); + if (error) { + return error; + } + + const { credentials } = await assumeConnectorRole({ accountId, externalId, roleName }); + const { defaultCacheBehavior, cacheBehaviors } = await getDistributionConfig( + credentials, + distributionId, + ); + const behaviors = []; + if (defaultCacheBehavior) { + behaviors.push({ ...defaultCacheBehavior, isDefault: true }); + } + cacheBehaviors.forEach((behavior) => behaviors.push({ ...behavior, isDefault: false })); + return ok({ behaviors }); + } catch (error) { + log.error(`Failed to read CloudFront behaviors for site ${siteId}:`, error); + return badRequest(cleanupHeaderValue(error.message)); + } + }; + return { getEdgeOptimizeBootstrapUrl, connectEdgeOptimize, getEdgeOptimizeDistributions, + checkEdgeOptimizePrerequisites, + getEdgeOptimizeOrigins, + getEdgeOptimizeBehaviors, getLlmoSheetData, queryLlmoSheetData, getLlmoGlobalSheetData, diff --git a/src/routes/index.js b/src/routes/index.js index 03bbb0a3f4..53ee361b15 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -483,6 +483,9 @@ export default function getRouteHandlers( 'POST /sites/:siteId/llmo/edge-optimize-bootstrap-url': llmoController.getEdgeOptimizeBootstrapUrl, 'POST /sites/:siteId/llmo/edge-optimize/connect': llmoController.connectEdgeOptimize, 'POST /sites/:siteId/llmo/edge-optimize/distributions': llmoController.getEdgeOptimizeDistributions, + 'POST /sites/:siteId/llmo/edge-optimize/prerequisites': llmoController.checkEdgeOptimizePrerequisites, + 'POST /sites/:siteId/llmo/edge-optimize/origins': llmoController.getEdgeOptimizeOrigins, + 'POST /sites/:siteId/llmo/edge-optimize/behaviors': llmoController.getEdgeOptimizeBehaviors, 'GET /sites/:siteId/llmo/strategy': llmoController.getStrategy, 'PUT /sites/:siteId/llmo/strategy': llmoController.saveStrategy, 'GET /sites/:siteId/llmo/edge-optimize-status': llmoController.checkEdgeOptimizeStatus, diff --git a/src/routes/required-capabilities.js b/src/routes/required-capabilities.js index ef7572de15..75ff5cb37a 100644 --- a/src/routes/required-capabilities.js +++ b/src/routes/required-capabilities.js @@ -130,6 +130,9 @@ export const INTERNAL_ROUTES = [ 'POST /sites/:siteId/llmo/edge-optimize-bootstrap-url', 'POST /sites/:siteId/llmo/edge-optimize/connect', 'POST /sites/:siteId/llmo/edge-optimize/distributions', + 'POST /sites/:siteId/llmo/edge-optimize/prerequisites', + 'POST /sites/:siteId/llmo/edge-optimize/origins', + 'POST /sites/:siteId/llmo/edge-optimize/behaviors', 'PUT /sites/:siteId/llmo/opportunities-reviewed', // PLG onboarding - IMS token auth, self-service flow, not S2S diff --git a/src/support/edge-optimize.js b/src/support/edge-optimize.js index 6507ab5aa1..cd6ee7a69d 100644 --- a/src/support/edge-optimize.js +++ b/src/support/edge-optimize.js @@ -11,7 +11,11 @@ */ import { STSClient, AssumeRoleCommand } from '@aws-sdk/client-sts'; -import { CloudFrontClient, ListDistributionsCommand } from '@aws-sdk/client-cloudfront'; +import { + CloudFrontClient, + ListDistributionsCommand, + GetDistributionConfigCommand, +} from '@aws-sdk/client-cloudfront'; import { hasText } from '@adobe/spacecat-shared-utils'; // CloudFront is a global service; its control plane lives in us-east-1. @@ -94,3 +98,47 @@ export async function listCloudFrontDistributions(credentials, region = EDGE_OPT comment: dist.Comment || '', })); } + +/** + * Fetch a single CloudFront distribution's configuration using assumed-role credentials. + * + * Returns the parsed origins, default cache behavior, and ordered cache behaviors projected to + * the fields the wizard needs to inspect routing. Read-only — uses GetDistributionConfig. + * + * @param {object} credentials - temporary credentials from {@link assumeConnectorRole}. + * @param {string} distributionId - the CloudFront distribution ID. + * @param {string} [region] - CloudFront control-plane region. + * @returns {Promise<{origins: Array, defaultCacheBehavior: object|null, + * cacheBehaviors: Array}>} + */ +export async function getDistributionConfig( + credentials, + distributionId, + region = EDGE_OPTIMIZE_REGION, +) { + if (!hasText(distributionId)) { + throw new Error('distributionId is required'); + } + const client = new CloudFrontClient({ region, credentials }); + const response = await client.send(new GetDistributionConfigCommand({ Id: distributionId })); + const config = response?.DistributionConfig || {}; + + const origins = (config.Origins?.Items || []).map((origin) => ({ + id: origin.Id, + domainName: origin.DomainName, + originPath: origin.OriginPath || '', + })); + + const mapBehavior = (behavior) => ({ + pathPattern: behavior.PathPattern, + targetOriginId: behavior.TargetOriginId, + }); + + const defaultCacheBehavior = config.DefaultCacheBehavior + ? mapBehavior({ ...config.DefaultCacheBehavior, PathPattern: 'Default (*)' }) + : null; + + const cacheBehaviors = (config.CacheBehaviors?.Items || []).map(mapBehavior); + + return { origins, defaultCacheBehavior, cacheBehaviors }; +} diff --git a/test/controllers/llmo/llmo.test.js b/test/controllers/llmo/llmo.test.js index 54ac17fc92..cfe9c47c63 100644 --- a/test/controllers/llmo/llmo.test.js +++ b/test/controllers/llmo/llmo.test.js @@ -88,6 +88,7 @@ describe('LlmoController', () => { let updateModifiedByDetailsStub; let assumeConnectorRoleStub; let listCloudFrontDistributionsStub; + let getDistributionConfigStub; let mockTokowakaClient; let readStrategyStub; let writeStrategyStub; @@ -198,6 +199,7 @@ describe('LlmoController', () => { updateModifiedByDetailsStub = sinon.stub(); assumeConnectorRoleStub = sinon.stub(); listCloudFrontDistributionsStub = sinon.stub(); + getDistributionConfigStub = sinon.stub(); // Initialize mock TokowakaClient mockTokowakaClient = { @@ -269,6 +271,7 @@ describe('LlmoController', () => { '../../../src/support/edge-optimize.js': { assumeConnectorRole: (...args) => assumeConnectorRoleStub(...args), listCloudFrontDistributions: (...args) => listCloudFrontDistributionsStub(...args), + getDistributionConfig: (...args) => getDistributionConfigStub(...args), }, '@adobe/spacecat-shared-ims-client': { ImsClient: function MockImsClient() { @@ -7802,6 +7805,361 @@ describe('LlmoController', () => { }); }); + describe('checkEdgeOptimizePrerequisites', () => { + let prereqContext; + + beforeEach(() => { + assumeConnectorRoleStub = sinon.stub().resolves({ + roleArn: 'arn:aws:iam::120569600543:role/AdobeLLMOptimizerCloudFrontConnectorRole', + accountId: '120569600543', + credentials: { accessKeyId: 'AKIA', secretAccessKey: 'secret', sessionToken: 'token' }, + }); + listCloudFrontDistributionsStub = sinon.stub().resolves([]); + prereqContext = { + ...mockContext, + params: { siteId: TEST_SITE_ID }, + data: { accountId: '120569600543', externalId: '7ff9518a-cf59-40b4-aa53-68a3cb2e24a5' }, + env: {}, + }; + }); + + it('returns all checks ok when the role assumes and CloudFront is readable', async () => { + const result = await controller.checkEdgeOptimizePrerequisites(prereqContext); + + expect(result.status).to.equal(200); + const body = await result.json(); + expect(body.checks).to.deep.equal([ + { name: 'connectorRole', ok: true }, + { name: 'cloudFrontRead', ok: true }, + ]); + expect(assumeConnectorRoleStub.calledOnce).to.equal(true); + expect(listCloudFrontDistributionsStub.calledOnce).to.equal(true); + }); + + it('reports connectorRole false (not erroring) when the role is not assumable', async () => { + assumeConnectorRoleStub = sinon.stub().rejects(new Error('AccessDenied: cannot assume')); + + const result = await controller.checkEdgeOptimizePrerequisites(prereqContext); + + expect(result.status).to.equal(200); + const body = await result.json(); + expect(body.checks[0]).to.include({ name: 'connectorRole', ok: false }); + expect(body.checks[0].detail).to.include('AccessDenied'); + expect(body.checks[1]).to.include({ name: 'cloudFrontRead', ok: false }); + }); + + it('reports cloudFrontRead false (not erroring) when the list call fails', async () => { + listCloudFrontDistributionsStub = sinon.stub().rejects(new Error('AccessDenied: cloudfront:ListDistributions')); + + const result = await controller.checkEdgeOptimizePrerequisites(prereqContext); + + expect(result.status).to.equal(200); + const body = await result.json(); + expect(body.checks[0]).to.deep.equal({ name: 'connectorRole', ok: true }); + expect(body.checks[1]).to.include({ name: 'cloudFrontRead', ok: false }); + expect(body.checks[1].detail).to.include('ListDistributions'); + }); + + it('returns 400 for an invalid account id', async () => { + const result = await controller.checkEdgeOptimizePrerequisites({ ...prereqContext, data: { accountId: '123', externalId: 'ext' } }); + + expect(result.status).to.equal(400); + }); + + it('returns 400 when the external id is missing', async () => { + const result = await controller.checkEdgeOptimizePrerequisites({ ...prereqContext, data: { accountId: '120569600543' } }); + + expect(result.status).to.equal(400); + }); + + it('returns 404 when the site is not found', async () => { + mockDataAccess.Site.findById.resolves(null); + + const result = await controller.checkEdgeOptimizePrerequisites(prereqContext); + + expect(result.status).to.equal(404); + }); + + it('returns 403 when the user lacks access to the site', async () => { + const deniedController = controllerWithAccessDenied(mockContext); + + const result = await deniedController.checkEdgeOptimizePrerequisites(prereqContext); + + expect(result.status).to.equal(403); + }); + + it('returns 403 when the user is not an LLMO administrator', async () => { + const LlmoControllerNoAdmin = await esmock('../../../src/controllers/llmo/llmo.js', { + '../../../src/support/access-control-util.js': createMockAccessControlUtil(true, true, false), + '@adobe/spacecat-shared-http-utils': mockHttpUtils, + '../../../src/support/cached-response.js': mockCachedResponse, + ...getCommonMocks(), + }); + + const controllerNoAdmin = LlmoControllerNoAdmin(mockContext); + const result = await controllerNoAdmin.checkEdgeOptimizePrerequisites(prereqContext); + + expect(result.status).to.equal(403); + }); + + it('returns 400 when an unexpected error occurs', async () => { + mockDataAccess.Site.findById.rejects(new Error('db boom')); + + const result = await controller.checkEdgeOptimizePrerequisites(prereqContext); + + expect(result.status).to.equal(400); + const body = await result.json(); + expect(body.message).to.include('db boom'); + }); + }); + + describe('getEdgeOptimizeOrigins', () => { + let originsContext; + + beforeEach(() => { + assumeConnectorRoleStub = sinon.stub().resolves({ + roleArn: 'arn:aws:iam::120569600543:role/AdobeLLMOptimizerCloudFrontConnectorRole', + accountId: '120569600543', + credentials: { accessKeyId: 'AKIA', secretAccessKey: 'secret', sessionToken: 'token' }, + }); + getDistributionConfigStub = sinon.stub().resolves({ + origins: [ + { id: 'origin-aem', domainName: 'origin.example.com', originPath: '/content' }, + ], + defaultCacheBehavior: { pathPattern: 'Default (*)', targetOriginId: 'origin-aem' }, + cacheBehaviors: [], + }); + originsContext = { + ...mockContext, + params: { siteId: TEST_SITE_ID }, + data: { + accountId: '120569600543', + externalId: '7ff9518a-cf59-40b4-aa53-68a3cb2e24a5', + distributionId: 'E2EXAMPLE123', + }, + env: {}, + }; + }); + + it('returns the origins and hasEdgeOptimizeOrigin false when none match', async () => { + const result = await controller.getEdgeOptimizeOrigins(originsContext); + + expect(result.status).to.equal(200); + const body = await result.json(); + expect(body.origins).to.have.length(1); + expect(body.origins[0].id).to.equal('origin-aem'); + expect(body.hasEdgeOptimizeOrigin).to.equal(false); + expect(assumeConnectorRoleStub.calledOnce).to.equal(true); + expect(getDistributionConfigStub.calledOnceWith(sinon.match.any, 'E2EXAMPLE123')).to.equal(true); + }); + + it('detects an Edge Optimize origin by id', async () => { + getDistributionConfigStub = sinon.stub().resolves({ + origins: [ + { id: 'EdgeOptimizeOrigin', domainName: 'something.example.com', originPath: '' }, + ], + defaultCacheBehavior: null, + cacheBehaviors: [], + }); + + const result = await controller.getEdgeOptimizeOrigins(originsContext); + + const body = await result.json(); + expect(body.hasEdgeOptimizeOrigin).to.equal(true); + }); + + it('detects an Edge Optimize origin by domain', async () => { + getDistributionConfigStub = sinon.stub().resolves({ + origins: [ + { id: 'custom', domainName: 'live.edgeoptimize.net', originPath: '' }, + ], + defaultCacheBehavior: null, + cacheBehaviors: [], + }); + + const result = await controller.getEdgeOptimizeOrigins(originsContext); + + const body = await result.json(); + expect(body.hasEdgeOptimizeOrigin).to.equal(true); + }); + + it('returns 400 for an invalid account id', async () => { + const result = await controller.getEdgeOptimizeOrigins({ ...originsContext, data: { ...originsContext.data, accountId: '123' } }); + + expect(result.status).to.equal(400); + }); + + it('returns 400 when the external id is missing', async () => { + const result = await controller.getEdgeOptimizeOrigins({ ...originsContext, data: { accountId: '120569600543', distributionId: 'E2EXAMPLE123' } }); + + expect(result.status).to.equal(400); + }); + + it('returns 400 when the distributionId is missing', async () => { + const result = await controller.getEdgeOptimizeOrigins({ ...originsContext, data: { accountId: '120569600543', externalId: 'ext' } }); + + expect(result.status).to.equal(400); + const body = await result.json(); + expect(body.message).to.include('distributionId'); + }); + + it('returns 400 when the AWS call fails', async () => { + getDistributionConfigStub = sinon.stub().rejects(new Error('GetDistributionConfig failed')); + + const result = await controller.getEdgeOptimizeOrigins(originsContext); + + expect(result.status).to.equal(400); + const body = await result.json(); + expect(body.message).to.include('GetDistributionConfig failed'); + }); + + it('returns 404 when the site is not found', async () => { + mockDataAccess.Site.findById.resolves(null); + + const result = await controller.getEdgeOptimizeOrigins(originsContext); + + expect(result.status).to.equal(404); + }); + + it('returns 403 when the user lacks access to the site', async () => { + const deniedController = controllerWithAccessDenied(mockContext); + + const result = await deniedController.getEdgeOptimizeOrigins(originsContext); + + expect(result.status).to.equal(403); + }); + + it('returns 403 when the user is not an LLMO administrator', async () => { + const LlmoControllerNoAdmin = await esmock('../../../src/controllers/llmo/llmo.js', { + '../../../src/support/access-control-util.js': createMockAccessControlUtil(true, true, false), + '@adobe/spacecat-shared-http-utils': mockHttpUtils, + '../../../src/support/cached-response.js': mockCachedResponse, + ...getCommonMocks(), + }); + + const controllerNoAdmin = LlmoControllerNoAdmin(mockContext); + const result = await controllerNoAdmin.getEdgeOptimizeOrigins(originsContext); + + expect(result.status).to.equal(403); + }); + }); + + describe('getEdgeOptimizeBehaviors', () => { + let behaviorsContext; + + beforeEach(() => { + assumeConnectorRoleStub = sinon.stub().resolves({ + roleArn: 'arn:aws:iam::120569600543:role/AdobeLLMOptimizerCloudFrontConnectorRole', + accountId: '120569600543', + credentials: { accessKeyId: 'AKIA', secretAccessKey: 'secret', sessionToken: 'token' }, + }); + getDistributionConfigStub = sinon.stub().resolves({ + origins: [], + defaultCacheBehavior: { pathPattern: 'Default (*)', targetOriginId: 'origin-aem' }, + cacheBehaviors: [ + { pathPattern: '/api/*', targetOriginId: 'origin-api' }, + ], + }); + behaviorsContext = { + ...mockContext, + params: { siteId: TEST_SITE_ID }, + data: { + accountId: '120569600543', + externalId: '7ff9518a-cf59-40b4-aa53-68a3cb2e24a5', + distributionId: 'E2EXAMPLE123', + }, + env: {}, + }; + }); + + it('returns the default behavior plus ordered cache behaviors', async () => { + const result = await controller.getEdgeOptimizeBehaviors(behaviorsContext); + + expect(result.status).to.equal(200); + const body = await result.json(); + expect(body.behaviors).to.deep.equal([ + { pathPattern: 'Default (*)', targetOriginId: 'origin-aem', isDefault: true }, + { pathPattern: '/api/*', targetOriginId: 'origin-api', isDefault: false }, + ]); + expect(getDistributionConfigStub.calledOnceWith(sinon.match.any, 'E2EXAMPLE123')).to.equal(true); + }); + + it('omits the default entry when the distribution has none', async () => { + getDistributionConfigStub = sinon.stub().resolves({ + origins: [], + defaultCacheBehavior: null, + cacheBehaviors: [{ pathPattern: '/api/*', targetOriginId: 'origin-api' }], + }); + + const result = await controller.getEdgeOptimizeBehaviors(behaviorsContext); + + const body = await result.json(); + expect(body.behaviors).to.deep.equal([ + { pathPattern: '/api/*', targetOriginId: 'origin-api', isDefault: false }, + ]); + }); + + it('returns 400 for an invalid account id', async () => { + const result = await controller.getEdgeOptimizeBehaviors({ ...behaviorsContext, data: { ...behaviorsContext.data, accountId: '123' } }); + + expect(result.status).to.equal(400); + }); + + it('returns 400 when the external id is missing', async () => { + const result = await controller.getEdgeOptimizeBehaviors({ ...behaviorsContext, data: { accountId: '120569600543', distributionId: 'E2EXAMPLE123' } }); + + expect(result.status).to.equal(400); + }); + + it('returns 400 when the distributionId is missing', async () => { + const result = await controller.getEdgeOptimizeBehaviors({ ...behaviorsContext, data: { accountId: '120569600543', externalId: 'ext' } }); + + expect(result.status).to.equal(400); + const body = await result.json(); + expect(body.message).to.include('distributionId'); + }); + + it('returns 400 when the AWS call fails', async () => { + getDistributionConfigStub = sinon.stub().rejects(new Error('GetDistributionConfig failed')); + + const result = await controller.getEdgeOptimizeBehaviors(behaviorsContext); + + expect(result.status).to.equal(400); + const body = await result.json(); + expect(body.message).to.include('GetDistributionConfig failed'); + }); + + it('returns 404 when the site is not found', async () => { + mockDataAccess.Site.findById.resolves(null); + + const result = await controller.getEdgeOptimizeBehaviors(behaviorsContext); + + expect(result.status).to.equal(404); + }); + + it('returns 403 when the user lacks access to the site', async () => { + const deniedController = controllerWithAccessDenied(mockContext); + + const result = await deniedController.getEdgeOptimizeBehaviors(behaviorsContext); + + expect(result.status).to.equal(403); + }); + + it('returns 403 when the user is not an LLMO administrator', async () => { + const LlmoControllerNoAdmin = await esmock('../../../src/controllers/llmo/llmo.js', { + '../../../src/support/access-control-util.js': createMockAccessControlUtil(true, true, false), + '@adobe/spacecat-shared-http-utils': mockHttpUtils, + '../../../src/support/cached-response.js': mockCachedResponse, + ...getCommonMocks(), + }); + + const controllerNoAdmin = LlmoControllerNoAdmin(mockContext); + const result = await controllerNoAdmin.getEdgeOptimizeBehaviors(behaviorsContext); + + expect(result.status).to.equal(403); + }); + }); + describe('getStrategy', () => { const mockStrategyData = { opportunities: [ diff --git a/test/routes/index.test.js b/test/routes/index.test.js index 1c3579579a..675e6fce7c 100755 --- a/test/routes/index.test.js +++ b/test/routes/index.test.js @@ -329,6 +329,9 @@ describe('getRouteHandlers', () => { createOrUpdateEdgeConfig: () => null, connectEdgeOptimize: () => null, getEdgeOptimizeDistributions: () => null, + checkEdgeOptimizePrerequisites: () => null, + getEdgeOptimizeOrigins: () => null, + getEdgeOptimizeBehaviors: () => null, getEdgeConfig: () => null, createOrUpdateStageEdgeConfig: () => null, checkEdgeOptimizeStatus: () => null, @@ -1066,6 +1069,9 @@ describe('getRouteHandlers', () => { 'POST /sites/:siteId/llmo/edge-optimize-bootstrap-url', 'POST /sites/:siteId/llmo/edge-optimize/connect', 'POST /sites/:siteId/llmo/edge-optimize/distributions', + 'POST /sites/:siteId/llmo/edge-optimize/prerequisites', + 'POST /sites/:siteId/llmo/edge-optimize/origins', + 'POST /sites/:siteId/llmo/edge-optimize/behaviors', 'GET /sites/:siteId/llmo/edge-optimize-status', 'GET /sites/:siteId/llmo/probes/edge-optimize', 'GET /sites/:siteId/llmo/strategy', diff --git a/test/support/edge-optimize.test.js b/test/support/edge-optimize.test.js index 6dcbf5ca55..96d3508e22 100644 --- a/test/support/edge-optimize.test.js +++ b/test/support/edge-optimize.test.js @@ -40,6 +40,9 @@ describe('edge-optimize support', () => { ListDistributionsCommand: function ListDistributionsCommand(input) { this.input = input; }, + GetDistributionConfigCommand: function GetDistributionConfigCommand(input) { + this.input = input; + }, }, }); }); @@ -178,4 +181,63 @@ describe('edge-optimize support', () => { expect(result[0].enabled).to.equal(false); }); }); + + describe('getDistributionConfig', () => { + it('maps origins, default cache behavior, and ordered cache behaviors', async () => { + cfSendStub.resolves({ + DistributionConfig: { + Origins: { + Items: [ + { Id: 'origin-aem', DomainName: 'origin.example.com', OriginPath: '/content' }, + { Id: 'EdgeOptimizeOrigin', DomainName: 'live.edgeoptimize.net' }, + ], + }, + DefaultCacheBehavior: { TargetOriginId: 'origin-aem' }, + CacheBehaviors: { + Items: [ + { PathPattern: '/api/*', TargetOriginId: 'origin-aem' }, + ], + }, + }, + }); + + const result = await edgeOptimize.getDistributionConfig({}, 'E2EXAMPLE'); + + expect(cfSendStub.calledOnce).to.equal(true); + expect(cfSendStub.firstCall.args[0].input).to.deep.equal({ Id: 'E2EXAMPLE' }); + expect(result.origins).to.deep.equal([ + { id: 'origin-aem', domainName: 'origin.example.com', originPath: '/content' }, + { id: 'EdgeOptimizeOrigin', domainName: 'live.edgeoptimize.net', originPath: '' }, + ]); + expect(result.defaultCacheBehavior).to.deep.equal({ + pathPattern: 'Default (*)', + targetOriginId: 'origin-aem', + }); + expect(result.cacheBehaviors).to.deep.equal([ + { pathPattern: '/api/*', targetOriginId: 'origin-aem' }, + ]); + }); + + it('defaults to empty collections when the config is sparse', async () => { + cfSendStub.resolves({ DistributionConfig: {} }); + + const result = await edgeOptimize.getDistributionConfig({}, 'E2EXAMPLE'); + + expect(result.origins).to.deep.equal([]); + expect(result.defaultCacheBehavior).to.equal(null); + expect(result.cacheBehaviors).to.deep.equal([]); + }); + + it('throws when the distribution id is missing', async () => { + let error; + try { + await edgeOptimize.getDistributionConfig({}, ''); + } catch (e) { + error = e; + } + expect(error).to.be.an('error'); + expect(error.message).to.include('distributionId'); + expect(cfSendStub.called).to.equal(false); + }); + }); }); From bdab820b92b393f849345943caa37dfe9795a485 Mon Sep 17 00:00:00 2001 From: Akash Bhardwaj Date: Sat, 20 Jun 2026 19:45:26 +0530 Subject: [PATCH 11/20] feat(llmo): add CloudFront edge-optimize mutation wizard steps Sync per-step endpoints that assume the customer connector role server-side and perform one CloudFront write each (no AWS creds in the browser): - create-origin: add the EdgeOptimize_Origin (env EDGE_OPTIMIZE_ORIGIN_DOMAIN, default dev.edgeoptimize.net) via UpdateDistribution (ETag IfMatch). - create-function: create/update + publish the edgeoptimize-routing CF Function (bot-routing JS ported from the standalone wizard). - apply-cache: add EO headers to the behavior's custom cache policy (common path; legacy ForwardedValues / managed-policy clone left as TODO). - create-lambda: create exec role (bounded IAM-propagation retry) + the edgeoptimize-origin Lambda@Edge and publish a version. - apply-associations: wire the function (viewer-request) + Lambda (origin- request/response) onto the selected behavior. - verify: server-side bot-vs-human probe; passed requires x-edgeoptimize-request-id (x-edgeoptimize-fo = failover, not success). Adds @aws-sdk/client-iam + @aws-sdk/client-lambda. All AWS ops use ETag read-modify-write. Embedded function/Lambda code ported verbatim from the connect-aws-wizard; Lambda code inlined per the helix-deploy bundling rule. Mocked-SDK unit tests for all 6 support fns + handlers; routes/capabilities + OpenAPI updated. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/openapi/api.yaml | 12 + docs/openapi/llmo-api.yaml | 395 +++++++++++++++ package-lock.json | 212 +++++++- package.json | 2 + src/controllers/llmo/llmo.js | 271 ++++++++++ src/routes/index.js | 6 + src/routes/required-capabilities.js | 6 + src/support/edge-optimize.js | 761 ++++++++++++++++++++++++++++ test/controllers/llmo/llmo.test.js | 588 +++++++++++++++++++++ test/routes/index.test.js | 12 + test/support/edge-optimize.test.js | 506 +++++++++++++++++- 11 files changed, 2756 insertions(+), 15 deletions(-) diff --git a/docs/openapi/api.yaml b/docs/openapi/api.yaml index b51d813118..99663880df 100644 --- a/docs/openapi/api.yaml +++ b/docs/openapi/api.yaml @@ -635,6 +635,18 @@ paths: $ref: './llmo-api.yaml#/site-llmo-edge-optimize-origins' /sites/{siteId}/llmo/edge-optimize/behaviors: $ref: './llmo-api.yaml#/site-llmo-edge-optimize-behaviors' + /sites/{siteId}/llmo/edge-optimize/create-origin: + $ref: './llmo-api.yaml#/site-llmo-edge-optimize-create-origin' + /sites/{siteId}/llmo/edge-optimize/create-function: + $ref: './llmo-api.yaml#/site-llmo-edge-optimize-create-function' + /sites/{siteId}/llmo/edge-optimize/apply-cache: + $ref: './llmo-api.yaml#/site-llmo-edge-optimize-apply-cache' + /sites/{siteId}/llmo/edge-optimize/create-lambda: + $ref: './llmo-api.yaml#/site-llmo-edge-optimize-create-lambda' + /sites/{siteId}/llmo/edge-optimize/apply-associations: + $ref: './llmo-api.yaml#/site-llmo-edge-optimize-apply-associations' + /sites/{siteId}/llmo/edge-optimize/verify: + $ref: './llmo-api.yaml#/site-llmo-edge-optimize-verify' /sites/{siteId}/llmo/edge-optimize-status: $ref: './llmo-api.yaml#/llmo-edge-optimize-status' /sites/{siteId}/llmo/probes/edge-optimize: diff --git a/docs/openapi/llmo-api.yaml b/docs/openapi/llmo-api.yaml index 6465412fdd..00ab3d109c 100644 --- a/docs/openapi/llmo-api.yaml +++ b/docs/openapi/llmo-api.yaml @@ -2723,6 +2723,344 @@ site-llmo-edge-optimize-behaviors: security: - api_key: [ ] +site-llmo-edge-optimize-create-origin: + post: + tags: + - llmo + summary: Add the Edge Optimize origin to a CloudFront distribution + description: | + Used by the CloudFront "Deploy routing" wizard's "Create Edge Optimize origin" step. Assumes + the connector role and, if no Edge Optimize origin exists yet, adds a custom HTTPS origin + pointing at the Edge Optimize target domain via UpdateDistribution. Idempotent — returns + `created: false, alreadyExisted: true` when the origin is already present. (CloudFront deploy + propagates in the background; this call does not block on it.) + operationId: createEdgeOptimizeOrigin + parameters: + - $ref: './parameters.yaml#/siteId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/edge-optimize-distribution-request' + responses: + '200': + description: Whether the Edge Optimize origin was created or already existed. + content: + application/json: + schema: + type: object + required: + - created + - alreadyExisted + - originId + properties: + created: + type: boolean + alreadyExisted: + type: boolean + originId: + type: string + example: + created: true + alreadyExisted: false + originId: EdgeOptimize_Origin + '400': + $ref: './responses.yaml#/400' + '401': + $ref: './responses.yaml#/401' + '403': + $ref: './responses.yaml#/403' + '404': + $ref: './responses.yaml#/404' + '500': + $ref: './responses.yaml#/500' + security: + - api_key: [ ] + +site-llmo-edge-optimize-create-function: + post: + tags: + - llmo + summary: Create or update the Edge Optimize routing CloudFront Function + description: | + Used by the CloudFront "Deploy routing" wizard's "Create routing function" step. Assumes the + connector role, derives the distribution's default-behavior target origin, and creates (or + updates) the `edgeoptimize-routing` CloudFront Function, then publishes it to LIVE. Idempotent. + operationId: createEdgeOptimizeRoutingFunction + parameters: + - $ref: './parameters.yaml#/siteId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/edge-optimize-distribution-request' + responses: + '200': + description: The published routing function. + content: + application/json: + schema: + type: object + required: + - name + - created + - stage + properties: + name: + type: string + created: + type: boolean + stage: + type: string + example: + name: edgeoptimize-routing + created: true + stage: LIVE + '400': + $ref: './responses.yaml#/400' + '401': + $ref: './responses.yaml#/401' + '403': + $ref: './responses.yaml#/403' + '404': + $ref: './responses.yaml#/404' + '500': + $ref: './responses.yaml#/500' + security: + - api_key: [ ] + +site-llmo-edge-optimize-apply-cache: + post: + tags: + - llmo + summary: Forward the Edge Optimize headers in a behavior's cache policy + description: | + Used by the CloudFront "Deploy routing" wizard's "Apply cache headers" step. Assumes the + connector role and ensures the Edge Optimize routing headers + (`x-edgeoptimize-config`, `x-edgeoptimize-url`) are forwarded by the target behavior's + (custom) cache policy, setting the policy MinTTL to 0. Idempotent. + operationId: applyEdgeOptimizeCache + parameters: + - $ref: './parameters.yaml#/siteId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/edge-optimize-behavior-request' + responses: + '200': + description: Whether the cache policy was updated. + content: + application/json: + schema: + type: object + required: + - policyId + - updated + - alreadyForwarded + properties: + policyId: + type: string + updated: + type: boolean + alreadyForwarded: + type: boolean + example: + policyId: 1234abcd-12ab-34cd-56ef-1234567890ab + updated: true + alreadyForwarded: false + '400': + $ref: './responses.yaml#/400' + '401': + $ref: './responses.yaml#/401' + '403': + $ref: './responses.yaml#/403' + '404': + $ref: './responses.yaml#/404' + '500': + $ref: './responses.yaml#/500' + security: + - api_key: [ ] + +site-llmo-edge-optimize-create-lambda: + post: + tags: + - llmo + summary: Create or update the Edge Optimize Lambda@Edge function + description: | + Used by the CloudFront "Deploy routing" wizard's "Create Lambda@Edge function" step. Assumes + the connector role, ensures the `edgeoptimize-origin-role` execution role exists (with a + bounded propagation wait), creates (or updates) the `edgeoptimize-origin` Lambda function, and + publishes a numbered version (required for Lambda@Edge). Returns the versioned ARN the + "Associate" step needs. Idempotent. + operationId: createEdgeOptimizeLambda + parameters: + - $ref: './parameters.yaml#/siteId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/edge-optimize-connector-request' + responses: + '200': + description: The published Lambda@Edge function version. + content: + application/json: + schema: + type: object + required: + - functionArn + - versionArn + - version + - roleArn + - created + properties: + functionArn: + type: string + versionArn: + type: string + description: The published, versioned Lambda ARN (use this for associations). + version: + type: string + roleArn: + type: string + created: + type: boolean + example: + functionArn: arn:aws:lambda:us-east-1:682033462621:function:edgeoptimize-origin + versionArn: arn:aws:lambda:us-east-1:682033462621:function:edgeoptimize-origin:1 + version: '1' + roleArn: arn:aws:iam::682033462621:role/edgeoptimize-origin-role + created: true + '400': + $ref: './responses.yaml#/400' + '401': + $ref: './responses.yaml#/401' + '403': + $ref: './responses.yaml#/403' + '404': + $ref: './responses.yaml#/404' + '500': + $ref: './responses.yaml#/500' + security: + - api_key: [ ] + +site-llmo-edge-optimize-apply-associations: + post: + tags: + - llmo + summary: Associate the routing function and Lambda@Edge onto a behavior + description: | + Used by the CloudFront "Deploy routing" wizard's "Associate" step. Assumes the connector role + and wires the `edgeoptimize-routing` CloudFront Function (viewer-request) plus the + `edgeoptimize-origin` Lambda@Edge (origin-request and origin-response, versioned ARN) onto the + user-selected cache behavior via UpdateDistribution. Surfaces a clear error if a conflicting + viewer-request function is already associated. + operationId: applyEdgeOptimizeAssociations + parameters: + - $ref: './parameters.yaml#/siteId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/edge-optimize-associate-request' + responses: + '200': + description: The applied associations. + content: + application/json: + schema: + type: object + required: + - cfFunctionArn + - lambdaArn + properties: + cfFunctionArn: + type: string + lambdaArn: + type: string + example: + cfFunctionArn: arn:aws:cloudfront::682033462621:function/edgeoptimize-routing + lambdaArn: arn:aws:lambda:us-east-1:682033462621:function:edgeoptimize-origin:1 + '400': + $ref: './responses.yaml#/400' + '401': + $ref: './responses.yaml#/401' + '403': + $ref: './responses.yaml#/403' + '404': + $ref: './responses.yaml#/404' + '500': + $ref: './responses.yaml#/500' + security: + - api_key: [ ] + +site-llmo-edge-optimize-verify: + post: + tags: + - llmo + summary: Verify Edge Optimize routing end-to-end + description: | + Used by the CloudFront "Deploy routing" wizard's "Verify routing" step. Fetches the + distribution domain server-side as an agentic bot and as a human, then inspects the + `x-edgeoptimize-*` response headers. `passed` is true only when the bot response carries + `x-edgeoptimize-request-id` (page served from the Edge Optimize prerender cache) and the human + response is not optimized. `x-edgeoptimize-fo` means failover to origin — NOT success. + operationId: verifyEdgeOptimizeRouting + parameters: + - $ref: './parameters.yaml#/siteId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/edge-optimize-distribution-request' + responses: + '200': + description: The verification result (inspect `passed`). + content: + application/json: + schema: + type: object + required: + - passed + properties: + passed: + type: boolean + requestId: + type: string + details: + type: object + description: The bot and human probe results (status + x-edgeoptimize-* headers). + example: + passed: true + requestId: req-123 + details: + bot: + status: 200 + headers: + x-edgeoptimize-request-id: req-123 + human: + status: 200 + headers: {} + '400': + $ref: './responses.yaml#/400' + '401': + $ref: './responses.yaml#/401' + '403': + $ref: './responses.yaml#/403' + '404': + $ref: './responses.yaml#/404' + '500': + $ref: './responses.yaml#/500' + security: + - api_key: [ ] + edge-optimize-connector-request: type: object required: @@ -2760,6 +3098,63 @@ edge-optimize-distribution-request: description: The CloudFront distribution ID to inspect. example: E2EXAMPLE123 +edge-optimize-behavior-request: + type: object + required: + - accountId + - externalId + - distributionId + properties: + accountId: + type: string + description: The 12-digit AWS account ID hosting the customer's CloudFront distribution. + pattern: '^[0-9]{12}$' + example: '682033462621' + externalId: + type: string + description: Per-session external ID baked into the connector role's trust policy. + example: '7ff9518a-cf59-40b4-aa53-68a3cb2e24a5' + distributionId: + type: string + description: The CloudFront distribution ID to modify. + example: E2EXAMPLE123 + pathPattern: + type: string + description: The cache behavior to target; use `default` for the default behavior. + default: default + example: /api/* + +edge-optimize-associate-request: + type: object + required: + - accountId + - externalId + - distributionId + - lambdaVersionArn + properties: + accountId: + type: string + description: The 12-digit AWS account ID hosting the customer's CloudFront distribution. + pattern: '^[0-9]{12}$' + example: '682033462621' + externalId: + type: string + description: Per-session external ID baked into the connector role's trust policy. + example: '7ff9518a-cf59-40b4-aa53-68a3cb2e24a5' + distributionId: + type: string + description: The CloudFront distribution ID to modify. + example: E2EXAMPLE123 + pathPattern: + type: string + description: The cache behavior to wire; use `default` for the default behavior. + default: default + example: /api/* + lambdaVersionArn: + type: string + description: The published, versioned Lambda@Edge ARN from the create-lambda step. + example: arn:aws:lambda:us-east-1:682033462621:function:edgeoptimize-origin:1 + llmo-probe-edge-optimize: get: tags: diff --git a/package-lock.json b/package-lock.json index 443e97eb2f..586a60c0bb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,6 +35,8 @@ "@adobe/spacecat-shared-utils": "1.119.2", "@adobe/spacecat-shared-vault-secrets": "1.3.5", "@aws-sdk/client-cloudfront": "3.1045.0", + "@aws-sdk/client-iam": "3.1045.0", + "@aws-sdk/client-lambda": "3.1045.0", "@aws-sdk/client-s3": "3.1045.0", "@aws-sdk/client-secrets-manager": "3.1045.0", "@aws-sdk/client-sfn": "3.1045.0", @@ -248,6 +250,62 @@ "@adobe/helix-shared-async": "2.0.2" } }, + "node_modules/@adobe/helix-deploy/node_modules/@aws-sdk/client-lambda": { + "version": "3.1042.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-lambda/-/client-lambda-3.1042.0.tgz", + "integrity": "sha512-g2NJMMGjQ18LvPapz75s8UzRaxJ2P5bF2Y025/eyVuBtzdCuW6XYoJxP29Tp39BzYgFb+HEtwATyZss/V6KdZg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.8", + "@aws-sdk/credential-provider-node": "^3.972.39", + "@aws-sdk/middleware-host-header": "^3.972.10", + "@aws-sdk/middleware-logger": "^3.972.10", + "@aws-sdk/middleware-recursion-detection": "^3.972.11", + "@aws-sdk/middleware-user-agent": "^3.972.38", + "@aws-sdk/region-config-resolver": "^3.972.13", + "@aws-sdk/types": "^3.973.8", + "@aws-sdk/util-endpoints": "^3.996.8", + "@aws-sdk/util-user-agent-browser": "^3.972.10", + "@aws-sdk/util-user-agent-node": "^3.973.24", + "@smithy/config-resolver": "^4.4.17", + "@smithy/core": "^3.23.17", + "@smithy/eventstream-serde-browser": "^4.2.14", + "@smithy/eventstream-serde-config-resolver": "^4.3.14", + "@smithy/eventstream-serde-node": "^4.2.14", + "@smithy/fetch-http-handler": "^5.3.17", + "@smithy/hash-node": "^4.2.14", + "@smithy/invalid-dependency": "^4.2.14", + "@smithy/middleware-content-length": "^4.2.14", + "@smithy/middleware-endpoint": "^4.4.32", + "@smithy/middleware-retry": "^4.5.7", + "@smithy/middleware-serde": "^4.2.20", + "@smithy/middleware-stack": "^4.2.14", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/node-http-handler": "^4.6.1", + "@smithy/protocol-http": "^5.3.14", + "@smithy/smithy-client": "^4.12.13", + "@smithy/types": "^4.14.1", + "@smithy/url-parser": "^4.2.14", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-body-length-browser": "^4.2.2", + "@smithy/util-body-length-node": "^4.2.3", + "@smithy/util-defaults-mode-browser": "^4.3.49", + "@smithy/util-defaults-mode-node": "^4.2.54", + "@smithy/util-endpoints": "^3.4.2", + "@smithy/util-middleware": "^4.2.14", + "@smithy/util-retry": "^4.3.6", + "@smithy/util-stream": "^4.5.25", + "@smithy/util-utf8": "^4.2.2", + "@smithy/util-waiter": "^4.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@adobe/helix-deploy/node_modules/@aws-sdk/client-s3": { "version": "3.1042.0", "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1042.0.tgz", @@ -10602,11 +10660,150 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/client-iam": { + "version": "3.1045.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-iam/-/client-iam-3.1045.0.tgz", + "integrity": "sha512-1kCDjvkOUyZ7MxQu2ybgXxYDW+8PG+cHjVCk0IXZwYy5R2dhAeHLLlDGJB9jA/KeSh6+XcN5uMlApqJGP1zbUg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.8", + "@aws-sdk/credential-provider-node": "^3.972.39", + "@aws-sdk/middleware-host-header": "^3.972.10", + "@aws-sdk/middleware-logger": "^3.972.10", + "@aws-sdk/middleware-recursion-detection": "^3.972.11", + "@aws-sdk/middleware-user-agent": "^3.972.38", + "@aws-sdk/region-config-resolver": "^3.972.13", + "@aws-sdk/types": "^3.973.8", + "@aws-sdk/util-endpoints": "^3.996.8", + "@aws-sdk/util-user-agent-browser": "^3.972.10", + "@aws-sdk/util-user-agent-node": "^3.973.24", + "@smithy/config-resolver": "^4.4.17", + "@smithy/core": "^3.23.17", + "@smithy/fetch-http-handler": "^5.3.17", + "@smithy/hash-node": "^4.2.14", + "@smithy/invalid-dependency": "^4.2.14", + "@smithy/middleware-content-length": "^4.2.14", + "@smithy/middleware-endpoint": "^4.4.32", + "@smithy/middleware-retry": "^4.5.7", + "@smithy/middleware-serde": "^4.2.20", + "@smithy/middleware-stack": "^4.2.14", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/node-http-handler": "^4.6.1", + "@smithy/protocol-http": "^5.3.14", + "@smithy/smithy-client": "^4.12.13", + "@smithy/types": "^4.14.1", + "@smithy/url-parser": "^4.2.14", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-body-length-browser": "^4.2.2", + "@smithy/util-body-length-node": "^4.2.3", + "@smithy/util-defaults-mode-browser": "^4.3.49", + "@smithy/util-defaults-mode-node": "^4.2.54", + "@smithy/util-endpoints": "^3.4.2", + "@smithy/util-middleware": "^4.2.14", + "@smithy/util-retry": "^4.3.6", + "@smithy/util-utf8": "^4.2.2", + "@smithy/util-waiter": "^4.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/middleware-host-header": { + "version": "3.972.23", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.23.tgz", + "integrity": "sha512-sWeojvaTI86wGRIt5HQb9o50V6AExi+NV3VrH2AwBlzV47a+m4JAejaC8CObaLXGVqSRqGt1B9AGYONp+NcTXg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.22", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/middleware-logger": { + "version": "3.972.22", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.972.22.tgz", + "integrity": "sha512-opYE2yUoQKJJz5QrGylnZeaBRmUaRzUSxlW40cX4LnmwO5XI61IWC3k/LDbJwF15AvmlNbome+glhIlFJzCWJw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.22", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/middleware-recursion-detection": { + "version": "3.972.24", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.972.24.tgz", + "integrity": "sha512-M0gO9RgAppZx7yDvFRB1pdnyzd34Gnt/JjpT9HoMZxxDYnAkYNa3eabbjsf2A6ffxDFAvZKFdkjthrQxlQgcRg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.22", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/region-config-resolver": { + "version": "3.972.26", + "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.972.26.tgz", + "integrity": "sha512-wDR6i4YBvJTGGElCQlXQd42f6AraatbU9ckvrjsKu1wFBwH0vTUBxFo6kqRWfuleKhDkMqSkQujCL1eWbFFLtw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.22", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/types": { + "version": "3.973.13", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.13.tgz", + "integrity": "sha512-pEHZqRkAlHfnfAU9tK+WpKv/gBNjGJrHMgA3A0iYRGyswBS2t0pfez+lWlwktb3Bqa0ovh7w/QJTFwp3fDxLNg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/util-endpoints": { + "version": "3.996.21", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.21.tgz", + "integrity": "sha512-mZbKi9+3h3BtGkph4eYPMCoxiyYHM4q1rYZILfCE4a1uszKaXCRrI0n8nPqcOQDGc2fTwpXm7jLAs/sok4MxVg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.22", + "@smithy/core": "^3.24.6", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/util-user-agent-browser": { + "version": "3.972.23", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.972.23.tgz", + "integrity": "sha512-5jF9hUGr6vplVanji97KRmbbTUlStXOp/WSz7xArD9fIWxId7tZoq0fPVRNJC5xOuaGzkwO3jHZn8BicrwfwSw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.22", + "tslib": "^2.6.2" + } + }, "node_modules/@aws-sdk/client-lambda": { - "version": "3.1042.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-lambda/-/client-lambda-3.1042.0.tgz", - "integrity": "sha512-g2NJMMGjQ18LvPapz75s8UzRaxJ2P5bF2Y025/eyVuBtzdCuW6XYoJxP29Tp39BzYgFb+HEtwATyZss/V6KdZg==", - "dev": true, + "version": "3.1045.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-lambda/-/client-lambda-3.1045.0.tgz", + "integrity": "sha512-9EDPinh03XanJQssTBdTY+9E7PkyQ0NLLJiaOAM71/g4DI+0OZboGqhX7KKizwUGqKkj0paKEAwgWaMLgEkQFQ==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", @@ -10662,7 +10859,6 @@ "version": "3.972.13", "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.13.tgz", "integrity": "sha512-EA3+u2LD3kGcfRNmCSjyJuzX4XvG4zYv57i4ZksH+1IEciuSyHQGvzivEz7vZ+jbRPdAAe7WWKy/4M8InCKDcw==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/core": "^3.974.12", @@ -10676,7 +10872,6 @@ "version": "3.972.12", "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.972.12.tgz", "integrity": "sha512-NxB2dS4/mV3380hNkC72TkhMaLLjWGGBeTAEucqlJptVVovTbNmQWZLwaMC74ICo9NZHmFiBVVTHzDfAh/3y6Q==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/core": "^3.974.12", @@ -10690,7 +10885,6 @@ "version": "3.972.14", "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.972.14.tgz", "integrity": "sha512-bqL+upATpOJ/7px4IVfMVxcM6Lyt9uRizmEx3mNg4N6+IQlnOaYXXOJ4TNX6P0mKPPW0lwn9ZW8QEhXwQuCH9A==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/core": "^3.974.12", @@ -10704,7 +10898,6 @@ "version": "3.972.16", "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.972.16.tgz", "integrity": "sha512-/YaivCvKUkEeMN9VTKBSvBn5w/4osAM1YboM58DKaLF/vqFGf/FdJCLmppqiPPJWZaXcASqByVjc3evE7KHKdA==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/core": "^3.974.12", @@ -10718,7 +10911,6 @@ "version": "3.973.8", "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.8.tgz", "integrity": "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.14.1", @@ -10732,7 +10924,6 @@ "version": "3.996.11", "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.11.tgz", "integrity": "sha512-BUMJ6VoL54r6Udj/wKy8uKRIndL04rGbaS/wTIV0dM1ewxSrR8yARBHdvZKQsK55ZSW2JrmAPk3KP15kBDxJMw==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/core": "^3.974.12", @@ -10747,7 +10938,6 @@ "version": "3.972.13", "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.972.13.tgz", "integrity": "sha512-wfk9ZdVwh187gdGXB1EyAoprwjSMt/bSfVtva+OaZx+LyNdKD7smlZf611yMd42UpfQ9vaS8NOftjSajgpdd+w==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/core": "^3.974.12", diff --git a/package.json b/package.json index 05d5019f10..1199574eb5 100644 --- a/package.json +++ b/package.json @@ -99,6 +99,8 @@ "@adobe/spacecat-shared-utils": "1.119.2", "@adobe/spacecat-shared-vault-secrets": "1.3.5", "@aws-sdk/client-cloudfront": "3.1045.0", + "@aws-sdk/client-iam": "3.1045.0", + "@aws-sdk/client-lambda": "3.1045.0", "@aws-sdk/client-s3": "3.1045.0", "@aws-sdk/client-secrets-manager": "3.1045.0", "@aws-sdk/client-sfn": "3.1045.0", diff --git a/src/controllers/llmo/llmo.js b/src/controllers/llmo/llmo.js index bafc9b4636..39d2ecb30e 100644 --- a/src/controllers/llmo/llmo.js +++ b/src/controllers/llmo/llmo.js @@ -37,6 +37,12 @@ import { assumeConnectorRole, listCloudFrontDistributions, getDistributionConfig, + createEdgeOptimizeOrigin, + createEdgeOptimizeRoutingFunction, + applyEdgeOptimizeCacheHeaders, + createEdgeOptimizeLambda, + applyEdgeOptimizeAssociations, + verifyEdgeOptimizeRouting, } from '../../support/edge-optimize.js'; import { UnauthorizedProductError } from '../../support/errors.js'; import { cachedOk } from '../../support/cached-response.js'; @@ -2386,6 +2392,265 @@ function LlmoController(ctx) { } }; + // Add the Edge Optimize origin to the selected distribution (mutation). Idempotent: returns + // { created: false, alreadyExisted: true } when the origin is already present. Used by the + // wizard's "Create Edge Optimize origin" step. + const createEdgeOptimizeOriginHandler = async (context) => { + const { log, dataAccess, env } = context; + const { siteId } = context.params; + const { Site } = dataAccess; + const accountId = String(context.data?.accountId || '').replace(/\D/g, ''); + const externalId = String(context.data?.externalId || '').trim(); + const distributionId = String(context.data?.distributionId || '').trim(); + const roleName = env?.EDGE_OPTIMIZE_ROLE_NAME || undefined; + const originDomain = env?.EDGE_OPTIMIZE_ORIGIN_DOMAIN || 'dev.edgeoptimize.net'; + + if (accountId.length !== 12) { + return badRequest('accountId must be a 12-digit AWS account ID'); + } + if (!hasText(externalId)) { + return badRequest('externalId is required'); + } + if (!hasText(distributionId)) { + return badRequest('distributionId is required'); + } + + try { + const { error } = await gateEdgeOptimizeWizard(siteId, Site, 'create the edge optimize origin'); + if (error) { + return error; + } + + const { credentials } = await assumeConnectorRole({ accountId, externalId, roleName }); + const result = await createEdgeOptimizeOrigin(credentials, distributionId, originDomain); + log.info(`[edge-optimize-origin] ${result.created ? 'Created' : 'Origin already existed for'} site ${siteId}, distribution ${distributionId}`); + return ok(result); + } catch (error) { + log.error(`Failed to create CloudFront Edge Optimize origin for site ${siteId}:`, error); + return badRequest(cleanupHeaderValue(error.message)); + } + }; + + // Create/update + publish the `edgeoptimize-routing` CloudFront Function (mutation, idempotent). + // Needs the default-behavior target origin id so the function's failover origin group is correct. + const createEdgeOptimizeRoutingFunctionHandler = async (context) => { + const { log, dataAccess, env } = context; + const { siteId } = context.params; + const { Site } = dataAccess; + const accountId = String(context.data?.accountId || '').replace(/\D/g, ''); + const externalId = String(context.data?.externalId || '').trim(); + const distributionId = String(context.data?.distributionId || '').trim(); + const targetedPaths = Array.isArray(context.data?.targetedPaths) + ? context.data.targetedPaths + : null; + const roleName = env?.EDGE_OPTIMIZE_ROLE_NAME || undefined; + + if (accountId.length !== 12) { + return badRequest('accountId must be a 12-digit AWS account ID'); + } + if (!hasText(externalId)) { + return badRequest('externalId is required'); + } + if (!hasText(distributionId)) { + return badRequest('distributionId is required'); + } + + try { + const { error } = await gateEdgeOptimizeWizard(siteId, Site, 'create the edge optimize routing function'); + if (error) { + return error; + } + + const { credentials } = await assumeConnectorRole({ accountId, externalId, roleName }); + // Derive the default-behavior target origin id from the live distribution config. + const { defaultCacheBehavior } = await getDistributionConfig(credentials, distributionId); + const defaultOriginId = defaultCacheBehavior?.targetOriginId; + if (!hasText(defaultOriginId)) { + return badRequest('Could not determine the default cache behavior target origin'); + } + + const result = await createEdgeOptimizeRoutingFunction( + credentials, + defaultOriginId, + targetedPaths, + ); + log.info(`[edge-optimize-function] ${result.created ? 'Created' : 'Updated'} routing function for site ${siteId}`); + return ok(result); + } catch (error) { + log.error(`Failed to create CloudFront routing function for site ${siteId}:`, error); + return badRequest(cleanupHeaderValue(error.message)); + } + }; + + // Ensure the Edge Optimize headers are forwarded by the selected behavior's cache policy + // (mutation, idempotent). Used by the wizard's "Apply cache headers" step. + const applyEdgeOptimizeCacheHandler = async (context) => { + const { log, dataAccess, env } = context; + const { siteId } = context.params; + const { Site } = dataAccess; + const accountId = String(context.data?.accountId || '').replace(/\D/g, ''); + const externalId = String(context.data?.externalId || '').trim(); + const distributionId = String(context.data?.distributionId || '').trim(); + const pathPattern = String(context.data?.pathPattern || '').trim() || 'default'; + const roleName = env?.EDGE_OPTIMIZE_ROLE_NAME || undefined; + + if (accountId.length !== 12) { + return badRequest('accountId must be a 12-digit AWS account ID'); + } + if (!hasText(externalId)) { + return badRequest('externalId is required'); + } + if (!hasText(distributionId)) { + return badRequest('distributionId is required'); + } + + try { + const { error } = await gateEdgeOptimizeWizard(siteId, Site, 'apply edge optimize cache headers'); + if (error) { + return error; + } + + const { credentials } = await assumeConnectorRole({ accountId, externalId, roleName }); + const result = await applyEdgeOptimizeCacheHeaders(credentials, distributionId, pathPattern); + log.info(`[edge-optimize-cache] Applied cache headers for site ${siteId}, behavior ${pathPattern}`); + return ok(result); + } catch (error) { + log.error(`Failed to apply CloudFront cache headers for site ${siteId}:`, error); + return badRequest(cleanupHeaderValue(error.message)); + } + }; + + // Create/update + publish the `edgeoptimize-origin` Lambda@Edge function and its exec role + // (mutation, idempotent). Returns the versioned ARN the associate step needs. + const createEdgeOptimizeLambdaHandler = async (context) => { + const { log, dataAccess, env } = context; + const { siteId } = context.params; + const { Site } = dataAccess; + const accountId = String(context.data?.accountId || '').replace(/\D/g, ''); + const externalId = String(context.data?.externalId || '').trim(); + const roleName = env?.EDGE_OPTIMIZE_ROLE_NAME || undefined; + + if (accountId.length !== 12) { + return badRequest('accountId must be a 12-digit AWS account ID'); + } + if (!hasText(externalId)) { + return badRequest('externalId is required'); + } + + try { + const { error } = await gateEdgeOptimizeWizard(siteId, Site, 'create the edge optimize Lambda@Edge function'); + if (error) { + return error; + } + + const { credentials, accountId: resolvedAccountId } = await assumeConnectorRole({ + accountId, externalId, roleName, + }); + const result = await createEdgeOptimizeLambda(credentials, resolvedAccountId); + log.info(`[edge-optimize-lambda] ${result.created ? 'Created' : 'Updated'} Lambda@Edge for site ${siteId}, published version ${result.version}`); + return ok(result); + } catch (error) { + log.error(`Failed to create Lambda@Edge function for site ${siteId}:`, error); + return badRequest(cleanupHeaderValue(error.message)); + } + }; + + // Associate the routing CloudFront Function (viewer-request) and Lambda@Edge (origin-request/ + // response, versioned ARN) onto the user-selected behavior (mutation). Used by "Associate". + const applyEdgeOptimizeAssociationsHandler = async (context) => { + const { log, dataAccess, env } = context; + const { siteId } = context.params; + const { Site } = dataAccess; + const accountId = String(context.data?.accountId || '').replace(/\D/g, ''); + const externalId = String(context.data?.externalId || '').trim(); + const distributionId = String(context.data?.distributionId || '').trim(); + const pathPattern = String(context.data?.pathPattern || '').trim() || 'default'; + const lambdaVersionArn = String(context.data?.lambdaVersionArn || '').trim(); + const roleName = env?.EDGE_OPTIMIZE_ROLE_NAME || undefined; + + if (accountId.length !== 12) { + return badRequest('accountId must be a 12-digit AWS account ID'); + } + if (!hasText(externalId)) { + return badRequest('externalId is required'); + } + if (!hasText(distributionId)) { + return badRequest('distributionId is required'); + } + if (!hasText(lambdaVersionArn)) { + return badRequest('lambdaVersionArn is required'); + } + + try { + const { error } = await gateEdgeOptimizeWizard(siteId, Site, 'associate edge optimize routing'); + if (error) { + return error; + } + + const { credentials } = await assumeConnectorRole({ accountId, externalId, roleName }); + const result = await applyEdgeOptimizeAssociations( + credentials, + distributionId, + pathPattern, + lambdaVersionArn, + ); + log.info(`[edge-optimize-associate] Associated routing for site ${siteId}, behavior ${pathPattern}`); + return ok(result); + } catch (error) { + log.error(`Failed to associate CloudFront routing for site ${siteId}:`, error); + return badRequest(cleanupHeaderValue(error.message)); + } + }; + + // Verify end-to-end routing by probing the distribution as a bot vs a human and inspecting the + // x-edgeoptimize-* headers. Always returns 200 with { passed }; success requires a request-id. + const verifyEdgeOptimizeRoutingHandler = async (context) => { + const { log, dataAccess, env } = context; + const { siteId } = context.params; + const { Site } = dataAccess; + const accountId = String(context.data?.accountId || '').replace(/\D/g, ''); + const externalId = String(context.data?.externalId || '').trim(); + const distributionId = String(context.data?.distributionId || '').trim(); + const roleName = env?.EDGE_OPTIMIZE_ROLE_NAME || undefined; + + if (accountId.length !== 12) { + return badRequest('accountId must be a 12-digit AWS account ID'); + } + if (!hasText(externalId)) { + return badRequest('externalId is required'); + } + if (!hasText(distributionId)) { + return badRequest('distributionId is required'); + } + + try { + const { error } = await gateEdgeOptimizeWizard(siteId, Site, 'verify edge optimize routing'); + if (error) { + return error; + } + + // Determine the URL to probe: prefer an explicit domain, else resolve from the distribution. + let domain = String(context.data?.domain || '').trim(); + if (!hasText(domain)) { + const { credentials } = await assumeConnectorRole({ accountId, externalId, roleName }); + const distributions = await listCloudFrontDistributions(credentials); + const match = distributions.find((d) => d.id === distributionId); + domain = match?.domainName || ''; + } + if (!hasText(domain)) { + return badRequest('Could not determine the distribution domain to verify'); + } + + const url = /^https?:\/\//.test(domain) ? domain : `https://${domain}/`; + const result = await verifyEdgeOptimizeRouting(url); + log.info(`[edge-optimize-verify] Verified routing for site ${siteId}: passed=${result.passed}`); + return ok(result); + } catch (error) { + log.error(`Failed to verify CloudFront routing for site ${siteId}:`, error); + return badRequest(cleanupHeaderValue(error.message)); + } + }; + return { getEdgeOptimizeBootstrapUrl, connectEdgeOptimize, @@ -2393,6 +2658,12 @@ function LlmoController(ctx) { checkEdgeOptimizePrerequisites, getEdgeOptimizeOrigins, getEdgeOptimizeBehaviors, + createEdgeOptimizeOrigin: createEdgeOptimizeOriginHandler, + createEdgeOptimizeRoutingFunction: createEdgeOptimizeRoutingFunctionHandler, + applyEdgeOptimizeCache: applyEdgeOptimizeCacheHandler, + createEdgeOptimizeLambda: createEdgeOptimizeLambdaHandler, + applyEdgeOptimizeAssociations: applyEdgeOptimizeAssociationsHandler, + verifyEdgeOptimizeRouting: verifyEdgeOptimizeRoutingHandler, getLlmoSheetData, queryLlmoSheetData, getLlmoGlobalSheetData, diff --git a/src/routes/index.js b/src/routes/index.js index 53ee361b15..3f34b2e4ed 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -486,6 +486,12 @@ export default function getRouteHandlers( 'POST /sites/:siteId/llmo/edge-optimize/prerequisites': llmoController.checkEdgeOptimizePrerequisites, 'POST /sites/:siteId/llmo/edge-optimize/origins': llmoController.getEdgeOptimizeOrigins, 'POST /sites/:siteId/llmo/edge-optimize/behaviors': llmoController.getEdgeOptimizeBehaviors, + 'POST /sites/:siteId/llmo/edge-optimize/create-origin': llmoController.createEdgeOptimizeOrigin, + 'POST /sites/:siteId/llmo/edge-optimize/create-function': llmoController.createEdgeOptimizeRoutingFunction, + 'POST /sites/:siteId/llmo/edge-optimize/apply-cache': llmoController.applyEdgeOptimizeCache, + 'POST /sites/:siteId/llmo/edge-optimize/create-lambda': llmoController.createEdgeOptimizeLambda, + 'POST /sites/:siteId/llmo/edge-optimize/apply-associations': llmoController.applyEdgeOptimizeAssociations, + 'POST /sites/:siteId/llmo/edge-optimize/verify': llmoController.verifyEdgeOptimizeRouting, 'GET /sites/:siteId/llmo/strategy': llmoController.getStrategy, 'PUT /sites/:siteId/llmo/strategy': llmoController.saveStrategy, 'GET /sites/:siteId/llmo/edge-optimize-status': llmoController.checkEdgeOptimizeStatus, diff --git a/src/routes/required-capabilities.js b/src/routes/required-capabilities.js index 75ff5cb37a..cf4ac42c05 100644 --- a/src/routes/required-capabilities.js +++ b/src/routes/required-capabilities.js @@ -133,6 +133,12 @@ export const INTERNAL_ROUTES = [ 'POST /sites/:siteId/llmo/edge-optimize/prerequisites', 'POST /sites/:siteId/llmo/edge-optimize/origins', 'POST /sites/:siteId/llmo/edge-optimize/behaviors', + 'POST /sites/:siteId/llmo/edge-optimize/create-origin', + 'POST /sites/:siteId/llmo/edge-optimize/create-function', + 'POST /sites/:siteId/llmo/edge-optimize/apply-cache', + 'POST /sites/:siteId/llmo/edge-optimize/create-lambda', + 'POST /sites/:siteId/llmo/edge-optimize/apply-associations', + 'POST /sites/:siteId/llmo/edge-optimize/verify', 'PUT /sites/:siteId/llmo/opportunities-reviewed', // PLG onboarding - IMS token auth, self-service flow, not S2S diff --git a/src/support/edge-optimize.js b/src/support/edge-optimize.js index cd6ee7a69d..3227fb80f1 100644 --- a/src/support/edge-optimize.js +++ b/src/support/edge-optimize.js @@ -10,12 +10,35 @@ * governing permissions and limitations under the License. */ +import { deflateRawSync } from 'node:zlib'; import { STSClient, AssumeRoleCommand } from '@aws-sdk/client-sts'; import { CloudFrontClient, ListDistributionsCommand, GetDistributionConfigCommand, + GetCachePolicyConfigCommand, + UpdateCachePolicyCommand, + CreateFunctionCommand, + UpdateFunctionCommand, + DescribeFunctionCommand, + PublishFunctionCommand, + UpdateDistributionCommand, } from '@aws-sdk/client-cloudfront'; +import { + IAMClient, + CreateRoleCommand, + GetRoleCommand, + PutRolePolicyCommand, + UpdateAssumeRolePolicyCommand, +} from '@aws-sdk/client-iam'; +import { + LambdaClient, + CreateFunctionCommand as LambdaCreateFunctionCommand, + UpdateFunctionCodeCommand, + GetFunctionCommand, + GetFunctionConfigurationCommand, + PublishVersionCommand, +} from '@aws-sdk/client-lambda'; import { hasText } from '@adobe/spacecat-shared-utils'; // CloudFront is a global service; its control plane lives in us-east-1. @@ -24,6 +47,20 @@ export const EDGE_OPTIMIZE_DEFAULT_ROLE_NAME = 'AdobeLLMOptimizerCloudFrontConne const SESSION_NAME = 'llmo-edge-optimize'; const SESSION_DURATION_SECONDS = 900; +// The connector role only permits writes to these exact resource names — keep them in sync +// with the standalone connect-aws-wizard (server.mjs) and the customer-bootstrap-role policy. +export const EDGE_OPTIMIZE_ORIGIN_ID = 'EdgeOptimize_Origin'; +export const EDGE_OPTIMIZE_DEFAULT_ORIGIN_DOMAIN = 'dev.edgeoptimize.net'; +export const EDGE_OPTIMIZE_FUNCTION_NAME = 'edgeoptimize-routing'; +export const EDGE_OPTIMIZE_LAMBDA_FUNCTION_NAME = 'edgeoptimize-origin'; +export const EDGE_OPTIMIZE_LAMBDA_ROLE_NAME = 'edgeoptimize-origin-role'; +// Headers the routing CloudFront Function sets and that must reach the EO origin uncached. +export const EDGE_OPTIMIZE_CACHE_HEADERS = ['x-edgeoptimize-config', 'x-edgeoptimize-url']; + +const delay = (ms) => new Promise((resolve) => { + setTimeout(resolve, ms); +}); + /** * Assume the customer's cross-account connector role and return short-lived credentials. * @@ -142,3 +179,727 @@ export async function getDistributionConfig( return { origins, defaultCacheBehavior, cacheBehaviors }; } + +/** + * Locate a behavior on a parsed DistributionConfig by its path pattern. The default behavior is + * addressed with the pseudo-pattern `default` (or `Default (*)`, the projection used by the read + * endpoints). + * + * @param {object} config - a raw CloudFront DistributionConfig. + * @param {string} pathPattern - the behavior path pattern, or `default`/`Default (*)`. + * @returns {object} the raw behavior object (mutating it mutates the config). + */ +function getBehaviorFromConfig(config, pathPattern) { + if (pathPattern === 'default' || pathPattern === 'Default (*)') { + return config.DefaultCacheBehavior; + } + const behavior = (config.CacheBehaviors?.Items || []).find((b) => b.PathPattern === pathPattern); + if (!behavior) { + throw new Error(`Behavior not found: ${pathPattern}`); + } + return behavior; +} + +/** + * Add the Edge Optimize origin to a CloudFront distribution (idempotent). + * + * Mirrors the standalone wizard's create-origin: reads the distribution config, and — only if no + * Edge Optimize origin exists yet — appends a custom HTTPS origin pointing at the EO target domain + * with the EO request headers, then writes it back via UpdateDistribution (deploy propagates in the + * background; we do not block on it). + * + * @param {object} credentials - temporary credentials from {@link assumeConnectorRole}. + * @param {string} distributionId - the CloudFront distribution ID. + * @param {string} [originDomain] - EO origin domain (env-driven; defaults to the dev EO domain). + * @param {string} [region] - CloudFront control-plane region. + * @returns {Promise<{created: boolean, alreadyExisted: boolean, originId: string}>} + */ +export async function createEdgeOptimizeOrigin( + credentials, + distributionId, + originDomain = EDGE_OPTIMIZE_DEFAULT_ORIGIN_DOMAIN, + region = EDGE_OPTIMIZE_REGION, +) { + if (!hasText(distributionId)) { + throw new Error('distributionId is required'); + } + const client = new CloudFrontClient({ region, credentials }); + const result = await client.send(new GetDistributionConfigCommand({ Id: distributionId })); + const config = result.DistributionConfig; + const etag = result.ETag; + const origins = config.Origins?.Items || []; + + const alreadyExisted = origins.some( + (o) => o.Id === EDGE_OPTIMIZE_ORIGIN_ID || o.DomainName === originDomain, + ); + + if (alreadyExisted) { + return { created: false, alreadyExisted: true, originId: EDGE_OPTIMIZE_ORIGIN_ID }; + } + + origins.push({ + Id: EDGE_OPTIMIZE_ORIGIN_ID, + DomainName: originDomain, + OriginPath: '', + CustomHeaders: { Quantity: 0, Items: [] }, + CustomOriginConfig: { + HTTPPort: 80, + HTTPSPort: 443, + OriginProtocolPolicy: 'https-only', + OriginSslProtocols: { Quantity: 1, Items: ['TLSv1.2'] }, + OriginReadTimeout: 30, + OriginKeepaliveTimeout: 5, + }, + ConnectionAttempts: 3, + ConnectionTimeout: 10, + }); + config.Origins = { Quantity: origins.length, Items: origins }; + + await client.send(new UpdateDistributionCommand({ + Id: distributionId, + IfMatch: etag, + DistributionConfig: config, + })); + + return { created: true, alreadyExisted: false, originId: EDGE_OPTIMIZE_ORIGIN_ID }; +} + +/** + * Build the CloudFront Function (viewer-request) routing code. Ported verbatim from the standalone + * wizard's `buildFunctionCode` (server.mjs). It detects agentic bots on HTML pages and, for them, + * creates a request origin group that fails over from the Edge Optimize origin to the default + * origin. + * + * @param {string} defaultOriginId - the distribution's default-behavior target origin id. + * @param {string[]|null} [targetedPaths] - explicit paths to target, or null for "all HTML pages". + * @returns {string} the CloudFront Function source code. + */ +export function buildRoutingFunctionCode(defaultOriginId, targetedPaths = null) { + const targetedPathsValue = targetedPaths === null ? 'null' : JSON.stringify(targetedPaths); + + return `import cf from 'cloudfront'; + +function handler(event) { + var request = event.request; + var headers = request.headers; + + delete headers['x-edgeoptimize-api-key']; + delete headers['x-edgeoptimize-url']; + delete headers['x-edgeoptimize-config']; + + var AGENTIC_BOTS = ['AdobeEdgeOptimize-AI', 'ChatGPT-User', 'GPTBot', 'OAI-SearchBot', 'PerplexityBot', 'Perplexity-User', 'ClaudeBot', 'Claude-User', 'Claude-SearchBot']; + var TARGETED_PATHS = ${targetedPathsValue}; + + var userAgent = headers['user-agent'] ? headers['user-agent'].value.toLowerCase() : ''; + var isEdgeOptimizeRequest = headers['x-edgeoptimize-request']; + + var path = request.uri; + var pattern = /(?:\\/[^./]+|\\.html|\\/)$/; + var isHtmlPage = pattern.test(path); + + var isTargetedPath = TARGETED_PATHS === null + ? isHtmlPage + : isHtmlPage && TARGETED_PATHS.includes(path); + + var isAgenticBot = AGENTIC_BOTS.some(function(bot) { + return userAgent.includes(bot.toLowerCase()); + }); + + if (!isEdgeOptimizeRequest && isAgenticBot && isTargetedPath) { + request.headers['x-edgeoptimize-url'] = { value: request.uri }; + request.headers['x-edgeoptimize-config'] = { value: "LLMCLIENT=true" }; + + console.log("Adding origin group for userAgent: " + userAgent); + + cf.createRequestOriginGroup({ + "originIds": [ + { "originId": "EdgeOptimize_Origin" }, + { "originId": "${defaultOriginId}" } + ], + "failoverCriteria": { + "statusCodes": [400, 403, 404, 416, 500, 502, 503, 504] + } + }); + + console.log("Routing to Edge Optimize origin for userAgent: " + userAgent); + return request; + } + + console.log("Routing to Default origin for userAgent: " + userAgent); + return request; +}`; +} + +/** + * Create or update the `edgeoptimize-routing` CloudFront Function and publish it to LIVE + * (idempotent). Mirrors the standalone wizard's create-function step. + * + * @param {object} credentials - temporary credentials from {@link assumeConnectorRole}. + * @param {string} defaultOriginId - the default-behavior target origin id (baked into the code). + * @param {string[]|null} [targetedPaths] - explicit paths to target, or null for all HTML pages. + * @param {string} [region] - CloudFront control-plane region. + * @returns {Promise<{name: string, created: boolean, stage: string}>} + */ +export async function createEdgeOptimizeRoutingFunction( + credentials, + defaultOriginId, + targetedPaths = null, + region = EDGE_OPTIMIZE_REGION, +) { + if (!hasText(defaultOriginId)) { + throw new Error('defaultOriginId is required'); + } + const client = new CloudFrontClient({ region, credentials }); + const code = Buffer.from(buildRoutingFunctionCode(defaultOriginId, targetedPaths), 'utf-8'); + const functionConfig = { + Comment: 'EdgeOptimize agentic bot routing — managed by LLM Optimizer', + Runtime: 'cloudfront-js-2.0', + }; + + // Look up the DEVELOPMENT stage to get its ETag (needed to update an existing function). + let existingEtag = null; + try { + const desc = await client.send(new DescribeFunctionCommand({ + Name: EDGE_OPTIMIZE_FUNCTION_NAME, + Stage: 'DEVELOPMENT', + })); + existingEtag = desc.ETag; + } catch (err) { + if (err.name !== 'NoSuchFunctionExists') { + throw err; + } + } + + let etag; + if (existingEtag) { + const updated = await client.send(new UpdateFunctionCommand({ + Name: EDGE_OPTIMIZE_FUNCTION_NAME, + IfMatch: existingEtag, + FunctionConfig: functionConfig, + FunctionCode: code, + })); + etag = updated.ETag; + } else { + const created = await client.send(new CreateFunctionCommand({ + Name: EDGE_OPTIMIZE_FUNCTION_NAME, + FunctionConfig: functionConfig, + FunctionCode: code, + })); + etag = created.ETag; + } + + await client.send(new PublishFunctionCommand({ + Name: EDGE_OPTIMIZE_FUNCTION_NAME, + IfMatch: etag, + })); + + return { name: EDGE_OPTIMIZE_FUNCTION_NAME, created: !existingEtag, stage: 'LIVE' }; +} + +/** + * Ensure the Edge Optimize routing headers are forwarded by the target behavior's cache policy. + * + * Mirrors the common path of the standalone wizard's apply-cache (the "custom policy" scenario): + * reads the cache policy attached to the selected behavior and, if the EO headers are not already + * forwarded, adds them to the policy's whitelist via UpdateCachePolicy. Setting `setMinTTLZero` + * (default true) forces MinTTL to 0 so agentic responses are not over-cached. + * + * @param {object} credentials - temporary credentials from {@link assumeConnectorRole}. + * @param {string} distributionId - the CloudFront distribution ID. + * @param {string} pathPattern - the behavior to target (`default` for the default behavior). + * @param {object} [opts] + * @param {boolean} [opts.setMinTTLZero=true] - force the policy MinTTL to 0. + * @param {string} [opts.region] - CloudFront control-plane region. + * @returns {Promise<{policyId: string, updated: boolean, alreadyForwarded: boolean}>} + */ +export async function applyEdgeOptimizeCacheHeaders( + credentials, + distributionId, + pathPattern, + { setMinTTLZero = true, region = EDGE_OPTIMIZE_REGION } = {}, +) { + if (!hasText(distributionId)) { + throw new Error('distributionId is required'); + } + if (!hasText(pathPattern)) { + throw new Error('pathPattern is required'); + } + const client = new CloudFrontClient({ region, credentials }); + + const distResult = await client.send(new GetDistributionConfigCommand({ Id: distributionId })); + const behavior = getBehaviorFromConfig(distResult.DistributionConfig, pathPattern); + const policyId = behavior.CachePolicyId; + + // TODO: edge scenarios from the standalone wizard not yet ported here: + // - legacy behaviors using ForwardedValues (no CachePolicyId) + // - AWS-managed cache policies, which must be cloned into a custom `edgeoptimize-cache` policy + // (the connector role can create custom policies but cannot mutate managed ones). + // The common path — a custom cache policy already attached to the behavior — is implemented. + if (!policyId) { + throw new Error( + `Behavior '${pathPattern}' uses legacy ForwardedValues or a managed cache policy; ` + + 'attach a custom cache policy before applying Edge Optimize cache headers.', + ); + } + + const pcResult = await client.send(new GetCachePolicyConfigCommand({ Id: policyId })); + const pc = pcResult.CachePolicyConfig; + const pcEtag = pcResult.ETag; + + const params = pc.ParametersInCacheKeyAndForwardedToOrigin || {}; + const hc = params.HeadersConfig || { HeaderBehavior: 'none' }; + + let alreadyForwarded = false; + if (hc.HeaderBehavior === 'allViewer' || hc.HeaderBehavior === 'all') { + alreadyForwarded = true; + } else { + const items = hc.Headers?.Items || []; + const lower = items.map((x) => x.toLowerCase()); + alreadyForwarded = EDGE_OPTIMIZE_CACHE_HEADERS.every((h) => lower.includes(h)); + if (!alreadyForwarded) { + EDGE_OPTIMIZE_CACHE_HEADERS.forEach((h) => { + if (!lower.includes(h)) { + items.push(h); + } + }); + hc.HeaderBehavior = 'whitelist'; + hc.Headers = { Quantity: items.length, Items: items }; + params.HeadersConfig = hc; + pc.ParametersInCacheKeyAndForwardedToOrigin = params; + } + } + + const needsMinTtl = setMinTTLZero && pc.MinTTL !== 0; + if (alreadyForwarded && !needsMinTtl) { + return { policyId, updated: false, alreadyForwarded: true }; + } + + if (needsMinTtl) { + pc.MinTTL = 0; + } + + await client.send(new UpdateCachePolicyCommand({ + Id: policyId, + IfMatch: pcEtag, + CachePolicyConfig: pc, + })); + + return { policyId, updated: true, alreadyForwarded }; +} + +// The Lambda@Edge origin-request/response handler, ported verbatim from the standalone wizard's +// templates/origin-request-response.js. Kept as an inline JS module string (not a sibling-file +// read) so the helix-deploy bundle preserves it — see CLAUDE.md "Lambda Bundle Constraints". +export const EDGE_OPTIMIZE_LAMBDA_CODE = `function hasHeader(map, name) { + const h = map?.[name]; + return Array.isArray(h) && h.length > 0 && (h[0].value || '').trim() !== ''; +} + +function setHeader(map, name, value) { + if (map) { + map[name.toLowerCase()] = [{ key: name, value: String(value) }]; + } +} + +export const handler = async (event) => { + const request = event?.Records?.[0]?.cf?.request; + const response = event?.Records?.[0]?.cf?.response; + const eventType = event.Records[0].cf.config.eventType; + const reqHeaders = request.headers || {}; + + if (eventType === 'origin-request') { + const originDomain = request.origin?.custom?.domainName; + const isEdgeOptimizeConfig = hasHeader(reqHeaders, 'x-edgeoptimize-config'); + const isEdgeOptimizeRequest = hasHeader(reqHeaders, 'x-edgeoptimize-request'); + + if (isEdgeOptimizeConfig && !isEdgeOptimizeRequest) { + if (originDomain === 'dev.edgeoptimize.net') { + console.log("Calling Edge Optimize Origin for agentic requests"); + setHeader(request.headers, 'host', originDomain); + } else { + console.log("Calling Default Origin in case of failover for agentic requests"); + setHeader(request.headers, 'x-edgeoptimize-request', 'fo'); + } + } + + return request; + + } else if (eventType === 'origin-response') { + const resHeaders = response.headers || {}; + const isEdgeOptimizeConfig = hasHeader(reqHeaders, 'x-edgeoptimize-config'); + const isEdgeOptimizeRequestId = hasHeader(resHeaders, 'x-edgeoptimize-request-id'); + + if (isEdgeOptimizeConfig && !isEdgeOptimizeRequestId) { + setHeader(response.headers, 'x-edgeoptimize-fo', '1'); + setHeader(response.headers, 'cache-control', 'no-store'); + console.log('Failover Triggered for agentic requests'); + } + + return response; + } +}; +`; + +const LAMBDA_TRUST_POLICY = JSON.stringify({ + Version: '2012-10-17', + Statement: [{ + Effect: 'Allow', + Principal: { Service: ['lambda.amazonaws.com', 'edgelambda.amazonaws.com'] }, + Action: 'sts:AssumeRole', + }], +}); + +function buildCwLogsPolicy(accountId, functionName) { + return JSON.stringify({ + Version: '2012-10-17', + Statement: [ + { + Effect: 'Allow', + Action: 'logs:CreateLogGroup', + Resource: `arn:aws:logs:*:${accountId}:*`, + }, + { + Effect: 'Allow', + Action: ['logs:CreateLogStream', 'logs:PutLogEvents'], + Resource: [`arn:aws:logs:*:${accountId}:log-group:/aws/lambda/us-east-1.${functionName}:*`], + }, + ], + }); +} + +// ── Minimal zip builder (no external deps) — ported from the standalone wizard's buildZip. ── +// CRC32 + ZIP local/central directory headers are inherently bit-twiddling and densely packed; the +// helix lint rules against bitwise ops, multiple statements per line, and long lines do not fit +// binary-format code, so they are disabled for this block only. +/* eslint-disable no-bitwise, max-statements-per-line, max-len */ +const CRC32_TABLE = (() => { + const t = new Uint32Array(256); + for (let i = 0; i < 256; i += 1) { + let c = i; + for (let j = 0; j < 8; j += 1) { c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1); } + t[i] = c; + } + return t; +})(); + +function crc32(buf) { + let c = 0xFFFFFFFF; + for (let i = 0; i < buf.length; i += 1) { c = (c >>> 8) ^ CRC32_TABLE[(c ^ buf[i]) & 0xFF]; } + return (c ^ 0xFFFFFFFF) >>> 0; +} + +/** + * Build an in-memory zip containing a single file. Used to package the Lambda@Edge code without + * adding a zip dependency to the runtime bundle. + * + * @param {string} filename - the entry name inside the zip (e.g. `index.mjs`). + * @param {string|Buffer} content - the file content. + * @returns {Buffer} the zip archive bytes. + */ +export function buildLambdaZip(filename, content) { + const data = Buffer.isBuffer(content) ? content : Buffer.from(content, 'utf-8'); + const compressed = deflateRawSync(data, { level: 9 }); + const crcVal = crc32(data); + const fn = Buffer.from(filename, 'utf-8'); + const now = new Date(); + const dosDate = ((now.getFullYear() - 1980) << 9) | ((now.getMonth() + 1) << 5) | now.getDate(); + const dosTime = (now.getHours() << 11) | (now.getMinutes() << 5) | (now.getSeconds() >> 1); + + const lh = Buffer.alloc(30 + fn.length); + lh.writeUInt32LE(0x04034b50, 0); lh.writeUInt16LE(20, 4); lh.writeUInt16LE(0, 6); + lh.writeUInt16LE(8, 8); lh.writeUInt16LE(dosTime, 10); lh.writeUInt16LE(dosDate, 12); + lh.writeUInt32LE(crcVal, 14); lh.writeUInt32LE(compressed.length, 18); + lh.writeUInt32LE(data.length, 22); lh.writeUInt16LE(fn.length, 26); lh.writeUInt16LE(0, 28); + fn.copy(lh, 30); + + const cd = Buffer.alloc(46 + fn.length); + cd.writeUInt32LE(0x02014b50, 0); cd.writeUInt16LE(20, 4); cd.writeUInt16LE(20, 6); + cd.writeUInt16LE(0, 8); cd.writeUInt16LE(8, 10); cd.writeUInt16LE(dosTime, 12); + cd.writeUInt16LE(dosDate, 14); cd.writeUInt32LE(crcVal, 16); + cd.writeUInt32LE(compressed.length, 20); cd.writeUInt32LE(data.length, 24); + cd.writeUInt16LE(fn.length, 28); cd.writeUInt16LE(0, 30); cd.writeUInt16LE(0, 32); + cd.writeUInt16LE(0, 34); cd.writeUInt16LE(0, 36); cd.writeUInt32LE(0, 38); + cd.writeUInt32LE(0, 42); fn.copy(cd, 46); + + const eocd = Buffer.alloc(22); + eocd.writeUInt32LE(0x06054b50, 0); eocd.writeUInt16LE(0, 4); eocd.writeUInt16LE(0, 6); + eocd.writeUInt16LE(1, 8); eocd.writeUInt16LE(1, 10); + eocd.writeUInt32LE(cd.length, 12); eocd.writeUInt32LE(lh.length + compressed.length, 16); + eocd.writeUInt16LE(0, 20); + + return Buffer.concat([lh, compressed, cd, eocd]); +} +/* eslint-enable no-bitwise, max-statements-per-line, max-len */ + +async function waitForLambdaActive(lambda, functionName, maxWaitMs = 30000) { + const deadline = Date.now() + maxWaitMs; + /* eslint-disable no-await-in-loop */ + while (Date.now() < deadline) { + const cfg = await lambda.send( + new GetFunctionConfigurationCommand({ FunctionName: functionName }), + ); + if (cfg.State === 'Active') { + return; + } + if (cfg.State === 'Failed') { + throw new Error(`Lambda function entered Failed state: ${cfg.StateReason}`); + } + await delay(2000); + } + /* eslint-enable no-await-in-loop */ + throw new Error('Lambda function did not become Active within 30 s'); +} + +/** + * Create (or update) the `edgeoptimize-origin` Lambda@Edge function and publish a version + * (idempotent). Mirrors the standalone wizard's create-lambda step: ensure the exec role exists + * (trusting lambda + edgelambda) with a basic CloudWatch-logs inline policy, then create/update the + * function code and publish a numbered version. Newly-created IAM roles take a few seconds to + * propagate, so the create path retries CreateFunction with a bounded back-off + * (up to ~5×5s, within ~30s). + * + * @param {object} credentials - temporary credentials from {@link assumeConnectorRole}. + * @param {string} accountId - the 12-digit customer AWS account ID (for the logs-policy ARNs). + * @param {object} [opts] + * @param {string} [opts.region] - control-plane region (Lambda@Edge must be us-east-1). + * @param {number} [opts.roleWaitMs] - extra wait after creating a new role before first create. + * @param {number} [opts.retryDelayMs] - back-off between CreateFunction role-propagation retries. + * @returns {Promise<{functionArn: string, versionArn: string, version: string, + * roleArn: string, created: boolean}>} + */ +export async function createEdgeOptimizeLambda( + credentials, + accountId, + { region = EDGE_OPTIMIZE_REGION, roleWaitMs = 12000, retryDelayMs = 5000 } = {}, +) { + if (!/^[0-9]{12}$/.test(String(accountId))) { + throw new Error('accountId must be a 12-digit AWS account ID'); + } + const lambda = new LambdaClient({ region, credentials }); + const iam = new IAMClient({ region, credentials }); + + const zipBuffer = buildLambdaZip('index.mjs', EDGE_OPTIMIZE_LAMBDA_CODE); + + // ── 1. Ensure the exec role exists with the current trust policy. ── + let roleArn; + let roleIsNew = false; + try { + const existing = await iam.send( + new GetRoleCommand({ RoleName: EDGE_OPTIMIZE_LAMBDA_ROLE_NAME }), + ); + roleArn = existing.Role.Arn; + await iam.send(new UpdateAssumeRolePolicyCommand({ + RoleName: EDGE_OPTIMIZE_LAMBDA_ROLE_NAME, + PolicyDocument: LAMBDA_TRUST_POLICY, + })); + } catch (err) { + if (err.name !== 'NoSuchEntityException') { + throw err; + } + const created = await iam.send(new CreateRoleCommand({ + RoleName: EDGE_OPTIMIZE_LAMBDA_ROLE_NAME, + AssumeRolePolicyDocument: LAMBDA_TRUST_POLICY, + Description: 'Execution role for EdgeOptimize Lambda@Edge function', + })); + roleArn = created.Role.Arn; + roleIsNew = true; + } + + // ── 2. Attach the CloudWatch-logs inline policy. ── + await iam.send(new PutRolePolicyCommand({ + RoleName: EDGE_OPTIMIZE_LAMBDA_ROLE_NAME, + PolicyName: 'EdgeOptimizeLambdaLogging', + PolicyDocument: buildCwLogsPolicy(String(accountId), EDGE_OPTIMIZE_LAMBDA_FUNCTION_NAME), + })); + + // ── 3. Create or update the function code. ── + let functionArn; + let fnExists = false; + try { + await lambda.send( + new GetFunctionCommand({ FunctionName: EDGE_OPTIMIZE_LAMBDA_FUNCTION_NAME }), + ); + fnExists = true; + } catch (err) { + if (err.name !== 'ResourceNotFoundException') { + throw err; + } + } + + if (fnExists) { + const updated = await lambda.send(new UpdateFunctionCodeCommand({ + FunctionName: EDGE_OPTIMIZE_LAMBDA_FUNCTION_NAME, + ZipFile: zipBuffer, + })); + functionArn = updated.FunctionArn; + } else { + if (roleIsNew && roleWaitMs > 0) { + await delay(roleWaitMs); + } + let lastErr; + /* eslint-disable no-await-in-loop */ + for (let attempt = 0; attempt < 5; attempt += 1) { + try { + const created = await lambda.send(new LambdaCreateFunctionCommand({ + FunctionName: EDGE_OPTIMIZE_LAMBDA_FUNCTION_NAME, + Runtime: 'nodejs24.x', + Role: roleArn, + Handler: 'index.handler', + Code: { ZipFile: zipBuffer }, + Description: 'EdgeOptimize origin request/response handler (Lambda@Edge)', + Timeout: 5, + MemorySize: 128, + })); + functionArn = created.FunctionArn; + lastErr = null; + break; + } catch (createErr) { + lastErr = createErr; + const isRolePropagation = createErr.name === 'InvalidParameterValueException' + && (createErr.message || '').toLowerCase().includes('role'); + if (!isRolePropagation || attempt >= 4) { + throw createErr; + } + await delay(retryDelayMs); + } + } + /* eslint-enable no-await-in-loop */ + if (lastErr) { + throw lastErr; + } + } + + // ── 4. Wait for Active, then publish a version (Lambda@Edge requires a numbered version). ── + await waitForLambdaActive(lambda, EDGE_OPTIMIZE_LAMBDA_FUNCTION_NAME); + const published = await lambda.send(new PublishVersionCommand({ + FunctionName: EDGE_OPTIMIZE_LAMBDA_FUNCTION_NAME, + Description: 'Published by LLM Optimizer CloudFront wizard', + })); + + return { + functionArn, + versionArn: published.FunctionArn, // includes the :N version suffix + version: published.Version, + roleArn, + created: !fnExists, + }; +} + +/** + * Wire the routing CloudFront Function (viewer-request) and the Lambda@Edge function + * (origin-request + origin-response) onto a selected cache behavior. Mirrors the standalone + * wizard's apply-associations step. + * + * @param {object} credentials - temporary credentials from {@link assumeConnectorRole}. + * @param {string} distributionId - the CloudFront distribution ID. + * @param {string} pathPattern - the behavior to wire (`default` for the default behavior). + * @param {string} lambdaVersionArn - the published, versioned Lambda@Edge ARN. + * @param {string} [region] - CloudFront control-plane region. + * @returns {Promise<{cfFunctionArn: string, lambdaArn: string}>} + */ +export async function applyEdgeOptimizeAssociations( + credentials, + distributionId, + pathPattern, + lambdaVersionArn, + region = EDGE_OPTIMIZE_REGION, +) { + if (!hasText(distributionId)) { + throw new Error('distributionId is required'); + } + if (!hasText(pathPattern)) { + throw new Error('pathPattern is required'); + } + if (!hasText(lambdaVersionArn)) { + throw new Error('lambdaVersionArn is required'); + } + const client = new CloudFrontClient({ region, credentials }); + + const fnResult = await client.send(new DescribeFunctionCommand({ + Name: EDGE_OPTIMIZE_FUNCTION_NAME, + Stage: 'LIVE', + })); + const cfFunctionArn = fnResult.FunctionSummary?.FunctionMetadata?.FunctionARN; + if (!cfFunctionArn) { + throw new Error(`CloudFront function '${EDGE_OPTIMIZE_FUNCTION_NAME}' not found or not published to LIVE`); + } + + const distResult = await client.send(new GetDistributionConfigCommand({ Id: distributionId })); + const config = distResult.DistributionConfig; + const behavior = getBehaviorFromConfig(config, pathPattern); + + // Surface a conflicting viewer-request association rather than silently clobbering it. + const existingViewerFns = (behavior.FunctionAssociations?.Items || []) + .filter((a) => a.EventType === 'viewer-request' && a.FunctionARN !== cfFunctionArn); + if (existingViewerFns.length > 0) { + throw new Error( + `Behavior '${pathPattern}' already has a different viewer-request function associated ` + + `(${existingViewerFns[0].FunctionARN}). Remove it before applying Edge Optimize routing.`, + ); + } + + behavior.FunctionAssociations = { + Quantity: 1, + Items: [{ FunctionARN: cfFunctionArn, EventType: 'viewer-request' }], + }; + behavior.LambdaFunctionAssociations = { + Quantity: 2, + Items: [ + { LambdaFunctionARN: lambdaVersionArn, EventType: 'origin-request', IncludeBody: false }, + { LambdaFunctionARN: lambdaVersionArn, EventType: 'origin-response', IncludeBody: false }, + ], + }; + + await client.send(new UpdateDistributionCommand({ + Id: distributionId, + IfMatch: distResult.ETag, + DistributionConfig: config, + })); + + return { cfFunctionArn, lambdaArn: lambdaVersionArn }; +} + +async function fetchEdgeOptimizeHeaders(url, userAgent) { + const response = await fetch(url, { + redirect: 'manual', + headers: { 'user-agent': userAgent }, + }); + const headers = {}; + response.headers.forEach((value, key) => { + if (key.toLowerCase().startsWith('x-edgeoptimize')) { + headers[key.toLowerCase()] = value; + } + }); + // Drain the body so the connection can be reused/closed. + await response.arrayBuffer().catch(() => {}); + return { status: response.status, headers }; +} + +/** + * Verify Edge Optimize routing end-to-end by fetching the distribution domain as an agentic bot + * and as a human, then inspecting the `x-edgeoptimize-*` headers. Mirrors the standalone wizard's + * verify logic: success REQUIRES `x-edgeoptimize-request-id` on the bot response (served from the + * Edge Optimize prerender cache). `x-edgeoptimize-fo` means failover to origin — routing worked but + * the page is NOT optimised, which is NOT success. + * + * @param {string} url - the URL to probe (typically `https:///`). + * @returns {Promise<{passed: boolean, requestId: string|null, + * details: {bot: object, human: object}}>} + */ +export async function verifyEdgeOptimizeRouting(url) { + if (!hasText(url)) { + throw new Error('url is required'); + } + const [bot, human] = await Promise.all([ + fetchEdgeOptimizeHeaders(url, 'chatgpt-user'), + fetchEdgeOptimizeHeaders(url, 'Mozilla/5.0'), + ]); + + const requestId = bot.headers['x-edgeoptimize-request-id'] || null; + const passed = Boolean(requestId) + && !human.headers['x-edgeoptimize-request-id'] + && !human.headers['x-edgeoptimize-fo'] + && human.headers['x-edgeoptimize-proxy'] !== '1'; + + return { passed, requestId, details: { bot, human } }; +} diff --git a/test/controllers/llmo/llmo.test.js b/test/controllers/llmo/llmo.test.js index cfe9c47c63..8ca0228ce3 100644 --- a/test/controllers/llmo/llmo.test.js +++ b/test/controllers/llmo/llmo.test.js @@ -89,6 +89,12 @@ describe('LlmoController', () => { let assumeConnectorRoleStub; let listCloudFrontDistributionsStub; let getDistributionConfigStub; + let createEdgeOptimizeOriginStub; + let createEdgeOptimizeRoutingFunctionStub; + let applyEdgeOptimizeCacheHeadersStub; + let createEdgeOptimizeLambdaStub; + let applyEdgeOptimizeAssociationsStub; + let verifyEdgeOptimizeRoutingStub; let mockTokowakaClient; let readStrategyStub; let writeStrategyStub; @@ -200,6 +206,12 @@ describe('LlmoController', () => { assumeConnectorRoleStub = sinon.stub(); listCloudFrontDistributionsStub = sinon.stub(); getDistributionConfigStub = sinon.stub(); + createEdgeOptimizeOriginStub = sinon.stub(); + createEdgeOptimizeRoutingFunctionStub = sinon.stub(); + applyEdgeOptimizeCacheHeadersStub = sinon.stub(); + createEdgeOptimizeLambdaStub = sinon.stub(); + applyEdgeOptimizeAssociationsStub = sinon.stub(); + verifyEdgeOptimizeRoutingStub = sinon.stub(); // Initialize mock TokowakaClient mockTokowakaClient = { @@ -272,6 +284,14 @@ describe('LlmoController', () => { assumeConnectorRole: (...args) => assumeConnectorRoleStub(...args), listCloudFrontDistributions: (...args) => listCloudFrontDistributionsStub(...args), getDistributionConfig: (...args) => getDistributionConfigStub(...args), + createEdgeOptimizeOrigin: (...args) => createEdgeOptimizeOriginStub(...args), + createEdgeOptimizeRoutingFunction: (...args) => ( + createEdgeOptimizeRoutingFunctionStub(...args) + ), + applyEdgeOptimizeCacheHeaders: (...args) => applyEdgeOptimizeCacheHeadersStub(...args), + createEdgeOptimizeLambda: (...args) => createEdgeOptimizeLambdaStub(...args), + applyEdgeOptimizeAssociations: (...args) => applyEdgeOptimizeAssociationsStub(...args), + verifyEdgeOptimizeRouting: (...args) => verifyEdgeOptimizeRoutingStub(...args), }, '@adobe/spacecat-shared-ims-client': { ImsClient: function MockImsClient() { @@ -8160,6 +8180,574 @@ describe('LlmoController', () => { }); }); + describe('createEdgeOptimizeOrigin', () => { + let originContext; + + beforeEach(() => { + assumeConnectorRoleStub = sinon.stub().resolves({ + roleArn: 'arn:aws:iam::120569600543:role/AdobeLLMOptimizerCloudFrontConnectorRole', + accountId: '120569600543', + credentials: { accessKeyId: 'AKIA', secretAccessKey: 'secret', sessionToken: 'token' }, + }); + createEdgeOptimizeOriginStub = sinon.stub().resolves({ + created: true, alreadyExisted: false, originId: 'EdgeOptimize_Origin', + }); + originContext = { + ...mockContext, + params: { siteId: TEST_SITE_ID }, + data: { + accountId: '120569600543', + externalId: '7ff9518a-cf59-40b4-aa53-68a3cb2e24a5', + distributionId: 'E2EXAMPLE123', + }, + env: {}, + }; + }); + + it('creates the origin and returns the result', async () => { + const result = await controller.createEdgeOptimizeOrigin(originContext); + + expect(result.status).to.equal(200); + const body = await result.json(); + expect(body).to.deep.equal({ created: true, alreadyExisted: false, originId: 'EdgeOptimize_Origin' }); + expect(assumeConnectorRoleStub.calledOnce).to.equal(true); + expect(createEdgeOptimizeOriginStub.calledOnceWith(sinon.match.any, 'E2EXAMPLE123', 'dev.edgeoptimize.net')).to.equal(true); + }); + + it('passes the env-driven origin domain when set', async () => { + await controller.createEdgeOptimizeOrigin({ + ...originContext, + env: { EDGE_OPTIMIZE_ORIGIN_DOMAIN: 'live.edgeoptimize.net' }, + }); + + expect(createEdgeOptimizeOriginStub.calledOnceWith(sinon.match.any, 'E2EXAMPLE123', 'live.edgeoptimize.net')).to.equal(true); + }); + + it('is idempotent when the origin already exists', async () => { + createEdgeOptimizeOriginStub = sinon.stub().resolves({ + created: false, alreadyExisted: true, originId: 'EdgeOptimize_Origin', + }); + + const result = await controller.createEdgeOptimizeOrigin(originContext); + const body = await result.json(); + expect(body.alreadyExisted).to.equal(true); + }); + + it('returns 400 for an invalid account id', async () => { + const result = await controller.createEdgeOptimizeOrigin({ ...originContext, data: { ...originContext.data, accountId: '123' } }); + expect(result.status).to.equal(400); + }); + + it('returns 400 when the external id is missing', async () => { + const result = await controller.createEdgeOptimizeOrigin({ ...originContext, data: { accountId: '120569600543', distributionId: 'E2EXAMPLE123' } }); + expect(result.status).to.equal(400); + }); + + it('returns 400 when the distributionId is missing', async () => { + const result = await controller.createEdgeOptimizeOrigin({ ...originContext, data: { accountId: '120569600543', externalId: 'ext' } }); + expect(result.status).to.equal(400); + const body = await result.json(); + expect(body.message).to.include('distributionId'); + }); + + it('returns 400 when the AWS call fails', async () => { + createEdgeOptimizeOriginStub = sinon.stub().rejects(new Error('UpdateDistribution failed')); + const result = await controller.createEdgeOptimizeOrigin(originContext); + expect(result.status).to.equal(400); + const body = await result.json(); + expect(body.message).to.include('UpdateDistribution failed'); + }); + + it('returns 404 when the site is not found', async () => { + mockDataAccess.Site.findById.resolves(null); + const result = await controller.createEdgeOptimizeOrigin(originContext); + expect(result.status).to.equal(404); + }); + + it('returns 403 when the user lacks access to the site', async () => { + const deniedController = controllerWithAccessDenied(mockContext); + const result = await deniedController.createEdgeOptimizeOrigin(originContext); + expect(result.status).to.equal(403); + }); + + it('returns 403 when the user is not an LLMO administrator', async () => { + const LlmoControllerNoAdmin = await esmock('../../../src/controllers/llmo/llmo.js', { + '../../../src/support/access-control-util.js': createMockAccessControlUtil(true, true, false), + '@adobe/spacecat-shared-http-utils': mockHttpUtils, + '../../../src/support/cached-response.js': mockCachedResponse, + ...getCommonMocks(), + }); + const result = await LlmoControllerNoAdmin(mockContext) + .createEdgeOptimizeOrigin(originContext); + expect(result.status).to.equal(403); + }); + }); + + describe('createEdgeOptimizeRoutingFunction', () => { + let functionContext; + + beforeEach(() => { + assumeConnectorRoleStub = sinon.stub().resolves({ + roleArn: 'arn:aws:iam::120569600543:role/AdobeLLMOptimizerCloudFrontConnectorRole', + accountId: '120569600543', + credentials: { accessKeyId: 'AKIA', secretAccessKey: 'secret', sessionToken: 'token' }, + }); + getDistributionConfigStub = sinon.stub().resolves({ + origins: [], + defaultCacheBehavior: { pathPattern: 'Default (*)', targetOriginId: 'origin-aem' }, + cacheBehaviors: [], + }); + createEdgeOptimizeRoutingFunctionStub = sinon.stub().resolves({ + name: 'edgeoptimize-routing', created: true, stage: 'LIVE', + }); + functionContext = { + ...mockContext, + params: { siteId: TEST_SITE_ID }, + data: { + accountId: '120569600543', + externalId: '7ff9518a-cf59-40b4-aa53-68a3cb2e24a5', + distributionId: 'E2EXAMPLE123', + }, + env: {}, + }; + }); + + it('creates the routing function using the default origin id', async () => { + const result = await controller.createEdgeOptimizeRoutingFunction(functionContext); + + expect(result.status).to.equal(200); + const body = await result.json(); + expect(body).to.deep.equal({ name: 'edgeoptimize-routing', created: true, stage: 'LIVE' }); + expect(createEdgeOptimizeRoutingFunctionStub.calledOnceWith(sinon.match.any, 'origin-aem', null)).to.equal(true); + }); + + it('returns 400 when the default cache behavior has no target origin', async () => { + getDistributionConfigStub = sinon.stub().resolves({ + origins: [], defaultCacheBehavior: null, cacheBehaviors: [], + }); + const result = await controller.createEdgeOptimizeRoutingFunction(functionContext); + expect(result.status).to.equal(400); + const body = await result.json(); + expect(body.message).to.include('default cache behavior'); + }); + + it('returns 400 for an invalid account id', async () => { + const result = await controller.createEdgeOptimizeRoutingFunction({ ...functionContext, data: { ...functionContext.data, accountId: '123' } }); + expect(result.status).to.equal(400); + }); + + it('returns 400 when the external id is missing', async () => { + const result = await controller.createEdgeOptimizeRoutingFunction({ ...functionContext, data: { accountId: '120569600543', distributionId: 'E2EXAMPLE123' } }); + expect(result.status).to.equal(400); + }); + + it('returns 400 when the distributionId is missing', async () => { + const result = await controller.createEdgeOptimizeRoutingFunction({ ...functionContext, data: { accountId: '120569600543', externalId: 'ext' } }); + expect(result.status).to.equal(400); + }); + + it('returns 400 when the AWS call fails', async () => { + createEdgeOptimizeRoutingFunctionStub = sinon.stub().rejects(new Error('CreateFunction failed')); + const result = await controller.createEdgeOptimizeRoutingFunction(functionContext); + expect(result.status).to.equal(400); + const body = await result.json(); + expect(body.message).to.include('CreateFunction failed'); + }); + + it('returns 404 when the site is not found', async () => { + mockDataAccess.Site.findById.resolves(null); + const result = await controller.createEdgeOptimizeRoutingFunction(functionContext); + expect(result.status).to.equal(404); + }); + + it('returns 403 when the user lacks access to the site', async () => { + const deniedController = controllerWithAccessDenied(mockContext); + const result = await deniedController.createEdgeOptimizeRoutingFunction(functionContext); + expect(result.status).to.equal(403); + }); + + it('returns 403 when the user is not an LLMO administrator', async () => { + const LlmoControllerNoAdmin = await esmock('../../../src/controllers/llmo/llmo.js', { + '../../../src/support/access-control-util.js': createMockAccessControlUtil(true, true, false), + '@adobe/spacecat-shared-http-utils': mockHttpUtils, + '../../../src/support/cached-response.js': mockCachedResponse, + ...getCommonMocks(), + }); + const result = await LlmoControllerNoAdmin(mockContext) + .createEdgeOptimizeRoutingFunction(functionContext); + expect(result.status).to.equal(403); + }); + }); + + describe('applyEdgeOptimizeCache', () => { + let cacheContext; + + beforeEach(() => { + assumeConnectorRoleStub = sinon.stub().resolves({ + roleArn: 'arn:aws:iam::120569600543:role/AdobeLLMOptimizerCloudFrontConnectorRole', + accountId: '120569600543', + credentials: { accessKeyId: 'AKIA', secretAccessKey: 'secret', sessionToken: 'token' }, + }); + applyEdgeOptimizeCacheHeadersStub = sinon.stub().resolves({ + policyId: 'cp-1', updated: true, alreadyForwarded: false, + }); + cacheContext = { + ...mockContext, + params: { siteId: TEST_SITE_ID }, + data: { + accountId: '120569600543', + externalId: '7ff9518a-cf59-40b4-aa53-68a3cb2e24a5', + distributionId: 'E2EXAMPLE123', + pathPattern: '/api/*', + }, + env: {}, + }; + }); + + it('applies the cache headers to the selected behavior', async () => { + const result = await controller.applyEdgeOptimizeCache(cacheContext); + + expect(result.status).to.equal(200); + const body = await result.json(); + expect(body.policyId).to.equal('cp-1'); + expect(applyEdgeOptimizeCacheHeadersStub.calledOnceWith(sinon.match.any, 'E2EXAMPLE123', '/api/*')).to.equal(true); + }); + + it('defaults the behavior to "default" when pathPattern is omitted', async () => { + await controller.applyEdgeOptimizeCache({ + ...cacheContext, + data: { ...cacheContext.data, pathPattern: undefined }, + }); + expect(applyEdgeOptimizeCacheHeadersStub.calledOnceWith(sinon.match.any, 'E2EXAMPLE123', 'default')).to.equal(true); + }); + + it('returns 400 for an invalid account id', async () => { + const result = await controller.applyEdgeOptimizeCache({ ...cacheContext, data: { ...cacheContext.data, accountId: '123' } }); + expect(result.status).to.equal(400); + }); + + it('returns 400 when the external id is missing', async () => { + const result = await controller.applyEdgeOptimizeCache({ ...cacheContext, data: { accountId: '120569600543', distributionId: 'E2EXAMPLE123' } }); + expect(result.status).to.equal(400); + }); + + it('returns 400 when the distributionId is missing', async () => { + const result = await controller.applyEdgeOptimizeCache({ ...cacheContext, data: { accountId: '120569600543', externalId: 'ext' } }); + expect(result.status).to.equal(400); + }); + + it('returns 400 when the AWS call fails', async () => { + applyEdgeOptimizeCacheHeadersStub = sinon.stub().rejects(new Error('UpdateCachePolicy failed')); + const result = await controller.applyEdgeOptimizeCache(cacheContext); + expect(result.status).to.equal(400); + const body = await result.json(); + expect(body.message).to.include('UpdateCachePolicy failed'); + }); + + it('returns 404 when the site is not found', async () => { + mockDataAccess.Site.findById.resolves(null); + const result = await controller.applyEdgeOptimizeCache(cacheContext); + expect(result.status).to.equal(404); + }); + + it('returns 403 when the user lacks access to the site', async () => { + const deniedController = controllerWithAccessDenied(mockContext); + const result = await deniedController.applyEdgeOptimizeCache(cacheContext); + expect(result.status).to.equal(403); + }); + + it('returns 403 when the user is not an LLMO administrator', async () => { + const LlmoControllerNoAdmin = await esmock('../../../src/controllers/llmo/llmo.js', { + '../../../src/support/access-control-util.js': createMockAccessControlUtil(true, true, false), + '@adobe/spacecat-shared-http-utils': mockHttpUtils, + '../../../src/support/cached-response.js': mockCachedResponse, + ...getCommonMocks(), + }); + const result = await LlmoControllerNoAdmin(mockContext).applyEdgeOptimizeCache(cacheContext); + expect(result.status).to.equal(403); + }); + }); + + describe('createEdgeOptimizeLambda', () => { + let lambdaContext; + + beforeEach(() => { + assumeConnectorRoleStub = sinon.stub().resolves({ + roleArn: 'arn:aws:iam::120569600543:role/AdobeLLMOptimizerCloudFrontConnectorRole', + accountId: '120569600543', + credentials: { accessKeyId: 'AKIA', secretAccessKey: 'secret', sessionToken: 'token' }, + }); + createEdgeOptimizeLambdaStub = sinon.stub().resolves({ + functionArn: 'arn:fn', + versionArn: 'arn:fn:1', + version: '1', + roleArn: 'arn:role', + created: true, + }); + lambdaContext = { + ...mockContext, + params: { siteId: TEST_SITE_ID }, + data: { accountId: '120569600543', externalId: '7ff9518a-cf59-40b4-aa53-68a3cb2e24a5' }, + env: {}, + }; + }); + + it('creates the Lambda@Edge function and returns the versioned ARN', async () => { + const result = await controller.createEdgeOptimizeLambda(lambdaContext); + + expect(result.status).to.equal(200); + const body = await result.json(); + expect(body.versionArn).to.equal('arn:fn:1'); + expect(body.version).to.equal('1'); + expect(createEdgeOptimizeLambdaStub.calledOnceWith(sinon.match.any, '120569600543')).to.equal(true); + }); + + it('returns 400 for an invalid account id', async () => { + const result = await controller.createEdgeOptimizeLambda({ ...lambdaContext, data: { accountId: '123', externalId: 'ext' } }); + expect(result.status).to.equal(400); + }); + + it('returns 400 when the external id is missing', async () => { + const result = await controller.createEdgeOptimizeLambda({ ...lambdaContext, data: { accountId: '120569600543' } }); + expect(result.status).to.equal(400); + }); + + it('returns 400 when the AWS call fails', async () => { + createEdgeOptimizeLambdaStub = sinon.stub().rejects(new Error('CreateRole failed')); + const result = await controller.createEdgeOptimizeLambda(lambdaContext); + expect(result.status).to.equal(400); + const body = await result.json(); + expect(body.message).to.include('CreateRole failed'); + }); + + it('returns 404 when the site is not found', async () => { + mockDataAccess.Site.findById.resolves(null); + const result = await controller.createEdgeOptimizeLambda(lambdaContext); + expect(result.status).to.equal(404); + }); + + it('returns 403 when the user lacks access to the site', async () => { + const deniedController = controllerWithAccessDenied(mockContext); + const result = await deniedController.createEdgeOptimizeLambda(lambdaContext); + expect(result.status).to.equal(403); + }); + + it('returns 403 when the user is not an LLMO administrator', async () => { + const LlmoControllerNoAdmin = await esmock('../../../src/controllers/llmo/llmo.js', { + '../../../src/support/access-control-util.js': createMockAccessControlUtil(true, true, false), + '@adobe/spacecat-shared-http-utils': mockHttpUtils, + '../../../src/support/cached-response.js': mockCachedResponse, + ...getCommonMocks(), + }); + const result = await LlmoControllerNoAdmin(mockContext) + .createEdgeOptimizeLambda(lambdaContext); + expect(result.status).to.equal(403); + }); + }); + + describe('applyEdgeOptimizeAssociations', () => { + let associateContext; + + beforeEach(() => { + assumeConnectorRoleStub = sinon.stub().resolves({ + roleArn: 'arn:aws:iam::120569600543:role/AdobeLLMOptimizerCloudFrontConnectorRole', + accountId: '120569600543', + credentials: { accessKeyId: 'AKIA', secretAccessKey: 'secret', sessionToken: 'token' }, + }); + applyEdgeOptimizeAssociationsStub = sinon.stub().resolves({ + cfFunctionArn: 'arn:cf-fn', + lambdaArn: 'arn:aws:lambda:us-east-1:120569600543:function:edgeoptimize-origin:1', + }); + associateContext = { + ...mockContext, + params: { siteId: TEST_SITE_ID }, + data: { + accountId: '120569600543', + externalId: '7ff9518a-cf59-40b4-aa53-68a3cb2e24a5', + distributionId: 'E2EXAMPLE123', + pathPattern: '/api/*', + lambdaVersionArn: 'arn:aws:lambda:us-east-1:120569600543:function:edgeoptimize-origin:1', + }, + env: {}, + }; + }); + + it('associates the function and lambda onto the selected behavior', async () => { + const result = await controller.applyEdgeOptimizeAssociations(associateContext); + + expect(result.status).to.equal(200); + const body = await result.json(); + expect(body.cfFunctionArn).to.equal('arn:cf-fn'); + expect(applyEdgeOptimizeAssociationsStub.calledOnceWith( + sinon.match.any, + 'E2EXAMPLE123', + '/api/*', + 'arn:aws:lambda:us-east-1:120569600543:function:edgeoptimize-origin:1', + )).to.equal(true); + }); + + it('returns 400 when the lambdaVersionArn is missing', async () => { + const result = await controller.applyEdgeOptimizeAssociations({ + ...associateContext, + data: { ...associateContext.data, lambdaVersionArn: undefined }, + }); + expect(result.status).to.equal(400); + const body = await result.json(); + expect(body.message).to.include('lambdaVersionArn'); + }); + + it('returns 400 for an invalid account id', async () => { + const result = await controller.applyEdgeOptimizeAssociations({ ...associateContext, data: { ...associateContext.data, accountId: '123' } }); + expect(result.status).to.equal(400); + }); + + it('returns 400 when the external id is missing', async () => { + const result = await controller.applyEdgeOptimizeAssociations({ ...associateContext, data: { ...associateContext.data, externalId: '' } }); + expect(result.status).to.equal(400); + }); + + it('returns 400 when the distributionId is missing', async () => { + const result = await controller.applyEdgeOptimizeAssociations({ ...associateContext, data: { ...associateContext.data, distributionId: '' } }); + expect(result.status).to.equal(400); + }); + + it('returns 400 when the AWS call fails (conflict)', async () => { + applyEdgeOptimizeAssociationsStub = sinon.stub().rejects(new Error('already has a different viewer-request function')); + const result = await controller.applyEdgeOptimizeAssociations(associateContext); + expect(result.status).to.equal(400); + const body = await result.json(); + expect(body.message).to.include('viewer-request'); + }); + + it('returns 404 when the site is not found', async () => { + mockDataAccess.Site.findById.resolves(null); + const result = await controller.applyEdgeOptimizeAssociations(associateContext); + expect(result.status).to.equal(404); + }); + + it('returns 403 when the user lacks access to the site', async () => { + const deniedController = controllerWithAccessDenied(mockContext); + const result = await deniedController.applyEdgeOptimizeAssociations(associateContext); + expect(result.status).to.equal(403); + }); + + it('returns 403 when the user is not an LLMO administrator', async () => { + const LlmoControllerNoAdmin = await esmock('../../../src/controllers/llmo/llmo.js', { + '../../../src/support/access-control-util.js': createMockAccessControlUtil(true, true, false), + '@adobe/spacecat-shared-http-utils': mockHttpUtils, + '../../../src/support/cached-response.js': mockCachedResponse, + ...getCommonMocks(), + }); + const result = await LlmoControllerNoAdmin(mockContext) + .applyEdgeOptimizeAssociations(associateContext); + expect(result.status).to.equal(403); + }); + }); + + describe('verifyEdgeOptimizeRouting', () => { + let verifyContext; + + beforeEach(() => { + assumeConnectorRoleStub = sinon.stub().resolves({ + roleArn: 'arn:aws:iam::120569600543:role/AdobeLLMOptimizerCloudFrontConnectorRole', + accountId: '120569600543', + credentials: { accessKeyId: 'AKIA', secretAccessKey: 'secret', sessionToken: 'token' }, + }); + listCloudFrontDistributionsStub = sinon.stub().resolves([ + { + id: 'E2EXAMPLE123', domainName: 'd111111abcdef8.cloudfront.net', aliases: [], status: 'Deployed', enabled: true, comment: '', + }, + ]); + verifyEdgeOptimizeRoutingStub = sinon.stub().resolves({ + passed: true, + requestId: 'req-123', + details: { bot: { status: 200, headers: {} }, human: { status: 200, headers: {} } }, + }); + verifyContext = { + ...mockContext, + params: { siteId: TEST_SITE_ID }, + data: { + accountId: '120569600543', + externalId: '7ff9518a-cf59-40b4-aa53-68a3cb2e24a5', + distributionId: 'E2EXAMPLE123', + }, + env: {}, + }; + }); + + it('resolves the domain from the distribution and verifies routing', async () => { + const result = await controller.verifyEdgeOptimizeRouting(verifyContext); + + expect(result.status).to.equal(200); + const body = await result.json(); + expect(body.passed).to.equal(true); + expect(body.requestId).to.equal('req-123'); + expect(verifyEdgeOptimizeRoutingStub.calledOnceWith('https://d111111abcdef8.cloudfront.net/')).to.equal(true); + expect(listCloudFrontDistributionsStub.calledOnce).to.equal(true); + }); + + it('uses an explicit domain when provided (no distribution lookup)', async () => { + await controller.verifyEdgeOptimizeRouting({ ...verifyContext, data: { ...verifyContext.data, domain: 'www.example.com' } }); + expect(listCloudFrontDistributionsStub.called).to.equal(false); + expect(verifyEdgeOptimizeRoutingStub.calledOnceWith('https://www.example.com/')).to.equal(true); + }); + + it('returns 400 when the distribution domain cannot be resolved', async () => { + listCloudFrontDistributionsStub = sinon.stub().resolves([]); + const result = await controller.verifyEdgeOptimizeRouting(verifyContext); + expect(result.status).to.equal(400); + const body = await result.json(); + expect(body.message).to.include('domain'); + }); + + it('returns 400 for an invalid account id', async () => { + const result = await controller.verifyEdgeOptimizeRouting({ ...verifyContext, data: { ...verifyContext.data, accountId: '123' } }); + expect(result.status).to.equal(400); + }); + + it('returns 400 when the external id is missing', async () => { + const result = await controller.verifyEdgeOptimizeRouting({ ...verifyContext, data: { accountId: '120569600543', distributionId: 'E2EXAMPLE123' } }); + expect(result.status).to.equal(400); + }); + + it('returns 400 when the distributionId is missing', async () => { + const result = await controller.verifyEdgeOptimizeRouting({ ...verifyContext, data: { accountId: '120569600543', externalId: 'ext' } }); + expect(result.status).to.equal(400); + }); + + it('returns 400 when the verify call fails', async () => { + verifyEdgeOptimizeRoutingStub = sinon.stub().rejects(new Error('fetch failed')); + const result = await controller.verifyEdgeOptimizeRouting(verifyContext); + expect(result.status).to.equal(400); + const body = await result.json(); + expect(body.message).to.include('fetch failed'); + }); + + it('returns 404 when the site is not found', async () => { + mockDataAccess.Site.findById.resolves(null); + const result = await controller.verifyEdgeOptimizeRouting(verifyContext); + expect(result.status).to.equal(404); + }); + + it('returns 403 when the user lacks access to the site', async () => { + const deniedController = controllerWithAccessDenied(mockContext); + const result = await deniedController.verifyEdgeOptimizeRouting(verifyContext); + expect(result.status).to.equal(403); + }); + + it('returns 403 when the user is not an LLMO administrator', async () => { + const LlmoControllerNoAdmin = await esmock('../../../src/controllers/llmo/llmo.js', { + '../../../src/support/access-control-util.js': createMockAccessControlUtil(true, true, false), + '@adobe/spacecat-shared-http-utils': mockHttpUtils, + '../../../src/support/cached-response.js': mockCachedResponse, + ...getCommonMocks(), + }); + const result = await LlmoControllerNoAdmin(mockContext) + .verifyEdgeOptimizeRouting(verifyContext); + expect(result.status).to.equal(403); + }); + }); + describe('getStrategy', () => { const mockStrategyData = { opportunities: [ diff --git a/test/routes/index.test.js b/test/routes/index.test.js index 675e6fce7c..4817b17c9f 100755 --- a/test/routes/index.test.js +++ b/test/routes/index.test.js @@ -332,6 +332,12 @@ describe('getRouteHandlers', () => { checkEdgeOptimizePrerequisites: () => null, getEdgeOptimizeOrigins: () => null, getEdgeOptimizeBehaviors: () => null, + createEdgeOptimizeOrigin: () => null, + createEdgeOptimizeRoutingFunction: () => null, + applyEdgeOptimizeCache: () => null, + createEdgeOptimizeLambda: () => null, + applyEdgeOptimizeAssociations: () => null, + verifyEdgeOptimizeRouting: () => null, getEdgeConfig: () => null, createOrUpdateStageEdgeConfig: () => null, checkEdgeOptimizeStatus: () => null, @@ -1072,6 +1078,12 @@ describe('getRouteHandlers', () => { 'POST /sites/:siteId/llmo/edge-optimize/prerequisites', 'POST /sites/:siteId/llmo/edge-optimize/origins', 'POST /sites/:siteId/llmo/edge-optimize/behaviors', + 'POST /sites/:siteId/llmo/edge-optimize/create-origin', + 'POST /sites/:siteId/llmo/edge-optimize/create-function', + 'POST /sites/:siteId/llmo/edge-optimize/apply-cache', + 'POST /sites/:siteId/llmo/edge-optimize/create-lambda', + 'POST /sites/:siteId/llmo/edge-optimize/apply-associations', + 'POST /sites/:siteId/llmo/edge-optimize/verify', 'GET /sites/:siteId/llmo/edge-optimize-status', 'GET /sites/:siteId/llmo/probes/edge-optimize', 'GET /sites/:siteId/llmo/strategy', diff --git a/test/support/edge-optimize.test.js b/test/support/edge-optimize.test.js index 96d3508e22..2e94376b69 100644 --- a/test/support/edge-optimize.test.js +++ b/test/support/edge-optimize.test.js @@ -17,12 +17,30 @@ import esmock from 'esmock'; describe('edge-optimize support', () => { let stsSendStub; let cfSendStub; + let iamSendStub; + let lambdaSendStub; let edgeOptimize; beforeEach(async function setup() { this.timeout(30000); stsSendStub = sinon.stub(); cfSendStub = sinon.stub(); + iamSendStub = sinon.stub(); + lambdaSendStub = sinon.stub(); + // Each command in a mocked module is a constructor FUNCTION (not a class) — eslint forbids + // multiple class declarations in one file, so we capture the command name + input on `this`. + const cfCommand = (Name) => function CloudFrontCommand(input) { + this.input = input; + this.commandName = Name; + }; + const iamCommand = (Name) => function IamCommand(input) { + this.input = input; + this.commandName = Name; + }; + const lambdaCommand = (Name) => function LambdaCommand(input) { + this.input = input; + this.commandName = Name; + }; edgeOptimize = await esmock('../../src/support/edge-optimize.js', { '@aws-sdk/client-sts': { STSClient: function STSClient() { @@ -37,12 +55,36 @@ describe('edge-optimize support', () => { this.config = config; this.send = (cmd) => cfSendStub(cmd); }, - ListDistributionsCommand: function ListDistributionsCommand(input) { - this.input = input; + ListDistributionsCommand: cfCommand('ListDistributions'), + GetDistributionConfigCommand: cfCommand('GetDistributionConfig'), + GetCachePolicyConfigCommand: cfCommand('GetCachePolicyConfig'), + UpdateCachePolicyCommand: cfCommand('UpdateCachePolicy'), + CreateFunctionCommand: cfCommand('CreateFunction'), + UpdateFunctionCommand: cfCommand('UpdateFunction'), + DescribeFunctionCommand: cfCommand('DescribeFunction'), + PublishFunctionCommand: cfCommand('PublishFunction'), + UpdateDistributionCommand: cfCommand('UpdateDistribution'), + }, + '@aws-sdk/client-iam': { + IAMClient: function IAMClient(config) { + this.config = config; + this.send = (cmd) => iamSendStub(cmd); }, - GetDistributionConfigCommand: function GetDistributionConfigCommand(input) { - this.input = input; + CreateRoleCommand: iamCommand('CreateRole'), + GetRoleCommand: iamCommand('GetRole'), + PutRolePolicyCommand: iamCommand('PutRolePolicy'), + UpdateAssumeRolePolicyCommand: iamCommand('UpdateAssumeRolePolicy'), + }, + '@aws-sdk/client-lambda': { + LambdaClient: function LambdaClient(config) { + this.config = config; + this.send = (cmd) => lambdaSendStub(cmd); }, + CreateFunctionCommand: lambdaCommand('CreateFunction'), + UpdateFunctionCodeCommand: lambdaCommand('UpdateFunctionCode'), + GetFunctionCommand: lambdaCommand('GetFunction'), + GetFunctionConfigurationCommand: lambdaCommand('GetFunctionConfiguration'), + PublishVersionCommand: lambdaCommand('PublishVersion'), }, }); }); @@ -240,4 +282,460 @@ describe('edge-optimize support', () => { expect(cfSendStub.called).to.equal(false); }); }); + + describe('createEdgeOptimizeOrigin', () => { + it('adds the Edge Optimize origin when it does not exist', async () => { + cfSendStub.onFirstCall().resolves({ + DistributionConfig: { Origins: { Quantity: 1, Items: [{ Id: 'origin-aem', DomainName: 'origin.example.com' }] } }, + ETag: 'etag-1', + }); + cfSendStub.onSecondCall().resolves({}); + + const result = await edgeOptimize.createEdgeOptimizeOrigin({}, 'E2EXAMPLE', 'dev.edgeoptimize.net'); + + expect(result).to.deep.equal({ created: true, alreadyExisted: false, originId: 'EdgeOptimize_Origin' }); + expect(cfSendStub.secondCall.args[0].commandName).to.equal('UpdateDistribution'); + const update = cfSendStub.secondCall.args[0].input; + expect(update.IfMatch).to.equal('etag-1'); + const added = update.DistributionConfig.Origins.Items.find((o) => o.Id === 'EdgeOptimize_Origin'); + expect(added.DomainName).to.equal('dev.edgeoptimize.net'); + expect(added.CustomOriginConfig.OriginProtocolPolicy).to.equal('https-only'); + }); + + it('is idempotent when the origin already exists by id', async () => { + cfSendStub.resolves({ + DistributionConfig: { Origins: { Quantity: 1, Items: [{ Id: 'EdgeOptimize_Origin', DomainName: 'x' }] } }, + ETag: 'etag-1', + }); + + const result = await edgeOptimize.createEdgeOptimizeOrigin({}, 'E2EXAMPLE'); + + expect(result).to.deep.equal({ created: false, alreadyExisted: true, originId: 'EdgeOptimize_Origin' }); + expect(cfSendStub.calledOnce).to.equal(true); // never updated + }); + + it('is idempotent when an origin already uses the EO domain', async () => { + cfSendStub.resolves({ + DistributionConfig: { Origins: { Items: [{ Id: 'custom', DomainName: 'dev.edgeoptimize.net' }] } }, + ETag: 'etag-1', + }); + + const result = await edgeOptimize.createEdgeOptimizeOrigin({}, 'E2EXAMPLE', 'dev.edgeoptimize.net'); + + expect(result.alreadyExisted).to.equal(true); + expect(cfSendStub.calledOnce).to.equal(true); + }); + + it('throws when the distribution id is missing', async () => { + let error; + try { + await edgeOptimize.createEdgeOptimizeOrigin({}, ''); + } catch (e) { + error = e; + } + expect(error.message).to.include('distributionId'); + expect(cfSendStub.called).to.equal(false); + }); + }); + + describe('buildRoutingFunctionCode', () => { + it('embeds the default origin id and null targeted paths', () => { + const code = edgeOptimize.buildRoutingFunctionCode('origin-aem'); + expect(code).to.include('{ "originId": "origin-aem" }'); + expect(code).to.include('var TARGETED_PATHS = null;'); + expect(code).to.include("import cf from 'cloudfront';"); + }); + + it('embeds explicit targeted paths as JSON', () => { + const code = edgeOptimize.buildRoutingFunctionCode('origin-aem', ['/a', '/b']); + expect(code).to.include('var TARGETED_PATHS = ["/a","/b"];'); + }); + }); + + describe('createEdgeOptimizeRoutingFunction', () => { + it('creates and publishes a new function when none exists', async () => { + cfSendStub.onFirstCall().rejects(Object.assign(new Error('not found'), { name: 'NoSuchFunctionExists' })); + cfSendStub.onSecondCall().resolves({ ETag: 'fn-etag' }); // CreateFunction + cfSendStub.onThirdCall().resolves({}); // PublishFunction + + const result = await edgeOptimize.createEdgeOptimizeRoutingFunction({}, 'origin-aem'); + + expect(result).to.deep.equal({ name: 'edgeoptimize-routing', created: true, stage: 'LIVE' }); + expect(cfSendStub.secondCall.args[0].commandName).to.equal('CreateFunction'); + expect(cfSendStub.thirdCall.args[0].commandName).to.equal('PublishFunction'); + expect(cfSendStub.thirdCall.args[0].input.IfMatch).to.equal('fn-etag'); + }); + + it('updates and publishes when the function already exists', async () => { + cfSendStub.onFirstCall().resolves({ ETag: 'dev-etag' }); // DescribeFunction DEVELOPMENT + cfSendStub.onSecondCall().resolves({ ETag: 'updated-etag' }); // UpdateFunction + cfSendStub.onThirdCall().resolves({}); // PublishFunction + + const result = await edgeOptimize.createEdgeOptimizeRoutingFunction({}, 'origin-aem'); + + expect(result.created).to.equal(false); + expect(cfSendStub.secondCall.args[0].commandName).to.equal('UpdateFunction'); + expect(cfSendStub.thirdCall.args[0].input.IfMatch).to.equal('updated-etag'); + }); + + it('throws when defaultOriginId is missing', async () => { + let error; + try { + await edgeOptimize.createEdgeOptimizeRoutingFunction({}, ''); + } catch (e) { + error = e; + } + expect(error.message).to.include('defaultOriginId'); + expect(cfSendStub.called).to.equal(false); + }); + + it('rethrows unexpected describe errors', async () => { + cfSendStub.onFirstCall().rejects(new Error('boom')); + let error; + try { + await edgeOptimize.createEdgeOptimizeRoutingFunction({}, 'origin-aem'); + } catch (e) { + error = e; + } + expect(error.message).to.equal('boom'); + }); + }); + + describe('applyEdgeOptimizeCacheHeaders', () => { + it('adds the EO headers to the behavior cache policy whitelist', async () => { + cfSendStub.onFirstCall().resolves({ + DistributionConfig: { DefaultCacheBehavior: { CachePolicyId: 'cp-1' } }, + }); + cfSendStub.onSecondCall().resolves({ + CachePolicyConfig: { + Name: 'my-policy', + MinTTL: 60, + ParametersInCacheKeyAndForwardedToOrigin: { + HeadersConfig: { HeaderBehavior: 'whitelist', Headers: { Quantity: 1, Items: ['accept'] } }, + }, + }, + ETag: 'cp-etag', + }); + cfSendStub.onThirdCall().resolves({}); + + const result = await edgeOptimize.applyEdgeOptimizeCacheHeaders({}, 'E2EXAMPLE', 'default'); + + expect(result.policyId).to.equal('cp-1'); + expect(result.updated).to.equal(true); + expect(cfSendStub.thirdCall.args[0].commandName).to.equal('UpdateCachePolicy'); + const updated = cfSendStub.thirdCall.args[0].input.CachePolicyConfig; + expect(updated.MinTTL).to.equal(0); + const items = updated.ParametersInCacheKeyAndForwardedToOrigin.HeadersConfig.Headers.Items; + expect(items).to.include('x-edgeoptimize-config'); + expect(items).to.include('x-edgeoptimize-url'); + }); + + it('is a no-op when headers are already forwarded and MinTTL is 0', async () => { + cfSendStub.onFirstCall().resolves({ + DistributionConfig: { DefaultCacheBehavior: { CachePolicyId: 'cp-1' } }, + }); + cfSendStub.onSecondCall().resolves({ + CachePolicyConfig: { + Name: 'my-policy', + MinTTL: 0, + ParametersInCacheKeyAndForwardedToOrigin: { + HeadersConfig: { + HeaderBehavior: 'whitelist', + Headers: { Quantity: 2, Items: ['x-edgeoptimize-config', 'x-edgeoptimize-url'] }, + }, + }, + }, + ETag: 'cp-etag', + }); + + const result = await edgeOptimize.applyEdgeOptimizeCacheHeaders({}, 'E2EXAMPLE', 'default'); + + expect(result).to.deep.equal({ policyId: 'cp-1', updated: false, alreadyForwarded: true }); + expect(cfSendStub.calledTwice).to.equal(true); // never updated + }); + + it('treats an allViewer header policy as already forwarded', async () => { + cfSendStub.onFirstCall().resolves({ + DistributionConfig: { DefaultCacheBehavior: { CachePolicyId: 'cp-1' } }, + }); + cfSendStub.onSecondCall().resolves({ + CachePolicyConfig: { + Name: 'p', + MinTTL: 0, + ParametersInCacheKeyAndForwardedToOrigin: { HeadersConfig: { HeaderBehavior: 'allViewer' } }, + }, + ETag: 'cp-etag', + }); + + const result = await edgeOptimize.applyEdgeOptimizeCacheHeaders({}, 'E2EXAMPLE', 'default'); + expect(result.updated).to.equal(false); + expect(result.alreadyForwarded).to.equal(true); + }); + + it('throws when the behavior has no cache policy (legacy/managed)', async () => { + cfSendStub.onFirstCall().resolves({ + DistributionConfig: { DefaultCacheBehavior: { ForwardedValues: {} } }, + }); + let error; + try { + await edgeOptimize.applyEdgeOptimizeCacheHeaders({}, 'E2EXAMPLE', 'default'); + } catch (e) { + error = e; + } + expect(error.message).to.include('custom cache policy'); + }); + + it('targets a named (non-default) behavior', async () => { + cfSendStub.onFirstCall().resolves({ + DistributionConfig: { + DefaultCacheBehavior: { CachePolicyId: 'cp-default' }, + CacheBehaviors: { Items: [{ PathPattern: '/api/*', CachePolicyId: 'cp-api' }] }, + }, + }); + cfSendStub.onSecondCall().resolves({ + CachePolicyConfig: { + Name: 'api', + MinTTL: 0, + ParametersInCacheKeyAndForwardedToOrigin: { HeadersConfig: { HeaderBehavior: 'none' } }, + }, + ETag: 'cp-etag', + }); + cfSendStub.onThirdCall().resolves({}); + + const result = await edgeOptimize.applyEdgeOptimizeCacheHeaders({}, 'E2EXAMPLE', '/api/*'); + expect(result.policyId).to.equal('cp-api'); + expect(cfSendStub.secondCall.args[0].input.Id).to.equal('cp-api'); + }); + + it('throws when pathPattern is missing', async () => { + let error; + try { + await edgeOptimize.applyEdgeOptimizeCacheHeaders({}, 'E2EXAMPLE', ''); + } catch (e) { + error = e; + } + expect(error.message).to.include('pathPattern'); + }); + }); + + describe('buildLambdaZip', () => { + it('produces a zip buffer with the local-file-header signature', () => { + const zip = edgeOptimize.buildLambdaZip('index.mjs', 'console.log(1)'); + expect(Buffer.isBuffer(zip)).to.equal(true); + expect(zip.readUInt32LE(0)).to.equal(0x04034b50); + }); + }); + + describe('createEdgeOptimizeLambda', () => { + const creds = { accessKeyId: 'A', secretAccessKey: 'S', sessionToken: 'T' }; + + it('creates the role, function and publishes a version', async () => { + iamSendStub.onFirstCall().rejects(Object.assign(new Error('no role'), { name: 'NoSuchEntityException' })); + iamSendStub.onSecondCall().resolves({ Role: { Arn: 'arn:aws:iam::120569600543:role/edgeoptimize-origin-role' } }); // CreateRole + iamSendStub.onThirdCall().resolves({}); // PutRolePolicy + + // Lambda flow: GetFunction (not found) -> CreateFunction -> GetFunctionConfiguration(Active) + // -> PublishVersion + lambdaSendStub.onCall(0).rejects(Object.assign(new Error('nf'), { name: 'ResourceNotFoundException' })); + lambdaSendStub.onCall(1).resolves({ FunctionArn: 'arn:fn' }); + lambdaSendStub.onCall(2).resolves({ State: 'Active' }); + lambdaSendStub.onCall(3).resolves({ FunctionArn: 'arn:fn:1', Version: '1' }); + + const result = await edgeOptimize.createEdgeOptimizeLambda(creds, '120569600543', { roleWaitMs: 0 }); + + expect(result).to.include({ + functionArn: 'arn:fn', + versionArn: 'arn:fn:1', + version: '1', + created: true, + }); + expect(result.roleArn).to.include('edgeoptimize-origin-role'); + expect(iamSendStub.secondCall.args[0].commandName).to.equal('CreateRole'); + expect(lambdaSendStub.getCall(1).args[0].commandName).to.equal('CreateFunction'); + expect(lambdaSendStub.getCall(1).args[0].input.Role).to.include('edgeoptimize-origin-role'); + expect(lambdaSendStub.getCall(3).args[0].commandName).to.equal('PublishVersion'); + }); + + it('updates the function code when it already exists', async () => { + iamSendStub.onFirstCall().resolves({ Role: { Arn: 'arn:role' } }); // GetRole + iamSendStub.onSecondCall().resolves({}); // UpdateAssumeRolePolicy + iamSendStub.onThirdCall().resolves({}); // PutRolePolicy + + lambdaSendStub.onCall(0).resolves({}); // GetFunction (exists) + lambdaSendStub.onCall(1).resolves({ FunctionArn: 'arn:fn' }); // UpdateFunctionCode + lambdaSendStub.onCall(2).resolves({ State: 'Active' }); + lambdaSendStub.onCall(3).resolves({ FunctionArn: 'arn:fn:2', Version: '2' }); + + const result = await edgeOptimize.createEdgeOptimizeLambda(creds, '120569600543'); + + expect(result.created).to.equal(false); + expect(result.version).to.equal('2'); + expect(lambdaSendStub.getCall(1).args[0].commandName).to.equal('UpdateFunctionCode'); + }); + + it('retries CreateFunction on role-propagation errors then succeeds', async () => { + iamSendStub.onFirstCall().rejects(Object.assign(new Error('no role'), { name: 'NoSuchEntityException' })); + iamSendStub.onSecondCall().resolves({ Role: { Arn: 'arn:role' } }); + iamSendStub.onThirdCall().resolves({}); + + const roleErr = Object.assign( + new Error('The role defined for the function cannot be assumed by Lambda'), + { name: 'InvalidParameterValueException' }, + ); + lambdaSendStub.onCall(0).rejects(Object.assign(new Error('nf'), { name: 'ResourceNotFoundException' })); + lambdaSendStub.onCall(1).rejects(roleErr); // first CreateFunction attempt + lambdaSendStub.onCall(2).resolves({ FunctionArn: 'arn:fn' }); // retry succeeds + lambdaSendStub.onCall(3).resolves({ State: 'Active' }); + lambdaSendStub.onCall(4).resolves({ FunctionArn: 'arn:fn:1', Version: '1' }); + + const result = await edgeOptimize.createEdgeOptimizeLambda( + creds, + '120569600543', + { roleWaitMs: 0, retryDelayMs: 0 }, + ); + + expect(result.version).to.equal('1'); + expect(lambdaSendStub.getCall(1).args[0].commandName).to.equal('CreateFunction'); + expect(lambdaSendStub.getCall(2).args[0].commandName).to.equal('CreateFunction'); + }); + + it('throws for an invalid account id', async () => { + let error; + try { + await edgeOptimize.createEdgeOptimizeLambda(creds, '123'); + } catch (e) { + error = e; + } + expect(error.message).to.include('12-digit'); + expect(iamSendStub.called).to.equal(false); + }); + }); + + describe('applyEdgeOptimizeAssociations', () => { + const lambdaArn = 'arn:aws:lambda:us-east-1:120569600543:function:edgeoptimize-origin:1'; + + it('wires the CF function (viewer-request) and Lambda (origin req/res) onto the behavior', async () => { + cfSendStub.onFirstCall().resolves({ + FunctionSummary: { FunctionMetadata: { FunctionARN: 'arn:cf-fn' } }, + }); + cfSendStub.onSecondCall().resolves({ + DistributionConfig: { DefaultCacheBehavior: {} }, + ETag: 'dist-etag', + }); + cfSendStub.onThirdCall().resolves({}); + + const result = await edgeOptimize.applyEdgeOptimizeAssociations({}, 'E2EXAMPLE', 'default', lambdaArn); + + expect(result).to.deep.equal({ cfFunctionArn: 'arn:cf-fn', lambdaArn }); + const update = cfSendStub.thirdCall.args[0]; + expect(update.commandName).to.equal('UpdateDistribution'); + const behavior = update.input.DistributionConfig.DefaultCacheBehavior; + expect(behavior.FunctionAssociations.Items[0]).to.deep.equal({ FunctionARN: 'arn:cf-fn', EventType: 'viewer-request' }); + expect(behavior.LambdaFunctionAssociations.Quantity).to.equal(2); + expect(behavior.LambdaFunctionAssociations.Items.map((i) => i.EventType)).to.deep.equal(['origin-request', 'origin-response']); + }); + + it('throws when the CF function is not published to LIVE', async () => { + cfSendStub.onFirstCall().resolves({ FunctionSummary: {} }); + let error; + try { + await edgeOptimize.applyEdgeOptimizeAssociations({}, 'E2EXAMPLE', 'default', lambdaArn); + } catch (e) { + error = e; + } + expect(error.message).to.include('not found or not published'); + }); + + it('surfaces a conflicting viewer-request association', async () => { + cfSendStub.onFirstCall().resolves({ + FunctionSummary: { FunctionMetadata: { FunctionARN: 'arn:cf-fn' } }, + }); + cfSendStub.onSecondCall().resolves({ + DistributionConfig: { + DefaultCacheBehavior: { + FunctionAssociations: { Items: [{ EventType: 'viewer-request', FunctionARN: 'arn:other-fn' }] }, + }, + }, + ETag: 'dist-etag', + }); + let error; + try { + await edgeOptimize.applyEdgeOptimizeAssociations({}, 'E2EXAMPLE', 'default', lambdaArn); + } catch (e) { + error = e; + } + expect(error.message).to.include('already has a different viewer-request function'); + }); + + it('throws when lambdaVersionArn is missing', async () => { + let error; + try { + await edgeOptimize.applyEdgeOptimizeAssociations({}, 'E2EXAMPLE', 'default', ''); + } catch (e) { + error = e; + } + expect(error.message).to.include('lambdaVersionArn'); + expect(cfSendStub.called).to.equal(false); + }); + }); + + describe('verifyEdgeOptimizeRouting', () => { + let fetchStub; + + const makeResponse = (status, headerMap) => ({ + status, + headers: { forEach: (cb) => Object.entries(headerMap).forEach(([k, v]) => cb(v, k)) }, + arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)), + }); + + afterEach(() => { + if (fetchStub) { + fetchStub.restore(); + } + fetchStub = undefined; + }); + + it('passes when the bot response carries x-edgeoptimize-request-id and the human does not', async () => { + fetchStub = sinon.stub(global, 'fetch'); + fetchStub.onFirstCall().resolves(makeResponse(200, { 'x-edgeoptimize-request-id': 'req-123' })); + fetchStub.onSecondCall().resolves(makeResponse(200, {})); + + const result = await edgeOptimize.verifyEdgeOptimizeRouting('https://d.cloudfront.net/'); + + expect(result.passed).to.equal(true); + expect(result.requestId).to.equal('req-123'); + expect(result.details.bot.status).to.equal(200); + }); + + it('does NOT pass when only failover (x-edgeoptimize-fo) is present', async () => { + fetchStub = sinon.stub(global, 'fetch'); + fetchStub.onFirstCall().resolves(makeResponse(200, { 'x-edgeoptimize-fo': '1' })); + fetchStub.onSecondCall().resolves(makeResponse(200, {})); + + const result = await edgeOptimize.verifyEdgeOptimizeRouting('https://d.cloudfront.net/'); + + expect(result.passed).to.equal(false); + expect(result.requestId).to.equal(null); + }); + + it('does NOT pass when the human response is also optimized', async () => { + fetchStub = sinon.stub(global, 'fetch'); + fetchStub.onFirstCall().resolves(makeResponse(200, { 'x-edgeoptimize-request-id': 'req-123' })); + fetchStub.onSecondCall().resolves(makeResponse(200, { 'x-edgeoptimize-request-id': 'req-999' })); + + const result = await edgeOptimize.verifyEdgeOptimizeRouting('https://d.cloudfront.net/'); + + expect(result.passed).to.equal(false); + }); + + it('throws when url is missing', async () => { + let error; + try { + await edgeOptimize.verifyEdgeOptimizeRouting(''); + } catch (e) { + error = e; + } + expect(error.message).to.include('url'); + }); + }); }); From 59cb8770a12707caf5b6e6612b791fe0131597de Mon Sep 17 00:00:00 2001 From: Akash Bhardwaj Date: Sat, 20 Jun 2026 20:52:10 +0530 Subject: [PATCH 12/20] fix(llmo): clone AWS-managed cache policy instead of failing (P0) The Default(*) behavior commonly uses an AWS-managed cache policy, which cannot be updated (UpdateCachePolicy -> 'update is not allowed for this policy'). applyEdgeOptimizeCacheHeaders now ports the full standalone-wizard logic with all three scenarios: - legacy (ForwardedValues, no CachePolicyId): add EO headers there + MinTTL 0 - custom policy: UpdateCachePolicy to add EO headers (existing path) - managed policy: CLONE into a custom edgeoptimize-cache policy with the EO headers, then repoint the behavior to it (idempotent by name) Adds GetCachePolicy/ListCachePolicies/CreateCachePolicy. Support tests rewritten to dispatch by command name and cover all three scenarios. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/support/edge-optimize.js | 173 ++++++++++++++++++------- test/support/edge-optimize.test.js | 201 ++++++++++++++++++++--------- 2 files changed, 265 insertions(+), 109 deletions(-) diff --git a/src/support/edge-optimize.js b/src/support/edge-optimize.js index 3227fb80f1..d621b3cde4 100644 --- a/src/support/edge-optimize.js +++ b/src/support/edge-optimize.js @@ -17,6 +17,9 @@ import { ListDistributionsCommand, GetDistributionConfigCommand, GetCachePolicyConfigCommand, + GetCachePolicyCommand, + ListCachePoliciesCommand, + CreateCachePolicyCommand, UpdateCachePolicyCommand, CreateFunctionCommand, UpdateFunctionCommand, @@ -56,6 +59,8 @@ export const EDGE_OPTIMIZE_LAMBDA_FUNCTION_NAME = 'edgeoptimize-origin'; export const EDGE_OPTIMIZE_LAMBDA_ROLE_NAME = 'edgeoptimize-origin-role'; // Headers the routing CloudFront Function sets and that must reach the EO origin uncached. export const EDGE_OPTIMIZE_CACHE_HEADERS = ['x-edgeoptimize-config', 'x-edgeoptimize-url']; +// Name of the custom cache policy we create when cloning an AWS-managed policy. +export const EDGE_OPTIMIZE_CACHE_POLICY_NAME = 'edgeoptimize-cache'; const delay = (ms) => new Promise((resolve) => { setTimeout(resolve, ms); @@ -397,12 +402,16 @@ export async function createEdgeOptimizeRoutingFunction( } /** - * Ensure the Edge Optimize routing headers are forwarded by the target behavior's cache policy. + * Add the Edge Optimize routing headers to the cache key/forwarded set for the target behavior. * - * Mirrors the common path of the standalone wizard's apply-cache (the "custom policy" scenario): - * reads the cache policy attached to the selected behavior and, if the EO headers are not already - * forwarded, adds them to the policy's whitelist via UpdateCachePolicy. Setting `setMinTTLZero` - * (default true) forces MinTTL to 0 so agentic responses are not over-cached. + * Ported from the standalone wizard's detect-cache + apply-cache (server.mjs). Handles all three + * scenarios the wizard supports, because real distributions commonly use an AWS-managed policy: + * - `legacy` — behavior has no CachePolicyId (uses ForwardedValues): add EO headers there. + * - `custom` — behavior uses a customer-owned cache policy: UpdateCachePolicy to add EO headers. + * - `managed` — behavior uses an AWS-managed policy (cannot be updated → "update is not allowed + * for this policy"): CLONE it into a custom `edgeoptimize-cache` policy with EO headers and + * repoint the behavior to it. Idempotent by policy name. + * `setMinTTLZero` (default true) forces MinTTL to 0 so agentic responses are not over-cached. * * @param {object} credentials - temporary credentials from {@link assumeConnectorRole}. * @param {string} distributionId - the CloudFront distribution ID. @@ -410,7 +419,8 @@ export async function createEdgeOptimizeRoutingFunction( * @param {object} [opts] * @param {boolean} [opts.setMinTTLZero=true] - force the policy MinTTL to 0. * @param {string} [opts.region] - CloudFront control-plane region. - * @returns {Promise<{policyId: string, updated: boolean, alreadyForwarded: boolean}>} + * @returns {Promise<{scenario: string, policyId: string|null, updated: boolean, + * alreadyForwarded: boolean, reused?: boolean}>} */ export async function applyEdgeOptimizeCacheHeaders( credentials, @@ -427,64 +437,135 @@ export async function applyEdgeOptimizeCacheHeaders( const client = new CloudFrontClient({ region, credentials }); const distResult = await client.send(new GetDistributionConfigCommand({ Id: distributionId })); - const behavior = getBehaviorFromConfig(distResult.DistributionConfig, pathPattern); + const config = distResult.DistributionConfig; + const behavior = getBehaviorFromConfig(config, pathPattern); const policyId = behavior.CachePolicyId; - // TODO: edge scenarios from the standalone wizard not yet ported here: - // - legacy behaviors using ForwardedValues (no CachePolicyId) - // - AWS-managed cache policies, which must be cloned into a custom `edgeoptimize-cache` policy - // (the connector role can create custom policies but cannot mutate managed ones). - // The common path — a custom cache policy already attached to the behavior — is implemented. + // ── Scenario A: legacy (ForwardedValues, no CachePolicyId) ────────────── if (!policyId) { - throw new Error( - `Behavior '${pathPattern}' uses legacy ForwardedValues or a managed cache policy; ` - + 'attach a custom cache policy before applying Edge Optimize cache headers.', - ); - } - - const pcResult = await client.send(new GetCachePolicyConfigCommand({ Id: policyId })); - const pc = pcResult.CachePolicyConfig; - const pcEtag = pcResult.ETag; - - const params = pc.ParametersInCacheKeyAndForwardedToOrigin || {}; - const hc = params.HeadersConfig || { HeaderBehavior: 'none' }; - - let alreadyForwarded = false; - if (hc.HeaderBehavior === 'allViewer' || hc.HeaderBehavior === 'all') { - alreadyForwarded = true; - } else { - const items = hc.Headers?.Items || []; + const fv = behavior.ForwardedValues || {}; + const items = fv.Headers?.Items || []; const lower = items.map((x) => x.toLowerCase()); - alreadyForwarded = EDGE_OPTIMIZE_CACHE_HEADERS.every((h) => lower.includes(h)); - if (!alreadyForwarded) { + let changed = false; + if (!lower.includes('*')) { EDGE_OPTIMIZE_CACHE_HEADERS.forEach((h) => { if (!lower.includes(h)) { items.push(h); + changed = true; } }); - hc.HeaderBehavior = 'whitelist'; - hc.Headers = { Quantity: items.length, Items: items }; - params.HeadersConfig = hc; - pc.ParametersInCacheKeyAndForwardedToOrigin = params; + fv.Headers = { Quantity: items.length, Items: items }; + behavior.ForwardedValues = fv; + } + if (setMinTTLZero && behavior.MinTTL !== 0) { + behavior.MinTTL = 0; + changed = true; + } + if (!changed) { + return { + scenario: 'legacy', policyId: null, updated: false, alreadyForwarded: true, + }; } + await client.send(new UpdateDistributionCommand({ + Id: distributionId, IfMatch: distResult.ETag, DistributionConfig: config, + })); + return { + scenario: 'legacy', policyId: null, updated: true, alreadyForwarded: false, + }; } - const needsMinTtl = setMinTTLZero && pc.MinTTL !== 0; - if (alreadyForwarded && !needsMinTtl) { - return { policyId, updated: false, alreadyForwarded: true }; + // Determine whether the attached policy is AWS-managed (managed policies cannot be updated). + const managedList = await client.send(new ListCachePoliciesCommand({ Type: 'managed' })); + const managedIds = new Set( + (managedList.CachePolicyList?.Items || []).map((i) => i.CachePolicy.Id), + ); + const isManaged = managedIds.has(policyId); + + // Helper: add the EO headers to a HeadersConfig in place; returns true if anything changed. + const addEoHeaders = (params) => { + const hc = params.HeadersConfig || { HeaderBehavior: 'none' }; + if (hc.HeaderBehavior === 'allViewer' || hc.HeaderBehavior === 'all') { + return false; + } + const items = hc.Headers?.Items || []; + const lower = items.map((x) => x.toLowerCase()); + const missing = EDGE_OPTIMIZE_CACHE_HEADERS.filter((h) => !lower.includes(h)); + if (missing.length === 0) { + return false; + } + missing.forEach((h) => items.push(h)); + hc.HeaderBehavior = 'whitelist'; + hc.Headers = { Quantity: items.length, Items: items }; + // eslint-disable-next-line no-param-reassign + params.HeadersConfig = hc; + return true; + }; + + // ── Scenario B: custom policy → update it in place ────────────────────── + if (!isManaged) { + const pcResult = await client.send(new GetCachePolicyConfigCommand({ Id: policyId })); + const pc = pcResult.CachePolicyConfig; + const params = pc.ParametersInCacheKeyAndForwardedToOrigin || {}; + const headersChanged = addEoHeaders(params); + pc.ParametersInCacheKeyAndForwardedToOrigin = params; + const needsMinTtl = setMinTTLZero && pc.MinTTL !== 0; + if (!headersChanged && !needsMinTtl) { + return { + scenario: 'custom', policyId, updated: false, alreadyForwarded: true, + }; + } + if (needsMinTtl) { + pc.MinTTL = 0; + } + await client.send(new UpdateCachePolicyCommand({ + Id: policyId, IfMatch: pcResult.ETag, CachePolicyConfig: pc, + })); + return { + scenario: 'custom', policyId, updated: true, alreadyForwarded: false, + }; } - if (needsMinTtl) { - pc.MinTTL = 0; + // ── Scenario C: managed policy → clone into edgeoptimize-cache + repoint ── + const srcResult = await client.send(new GetCachePolicyCommand({ Id: policyId })); + const cloned = JSON.parse(JSON.stringify(srcResult.CachePolicy.CachePolicyConfig)); + const sourceName = cloned.Name; + cloned.Name = EDGE_OPTIMIZE_CACHE_POLICY_NAME; + cloned.Comment = `Cloned from ${sourceName} with Edge Optimize headers — managed by LLM Optimizer`; + if (setMinTTLZero) { + cloned.MinTTL = 0; + } + const clonedParams = cloned.ParametersInCacheKeyAndForwardedToOrigin || {}; + addEoHeaders(clonedParams); + cloned.ParametersInCacheKeyAndForwardedToOrigin = clonedParams; + + // Idempotent: reuse an existing edgeoptimize-cache custom policy if a prior run created it. + const customList = await client.send(new ListCachePoliciesCommand({ Type: 'custom' })); + const existing = (customList.CachePolicyList?.Items || []).find( + (i) => i.CachePolicy.CachePolicyConfig.Name === EDGE_OPTIMIZE_CACHE_POLICY_NAME, + ); + let newPolicyId; + let reused = false; + if (existing) { + newPolicyId = existing.CachePolicy.Id; + reused = true; + } else { + const created = await client.send(new CreateCachePolicyCommand({ CachePolicyConfig: cloned })); + newPolicyId = created.CachePolicy.Id; } - await client.send(new UpdateCachePolicyCommand({ - Id: policyId, - IfMatch: pcEtag, - CachePolicyConfig: pc, + // Re-read the distribution for a fresh ETag, repoint the behavior to the new custom policy. + const freshDist = await client.send(new GetDistributionConfigCommand({ Id: distributionId })); + const freshConfig = freshDist.DistributionConfig; + const freshBehavior = getBehaviorFromConfig(freshConfig, pathPattern); + freshBehavior.CachePolicyId = newPolicyId; + delete freshBehavior.ForwardedValues; // cannot coexist with CachePolicyId + await client.send(new UpdateDistributionCommand({ + Id: distributionId, IfMatch: freshDist.ETag, DistributionConfig: freshConfig, })); - return { policyId, updated: true, alreadyForwarded }; + return { + scenario: 'managed', policyId: newPolicyId, updated: true, alreadyForwarded: false, reused, + }; } // The Lambda@Edge origin-request/response handler, ported verbatim from the standalone wizard's diff --git a/test/support/edge-optimize.test.js b/test/support/edge-optimize.test.js index 2e94376b69..5e8776447b 100644 --- a/test/support/edge-optimize.test.js +++ b/test/support/edge-optimize.test.js @@ -58,6 +58,9 @@ describe('edge-optimize support', () => { ListDistributionsCommand: cfCommand('ListDistributions'), GetDistributionConfigCommand: cfCommand('GetDistributionConfig'), GetCachePolicyConfigCommand: cfCommand('GetCachePolicyConfig'), + GetCachePolicyCommand: cfCommand('GetCachePolicy'), + ListCachePoliciesCommand: cfCommand('ListCachePolicies'), + CreateCachePolicyCommand: cfCommand('CreateCachePolicy'), UpdateCachePolicyCommand: cfCommand('UpdateCachePolicy'), CreateFunctionCommand: cfCommand('CreateFunction'), UpdateFunctionCommand: cfCommand('UpdateFunction'), @@ -402,109 +405,181 @@ describe('edge-optimize support', () => { }); describe('applyEdgeOptimizeCacheHeaders', () => { - it('adds the EO headers to the behavior cache policy whitelist', async () => { - cfSendStub.onFirstCall().resolves({ - DistributionConfig: { DefaultCacheBehavior: { CachePolicyId: 'cp-1' } }, + // Dispatch cfSendStub by command name so tests are robust to call order. + const wireCloudFront = (responders) => { + cfSendStub.callsFake((cmd) => { + const fn = responders[cmd.commandName]; + if (!fn) { + throw new Error(`unexpected command in test: ${cmd.commandName}`); + } + return Promise.resolve(typeof fn === 'function' ? fn(cmd) : fn); }); - cfSendStub.onSecondCall().resolves({ - CachePolicyConfig: { - Name: 'my-policy', - MinTTL: 60, - ParametersInCacheKeyAndForwardedToOrigin: { - HeadersConfig: { HeaderBehavior: 'whitelist', Headers: { Quantity: 1, Items: ['accept'] } }, + }; + + const lastCommand = (name) => cfSendStub.getCalls() + .filter((c) => c.args[0].commandName === name).pop()?.args[0]; + + it('updates a CUSTOM policy to add the EO headers + MinTTL 0', async () => { + wireCloudFront({ + GetDistributionConfig: { DistributionConfig: { DefaultCacheBehavior: { CachePolicyId: 'cp-1' } } }, + ListCachePolicies: { CachePolicyList: { Items: [{ CachePolicy: { Id: 'managed-x' } }] } }, + GetCachePolicyConfig: { + CachePolicyConfig: { + Name: 'my-policy', + MinTTL: 60, + ParametersInCacheKeyAndForwardedToOrigin: { + HeadersConfig: { HeaderBehavior: 'whitelist', Headers: { Quantity: 1, Items: ['accept'] } }, + }, }, + ETag: 'cp-etag', }, - ETag: 'cp-etag', + UpdateCachePolicy: {}, }); - cfSendStub.onThirdCall().resolves({}); const result = await edgeOptimize.applyEdgeOptimizeCacheHeaders({}, 'E2EXAMPLE', 'default'); + expect(result.scenario).to.equal('custom'); expect(result.policyId).to.equal('cp-1'); expect(result.updated).to.equal(true); - expect(cfSendStub.thirdCall.args[0].commandName).to.equal('UpdateCachePolicy'); - const updated = cfSendStub.thirdCall.args[0].input.CachePolicyConfig; + const updated = lastCommand('UpdateCachePolicy').input.CachePolicyConfig; expect(updated.MinTTL).to.equal(0); const items = updated.ParametersInCacheKeyAndForwardedToOrigin.HeadersConfig.Headers.Items; expect(items).to.include('x-edgeoptimize-config'); expect(items).to.include('x-edgeoptimize-url'); }); - it('is a no-op when headers are already forwarded and MinTTL is 0', async () => { - cfSendStub.onFirstCall().resolves({ - DistributionConfig: { DefaultCacheBehavior: { CachePolicyId: 'cp-1' } }, - }); - cfSendStub.onSecondCall().resolves({ - CachePolicyConfig: { - Name: 'my-policy', - MinTTL: 0, - ParametersInCacheKeyAndForwardedToOrigin: { - HeadersConfig: { - HeaderBehavior: 'whitelist', - Headers: { Quantity: 2, Items: ['x-edgeoptimize-config', 'x-edgeoptimize-url'] }, + it('is a no-op when a custom policy already forwards the headers and MinTTL is 0', async () => { + wireCloudFront({ + GetDistributionConfig: { DistributionConfig: { DefaultCacheBehavior: { CachePolicyId: 'cp-1' } } }, + ListCachePolicies: { CachePolicyList: { Items: [] } }, + GetCachePolicyConfig: { + CachePolicyConfig: { + Name: 'my-policy', + MinTTL: 0, + ParametersInCacheKeyAndForwardedToOrigin: { + HeadersConfig: { + HeaderBehavior: 'whitelist', + Headers: { Quantity: 2, Items: ['x-edgeoptimize-config', 'x-edgeoptimize-url'] }, + }, }, }, + ETag: 'cp-etag', }, - ETag: 'cp-etag', }); const result = await edgeOptimize.applyEdgeOptimizeCacheHeaders({}, 'E2EXAMPLE', 'default'); - expect(result).to.deep.equal({ policyId: 'cp-1', updated: false, alreadyForwarded: true }); - expect(cfSendStub.calledTwice).to.equal(true); // never updated + expect(result).to.deep.equal({ + scenario: 'custom', policyId: 'cp-1', updated: false, alreadyForwarded: true, + }); + expect(lastCommand('UpdateCachePolicy')).to.equal(undefined); // never updated }); - it('treats an allViewer header policy as already forwarded', async () => { - cfSendStub.onFirstCall().resolves({ - DistributionConfig: { DefaultCacheBehavior: { CachePolicyId: 'cp-1' } }, - }); - cfSendStub.onSecondCall().resolves({ - CachePolicyConfig: { - Name: 'p', - MinTTL: 0, - ParametersInCacheKeyAndForwardedToOrigin: { HeadersConfig: { HeaderBehavior: 'allViewer' } }, + it('CLONES an AWS-managed policy into edgeoptimize-cache and repoints the behavior', async () => { + wireCloudFront({ + GetDistributionConfig: { + DistributionConfig: { DefaultCacheBehavior: { CachePolicyId: 'managed-1', ForwardedValues: { x: 1 } } }, + ETag: 'dist-etag', + }, + ListCachePolicies: (cmd) => (cmd.input.Type === 'managed' + ? { CachePolicyList: { Items: [{ CachePolicy: { Id: 'managed-1' } }] } } + : { CachePolicyList: { Items: [] } }), // no existing custom edgeoptimize-cache + GetCachePolicy: { + CachePolicy: { + CachePolicyConfig: { + Name: 'Managed-CachingOptimized', + MinTTL: 1, + ParametersInCacheKeyAndForwardedToOrigin: { HeadersConfig: { HeaderBehavior: 'none' } }, + }, + }, }, - ETag: 'cp-etag', + CreateCachePolicy: { CachePolicy: { Id: 'new-eo-policy' } }, + UpdateDistribution: {}, }); const result = await edgeOptimize.applyEdgeOptimizeCacheHeaders({}, 'E2EXAMPLE', 'default'); - expect(result.updated).to.equal(false); - expect(result.alreadyForwarded).to.equal(true); + + expect(result.scenario).to.equal('managed'); + expect(result.policyId).to.equal('new-eo-policy'); + expect(result.reused).to.equal(false); + const created = lastCommand('CreateCachePolicy').input.CachePolicyConfig; + expect(created.Name).to.equal('edgeoptimize-cache'); + expect(created.MinTTL).to.equal(0); + const items = created.ParametersInCacheKeyAndForwardedToOrigin.HeadersConfig.Headers.Items; + expect(items).to.include('x-edgeoptimize-config'); + // behavior repointed to the new policy + ForwardedValues removed + const cfg = lastCommand('UpdateDistribution').input.DistributionConfig; + expect(cfg.DefaultCacheBehavior.CachePolicyId).to.equal('new-eo-policy'); + expect(cfg.DefaultCacheBehavior.ForwardedValues).to.equal(undefined); }); - it('throws when the behavior has no cache policy (legacy/managed)', async () => { - cfSendStub.onFirstCall().resolves({ - DistributionConfig: { DefaultCacheBehavior: { ForwardedValues: {} } }, + it('reuses an existing edgeoptimize-cache custom policy (idempotent managed path)', async () => { + wireCloudFront({ + GetDistributionConfig: { + DistributionConfig: { DefaultCacheBehavior: { CachePolicyId: 'managed-1' } }, + ETag: 'dist-etag', + }, + ListCachePolicies: (cmd) => (cmd.input.Type === 'managed' + ? { CachePolicyList: { Items: [{ CachePolicy: { Id: 'managed-1' } }] } } + : { CachePolicyList: { Items: [{ CachePolicy: { Id: 'existing-eo', CachePolicyConfig: { Name: 'edgeoptimize-cache' } } }] } }), + GetCachePolicy: { + CachePolicy: { CachePolicyConfig: { Name: 'Managed-X', ParametersInCacheKeyAndForwardedToOrigin: {} } }, + }, + UpdateDistribution: {}, }); - let error; - try { - await edgeOptimize.applyEdgeOptimizeCacheHeaders({}, 'E2EXAMPLE', 'default'); - } catch (e) { - error = e; - } - expect(error.message).to.include('custom cache policy'); + + const result = await edgeOptimize.applyEdgeOptimizeCacheHeaders({}, 'E2EXAMPLE', 'default'); + + expect(result.scenario).to.equal('managed'); + expect(result.policyId).to.equal('existing-eo'); + expect(result.reused).to.equal(true); + expect(lastCommand('CreateCachePolicy')).to.equal(undefined); // reused, not created }); - it('targets a named (non-default) behavior', async () => { - cfSendStub.onFirstCall().resolves({ - DistributionConfig: { - DefaultCacheBehavior: { CachePolicyId: 'cp-default' }, - CacheBehaviors: { Items: [{ PathPattern: '/api/*', CachePolicyId: 'cp-api' }] }, + it('handles a LEGACY behavior (ForwardedValues, no CachePolicyId)', async () => { + wireCloudFront({ + GetDistributionConfig: { + DistributionConfig: { + DefaultCacheBehavior: { ForwardedValues: { Headers: { Quantity: 1, Items: ['accept'] } }, MinTTL: 60 }, + }, + ETag: 'dist-etag', }, + UpdateDistribution: {}, }); - cfSendStub.onSecondCall().resolves({ - CachePolicyConfig: { - Name: 'api', - MinTTL: 0, - ParametersInCacheKeyAndForwardedToOrigin: { HeadersConfig: { HeaderBehavior: 'none' } }, + + const result = await edgeOptimize.applyEdgeOptimizeCacheHeaders({}, 'E2EXAMPLE', 'default'); + + expect(result.scenario).to.equal('legacy'); + expect(result.updated).to.equal(true); + const cfg = lastCommand('UpdateDistribution').input.DistributionConfig; + const items = cfg.DefaultCacheBehavior.ForwardedValues.Headers.Items; + expect(items).to.include('x-edgeoptimize-config'); + expect(cfg.DefaultCacheBehavior.MinTTL).to.equal(0); + }); + + it('targets a named (non-default) custom-policy behavior', async () => { + wireCloudFront({ + GetDistributionConfig: { + DistributionConfig: { + DefaultCacheBehavior: { CachePolicyId: 'cp-default' }, + CacheBehaviors: { Items: [{ PathPattern: '/api/*', CachePolicyId: 'cp-api' }] }, + }, }, - ETag: 'cp-etag', + ListCachePolicies: { CachePolicyList: { Items: [] } }, + GetCachePolicyConfig: { + CachePolicyConfig: { + Name: 'api', + MinTTL: 0, + ParametersInCacheKeyAndForwardedToOrigin: { HeadersConfig: { HeaderBehavior: 'none' } }, + }, + ETag: 'cp-etag', + }, + UpdateCachePolicy: {}, }); - cfSendStub.onThirdCall().resolves({}); const result = await edgeOptimize.applyEdgeOptimizeCacheHeaders({}, 'E2EXAMPLE', '/api/*'); expect(result.policyId).to.equal('cp-api'); - expect(cfSendStub.secondCall.args[0].input.Id).to.equal('cp-api'); + expect(lastCommand('GetCachePolicyConfig').input.Id).to.equal('cp-api'); }); it('throws when pathPattern is missing', async () => { From 9cb075f587204f22261837d5d5f1c070a4396de6 Mon Sep 17 00:00:00 2001 From: Akash Bhardwaj Date: Sun, 21 Jun 2026 14:13:39 +0530 Subject: [PATCH 13/20] fix(llmo): make Lambda@Edge step idempotent + idle-aware; add status endpoint Fixes the Lambda step's three failure modes (timeout, 'update in progress', no existence check): - waitForLambdaIdle now gates on State Active AND LastUpdateStatus != InProgress (was State only), so we never hit ResourceConflictException ('update is in progress') on a retry after a slow/timed-out first call. - createEdgeOptimizeLambda is fully idempotent: if a published numbered version already matches the current code, reuse it (no update/publish); otherwise update + publish. So a retry after a CDN first-byte timeout returns immediately instead of conflicting. - New read-only POST /sites/:siteId/llmo/edge-optimize/lambda-status (getEdgeOptimizeLambdaStatus) so the wizard can detect on entry and poll whether the function already exists with a published version. Support tests rewritten to dispatch by command name; status tests added. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/openapi/api.yaml | 2 + docs/openapi/llmo-api.yaml | 63 +++++++++ src/controllers/llmo/llmo.js | 34 +++++ src/routes/index.js | 1 + src/routes/required-capabilities.js | 1 + src/support/edge-optimize.js | 102 +++++++++++++-- test/controllers/llmo/llmo.test.js | 71 ++++++++++ test/routes/index.test.js | 2 + test/support/edge-optimize.test.js | 196 ++++++++++++++++++++-------- 9 files changed, 408 insertions(+), 64 deletions(-) diff --git a/docs/openapi/api.yaml b/docs/openapi/api.yaml index 32f1868d43..003d22c4be 100644 --- a/docs/openapi/api.yaml +++ b/docs/openapi/api.yaml @@ -643,6 +643,8 @@ paths: $ref: './llmo-api.yaml#/site-llmo-edge-optimize-apply-cache' /sites/{siteId}/llmo/edge-optimize/create-lambda: $ref: './llmo-api.yaml#/site-llmo-edge-optimize-create-lambda' + /sites/{siteId}/llmo/edge-optimize/lambda-status: + $ref: './llmo-api.yaml#/site-llmo-edge-optimize-lambda-status' /sites/{siteId}/llmo/edge-optimize/apply-associations: $ref: './llmo-api.yaml#/site-llmo-edge-optimize-apply-associations' /sites/{siteId}/llmo/edge-optimize/verify: diff --git a/docs/openapi/llmo-api.yaml b/docs/openapi/llmo-api.yaml index 00ab3d109c..ed95da1641 100644 --- a/docs/openapi/llmo-api.yaml +++ b/docs/openapi/llmo-api.yaml @@ -2949,6 +2949,69 @@ site-llmo-edge-optimize-create-lambda: security: - api_key: [ ] +site-llmo-edge-optimize-lambda-status: + post: + tags: + - llmo + summary: Read the Edge Optimize Lambda@Edge function status + description: | + Used by the CloudFront "Deploy routing" wizard to check on entry (and poll after a slow or + timed-out create) whether the `edgeoptimize-origin` Lambda@Edge function already exists and + has a published numbered version. Read-only — assumes the connector role and inspects the + function configuration + versions. + operationId: getEdgeOptimizeLambdaStatus + parameters: + - $ref: './parameters.yaml#/siteId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/edge-optimize-connector-request' + responses: + '200': + description: The Lambda@Edge function status. + content: + application/json: + schema: + type: object + required: + - exists + - versionArn + properties: + exists: + type: boolean + state: + type: string + lastUpdateStatus: + type: string + functionArn: + type: string + versionArn: + type: string + description: Latest published numbered version ARN, or null if not yet published. + version: + type: string + example: + exists: true + state: Active + lastUpdateStatus: Successful + functionArn: arn:aws:lambda:us-east-1:682033462621:function:edgeoptimize-origin + versionArn: arn:aws:lambda:us-east-1:682033462621:function:edgeoptimize-origin:1 + version: '1' + '400': + $ref: './responses.yaml#/400' + '401': + $ref: './responses.yaml#/401' + '403': + $ref: './responses.yaml#/403' + '404': + $ref: './responses.yaml#/404' + '500': + $ref: './responses.yaml#/500' + security: + - api_key: [ ] + site-llmo-edge-optimize-apply-associations: post: tags: diff --git a/src/controllers/llmo/llmo.js b/src/controllers/llmo/llmo.js index 39d2ecb30e..d3a9d1e003 100644 --- a/src/controllers/llmo/llmo.js +++ b/src/controllers/llmo/llmo.js @@ -41,6 +41,7 @@ import { createEdgeOptimizeRoutingFunction, applyEdgeOptimizeCacheHeaders, createEdgeOptimizeLambda, + getEdgeOptimizeLambdaStatus, applyEdgeOptimizeAssociations, verifyEdgeOptimizeRouting, } from '../../support/edge-optimize.js'; @@ -2555,6 +2556,38 @@ function LlmoController(ctx) { } }; + // Read-only status for the Lambda@Edge function so the wizard can detect on entry (and poll + // after a slow/timed-out create) whether it already exists with a published version. + const getEdgeOptimizeLambdaStatusHandler = async (context) => { + const { log, dataAccess, env } = context; + const { siteId } = context.params; + const { Site } = dataAccess; + const accountId = String(context.data?.accountId || '').replace(/\D/g, ''); + const externalId = String(context.data?.externalId || '').trim(); + const roleName = env?.EDGE_OPTIMIZE_ROLE_NAME || undefined; + + if (accountId.length !== 12) { + return badRequest('accountId must be a 12-digit AWS account ID'); + } + if (!hasText(externalId)) { + return badRequest('externalId is required'); + } + + try { + const { error } = await gateEdgeOptimizeWizard(siteId, Site, 'read the edge optimize Lambda@Edge status'); + if (error) { + return error; + } + + const { credentials } = await assumeConnectorRole({ accountId, externalId, roleName }); + const status = await getEdgeOptimizeLambdaStatus(credentials); + return ok(status); + } catch (error) { + log.error(`Failed to read Lambda@Edge status for site ${siteId}:`, error); + return badRequest(cleanupHeaderValue(error.message)); + } + }; + // Associate the routing CloudFront Function (viewer-request) and Lambda@Edge (origin-request/ // response, versioned ARN) onto the user-selected behavior (mutation). Used by "Associate". const applyEdgeOptimizeAssociationsHandler = async (context) => { @@ -2662,6 +2695,7 @@ function LlmoController(ctx) { createEdgeOptimizeRoutingFunction: createEdgeOptimizeRoutingFunctionHandler, applyEdgeOptimizeCache: applyEdgeOptimizeCacheHandler, createEdgeOptimizeLambda: createEdgeOptimizeLambdaHandler, + getEdgeOptimizeLambdaStatus: getEdgeOptimizeLambdaStatusHandler, applyEdgeOptimizeAssociations: applyEdgeOptimizeAssociationsHandler, verifyEdgeOptimizeRouting: verifyEdgeOptimizeRoutingHandler, getLlmoSheetData, diff --git a/src/routes/index.js b/src/routes/index.js index bb314348f0..40b4b73818 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -490,6 +490,7 @@ export default function getRouteHandlers( 'POST /sites/:siteId/llmo/edge-optimize/create-function': llmoController.createEdgeOptimizeRoutingFunction, 'POST /sites/:siteId/llmo/edge-optimize/apply-cache': llmoController.applyEdgeOptimizeCache, 'POST /sites/:siteId/llmo/edge-optimize/create-lambda': llmoController.createEdgeOptimizeLambda, + 'POST /sites/:siteId/llmo/edge-optimize/lambda-status': llmoController.getEdgeOptimizeLambdaStatus, 'POST /sites/:siteId/llmo/edge-optimize/apply-associations': llmoController.applyEdgeOptimizeAssociations, 'POST /sites/:siteId/llmo/edge-optimize/verify': llmoController.verifyEdgeOptimizeRouting, 'GET /sites/:siteId/llmo/strategy': llmoController.getStrategy, diff --git a/src/routes/required-capabilities.js b/src/routes/required-capabilities.js index c5527c9dad..fb7100d20e 100644 --- a/src/routes/required-capabilities.js +++ b/src/routes/required-capabilities.js @@ -137,6 +137,7 @@ export const INTERNAL_ROUTES = [ 'POST /sites/:siteId/llmo/edge-optimize/create-function', 'POST /sites/:siteId/llmo/edge-optimize/apply-cache', 'POST /sites/:siteId/llmo/edge-optimize/create-lambda', + 'POST /sites/:siteId/llmo/edge-optimize/lambda-status', 'POST /sites/:siteId/llmo/edge-optimize/apply-associations', 'POST /sites/:siteId/llmo/edge-optimize/verify', 'PUT /sites/:siteId/llmo/opportunities-reviewed', diff --git a/src/support/edge-optimize.js b/src/support/edge-optimize.js index d621b3cde4..6120aaab4b 100644 --- a/src/support/edge-optimize.js +++ b/src/support/edge-optimize.js @@ -38,8 +38,8 @@ import { LambdaClient, CreateFunctionCommand as LambdaCreateFunctionCommand, UpdateFunctionCodeCommand, - GetFunctionCommand, GetFunctionConfigurationCommand, + ListVersionsByFunctionCommand, PublishVersionCommand, } from '@aws-sdk/client-lambda'; import { hasText } from '@adobe/spacecat-shared-utils'; @@ -712,23 +712,40 @@ export function buildLambdaZip(filename, content) { } /* eslint-enable no-bitwise, max-statements-per-line, max-len */ -async function waitForLambdaActive(lambda, functionName, maxWaitMs = 30000) { +// Wait until the function is fully idle: State Active AND no update in progress. Lambda rejects +// UpdateFunctionCode / PublishVersion with ResourceConflictException ("update is in progress") +// while LastUpdateStatus is InProgress, so we must gate on both signals (not just State). +async function waitForLambdaIdle(lambda, functionName, maxWaitMs = 25000) { const deadline = Date.now() + maxWaitMs; /* eslint-disable no-await-in-loop */ while (Date.now() < deadline) { const cfg = await lambda.send( new GetFunctionConfigurationCommand({ FunctionName: functionName }), ); - if (cfg.State === 'Active') { - return; - } if (cfg.State === 'Failed') { throw new Error(`Lambda function entered Failed state: ${cfg.StateReason}`); } + if (cfg.State === 'Active' && cfg.LastUpdateStatus !== 'InProgress') { + return cfg; + } await delay(2000); } /* eslint-enable no-await-in-loop */ - throw new Error('Lambda function did not become Active within 30 s'); + throw new Error('Lambda function did not become idle in time'); +} + +// Latest published numbered version (skips $LATEST). Returns { versionArn, version, codeSha256 } +// or null when no numbered version has been published yet. +async function getLatestLambdaVersion(lambda, functionName) { + const resp = await lambda.send( + new ListVersionsByFunctionCommand({ FunctionName: functionName }), + ); + const numbered = (resp.Versions || []).filter((v) => v.Version && v.Version !== '$LATEST'); + if (numbered.length === 0) { + return null; + } + const latest = numbered.sort((a, b) => Number(b.Version) - Number(a.Version))[0]; + return { versionArn: latest.FunctionArn, version: latest.Version, codeSha256: latest.CodeSha256 }; } /** @@ -793,14 +810,17 @@ export async function createEdgeOptimizeLambda( PolicyDocument: buildCwLogsPolicy(String(accountId), EDGE_OPTIMIZE_LAMBDA_FUNCTION_NAME), })); - // ── 3. Create or update the function code. ── + // ── 3. Ensure the function exists with the current code (idempotent + idle-aware). ── let functionArn; let fnExists = false; + let currentCodeSha; try { - await lambda.send( - new GetFunctionCommand({ FunctionName: EDGE_OPTIMIZE_LAMBDA_FUNCTION_NAME }), + const cfg = await lambda.send( + new GetFunctionConfigurationCommand({ FunctionName: EDGE_OPTIMIZE_LAMBDA_FUNCTION_NAME }), ); fnExists = true; + functionArn = cfg.FunctionArn; + currentCodeSha = cfg.CodeSha256; } catch (err) { if (err.name !== 'ResourceNotFoundException') { throw err; @@ -808,11 +828,33 @@ export async function createEdgeOptimizeLambda( } if (fnExists) { - const updated = await lambda.send(new UpdateFunctionCodeCommand({ + // The function may still be finalizing a prior (possibly timed-out) call — wait for idle + // before touching it so we don't hit "update is in progress" (ResourceConflictException). + const idleCfg = await waitForLambdaIdle(lambda, EDGE_OPTIMIZE_LAMBDA_FUNCTION_NAME); + currentCodeSha = idleCfg.CodeSha256; + + // If a numbered version already exists for the CURRENT code, reuse it — fully idempotent, + // avoids version churn and lets a retry after a CDN timeout return immediately. + const existingVersion = await getLatestLambdaVersion( + lambda, + EDGE_OPTIMIZE_LAMBDA_FUNCTION_NAME, + ); + if (existingVersion && existingVersion.codeSha256 === currentCodeSha) { + return { + functionArn, + versionArn: existingVersion.versionArn, + version: existingVersion.version, + roleArn, + created: false, + alreadyExisted: true, + }; + } + + // Code differs (or no version yet) — update the code, wait for idle, then publish. + await lambda.send(new UpdateFunctionCodeCommand({ FunctionName: EDGE_OPTIMIZE_LAMBDA_FUNCTION_NAME, ZipFile: zipBuffer, })); - functionArn = updated.FunctionArn; } else { if (roleIsNew && roleWaitMs > 0) { await delay(roleWaitMs); @@ -850,8 +892,8 @@ export async function createEdgeOptimizeLambda( } } - // ── 4. Wait for Active, then publish a version (Lambda@Edge requires a numbered version). ── - await waitForLambdaActive(lambda, EDGE_OPTIMIZE_LAMBDA_FUNCTION_NAME); + // ── 4. Wait for idle, then publish a version (Lambda@Edge requires a numbered version). ── + await waitForLambdaIdle(lambda, EDGE_OPTIMIZE_LAMBDA_FUNCTION_NAME); const published = await lambda.send(new PublishVersionCommand({ FunctionName: EDGE_OPTIMIZE_LAMBDA_FUNCTION_NAME, Description: 'Published by LLM Optimizer CloudFront wizard', @@ -863,6 +905,40 @@ export async function createEdgeOptimizeLambda( version: published.Version, roleArn, created: !fnExists, + alreadyExisted: false, + }; +} + +/** + * Read-only status of the Edge Optimize Lambda@Edge function so the wizard can check on entry + * (and poll after a slow/timed-out create) whether it already exists and has a published version. + * + * @param {object} credentials - temporary credentials from {@link assumeConnectorRole}. + * @param {string} [region] - control-plane region. + * @returns {Promise<{exists: boolean, state?: string, lastUpdateStatus?: string, + * functionArn?: string, versionArn: string|null, version?: string}>} + */ +export async function getEdgeOptimizeLambdaStatus(credentials, region = EDGE_OPTIMIZE_REGION) { + const lambda = new LambdaClient({ region, credentials }); + let cfg; + try { + cfg = await lambda.send( + new GetFunctionConfigurationCommand({ FunctionName: EDGE_OPTIMIZE_LAMBDA_FUNCTION_NAME }), + ); + } catch (err) { + if (err.name === 'ResourceNotFoundException') { + return { exists: false, versionArn: null }; + } + throw err; + } + const latest = await getLatestLambdaVersion(lambda, EDGE_OPTIMIZE_LAMBDA_FUNCTION_NAME); + return { + exists: true, + state: cfg.State, + lastUpdateStatus: cfg.LastUpdateStatus, + functionArn: cfg.FunctionArn, + versionArn: latest?.versionArn || null, + version: latest?.version, }; } diff --git a/test/controllers/llmo/llmo.test.js b/test/controllers/llmo/llmo.test.js index 8ca0228ce3..cab086718f 100644 --- a/test/controllers/llmo/llmo.test.js +++ b/test/controllers/llmo/llmo.test.js @@ -93,6 +93,7 @@ describe('LlmoController', () => { let createEdgeOptimizeRoutingFunctionStub; let applyEdgeOptimizeCacheHeadersStub; let createEdgeOptimizeLambdaStub; + let getEdgeOptimizeLambdaStatusStub; let applyEdgeOptimizeAssociationsStub; let verifyEdgeOptimizeRoutingStub; let mockTokowakaClient; @@ -210,6 +211,7 @@ describe('LlmoController', () => { createEdgeOptimizeRoutingFunctionStub = sinon.stub(); applyEdgeOptimizeCacheHeadersStub = sinon.stub(); createEdgeOptimizeLambdaStub = sinon.stub(); + getEdgeOptimizeLambdaStatusStub = sinon.stub(); applyEdgeOptimizeAssociationsStub = sinon.stub(); verifyEdgeOptimizeRoutingStub = sinon.stub(); @@ -290,6 +292,7 @@ describe('LlmoController', () => { ), applyEdgeOptimizeCacheHeaders: (...args) => applyEdgeOptimizeCacheHeadersStub(...args), createEdgeOptimizeLambda: (...args) => createEdgeOptimizeLambdaStub(...args), + getEdgeOptimizeLambdaStatus: (...args) => getEdgeOptimizeLambdaStatusStub(...args), applyEdgeOptimizeAssociations: (...args) => applyEdgeOptimizeAssociationsStub(...args), verifyEdgeOptimizeRouting: (...args) => verifyEdgeOptimizeRoutingStub(...args), }, @@ -8545,6 +8548,74 @@ describe('LlmoController', () => { }); }); + describe('getEdgeOptimizeLambdaStatus', () => { + let statusContext; + + beforeEach(() => { + assumeConnectorRoleStub = sinon.stub().resolves({ + roleArn: 'arn:aws:iam::120569600543:role/AdobeLLMOptimizerCloudFrontConnectorRole', + accountId: '120569600543', + credentials: { accessKeyId: 'AKIA', secretAccessKey: 'secret', sessionToken: 'token' }, + }); + getEdgeOptimizeLambdaStatusStub = sinon.stub().resolves({ + exists: true, state: 'Active', lastUpdateStatus: 'Successful', versionArn: 'arn:fn:2', version: '2', + }); + statusContext = { + ...mockContext, + params: { siteId: TEST_SITE_ID }, + data: { accountId: '120569600543', externalId: '7ff9518a-cf59-40b4-aa53-68a3cb2e24a5' }, + env: {}, + }; + }); + + it('returns the Lambda@Edge status with the versioned ARN', async () => { + const result = await controller.getEdgeOptimizeLambdaStatus(statusContext); + + expect(result.status).to.equal(200); + const body = await result.json(); + expect(body.exists).to.equal(true); + expect(body.versionArn).to.equal('arn:fn:2'); + expect(getEdgeOptimizeLambdaStatusStub.calledOnce).to.equal(true); + }); + + it('returns exists:false when the function is absent', async () => { + getEdgeOptimizeLambdaStatusStub = sinon.stub().resolves({ exists: false, versionArn: null }); + const result = await controller.getEdgeOptimizeLambdaStatus(statusContext); + expect(result.status).to.equal(200); + const body = await result.json(); + expect(body.exists).to.equal(false); + expect(body.versionArn).to.equal(null); + }); + + it('returns 400 for an invalid account id', async () => { + const result = await controller.getEdgeOptimizeLambdaStatus({ ...statusContext, data: { accountId: '123', externalId: 'ext' } }); + expect(result.status).to.equal(400); + }); + + it('returns 400 when the external id is missing', async () => { + const result = await controller.getEdgeOptimizeLambdaStatus({ ...statusContext, data: { accountId: '120569600543' } }); + expect(result.status).to.equal(400); + }); + + it('returns 400 when the AWS call fails', async () => { + getEdgeOptimizeLambdaStatusStub = sinon.stub().rejects(new Error('ListVersions failed')); + const result = await controller.getEdgeOptimizeLambdaStatus(statusContext); + expect(result.status).to.equal(400); + }); + + it('returns 404 when the site is not found', async () => { + mockDataAccess.Site.findById.resolves(null); + const result = await controller.getEdgeOptimizeLambdaStatus(statusContext); + expect(result.status).to.equal(404); + }); + + it('returns 403 when the user lacks access to the site', async () => { + const deniedController = controllerWithAccessDenied(mockContext); + const result = await deniedController.getEdgeOptimizeLambdaStatus(statusContext); + expect(result.status).to.equal(403); + }); + }); + describe('applyEdgeOptimizeAssociations', () => { let associateContext; diff --git a/test/routes/index.test.js b/test/routes/index.test.js index 77692c7571..5f7389a2f7 100755 --- a/test/routes/index.test.js +++ b/test/routes/index.test.js @@ -336,6 +336,7 @@ describe('getRouteHandlers', () => { createEdgeOptimizeRoutingFunction: () => null, applyEdgeOptimizeCache: () => null, createEdgeOptimizeLambda: () => null, + getEdgeOptimizeLambdaStatus: () => null, applyEdgeOptimizeAssociations: () => null, verifyEdgeOptimizeRouting: () => null, getEdgeConfig: () => null, @@ -1094,6 +1095,7 @@ describe('getRouteHandlers', () => { 'POST /sites/:siteId/llmo/edge-optimize/create-function', 'POST /sites/:siteId/llmo/edge-optimize/apply-cache', 'POST /sites/:siteId/llmo/edge-optimize/create-lambda', + 'POST /sites/:siteId/llmo/edge-optimize/lambda-status', 'POST /sites/:siteId/llmo/edge-optimize/apply-associations', 'POST /sites/:siteId/llmo/edge-optimize/verify', 'GET /sites/:siteId/llmo/edge-optimize-status', diff --git a/test/support/edge-optimize.test.js b/test/support/edge-optimize.test.js index 5e8776447b..a975f6ef66 100644 --- a/test/support/edge-optimize.test.js +++ b/test/support/edge-optimize.test.js @@ -85,8 +85,8 @@ describe('edge-optimize support', () => { }, CreateFunctionCommand: lambdaCommand('CreateFunction'), UpdateFunctionCodeCommand: lambdaCommand('UpdateFunctionCode'), - GetFunctionCommand: lambdaCommand('GetFunction'), GetFunctionConfigurationCommand: lambdaCommand('GetFunctionConfiguration'), + ListVersionsByFunctionCommand: lambdaCommand('ListVersionsByFunction'), PublishVersionCommand: lambdaCommand('PublishVersion'), }, }); @@ -604,74 +604,117 @@ describe('edge-optimize support', () => { describe('createEdgeOptimizeLambda', () => { const creds = { accessKeyId: 'A', secretAccessKey: 'S', sessionToken: 'T' }; - it('creates the role, function and publishes a version', async () => { - iamSendStub.onFirstCall().rejects(Object.assign(new Error('no role'), { name: 'NoSuchEntityException' })); - iamSendStub.onSecondCall().resolves({ Role: { Arn: 'arn:aws:iam::120569600543:role/edgeoptimize-origin-role' } }); // CreateRole - iamSendStub.onThirdCall().resolves({}); // PutRolePolicy + // IAM + Lambda stubs dispatch by command name (robust to call order/poll counts). + const wireIam = (responders) => { + iamSendStub.callsFake((cmd) => { + const r = responders[cmd.commandName]; + return Promise.resolve(typeof r === 'function' ? r(cmd) : (r || {})); + }); + }; + const wireLambda = (responders) => { + lambdaSendStub.callsFake((cmd) => { + const r = responders[cmd.commandName]; + if (r === undefined) { + throw new Error(`unexpected lambda command: ${cmd.commandName}`); + } + return Promise.resolve(typeof r === 'function' ? r(cmd) : r); + }); + }; + const lastLambda = (name) => lambdaSendStub.getCalls() + .filter((c) => c.args[0].commandName === name).pop()?.args[0]; + const notFound = () => Promise.reject(Object.assign(new Error('nf'), { name: 'ResourceNotFoundException' })); - // Lambda flow: GetFunction (not found) -> CreateFunction -> GetFunctionConfiguration(Active) - // -> PublishVersion - lambdaSendStub.onCall(0).rejects(Object.assign(new Error('nf'), { name: 'ResourceNotFoundException' })); - lambdaSendStub.onCall(1).resolves({ FunctionArn: 'arn:fn' }); - lambdaSendStub.onCall(2).resolves({ State: 'Active' }); - lambdaSendStub.onCall(3).resolves({ FunctionArn: 'arn:fn:1', Version: '1' }); + it('creates the role, function and publishes a version', async () => { + wireIam({ + GetRole: () => Promise.reject(Object.assign(new Error('no role'), { name: 'NoSuchEntityException' })), + CreateRole: { Role: { Arn: 'arn:aws:iam::120569600543:role/edgeoptimize-origin-role' } }, + PutRolePolicy: {}, + }); + let fnCreated = false; + wireLambda({ + GetFunctionConfiguration: () => (fnCreated + ? { + FunctionArn: 'arn:fn', State: 'Active', LastUpdateStatus: 'Successful', CodeSha256: 'sha-new', + } + : notFound()), + CreateFunction: () => { + fnCreated = true; + return { FunctionArn: 'arn:fn' }; + }, + ListVersionsByFunction: { Versions: [{ Version: '$LATEST' }] }, + PublishVersion: { FunctionArn: 'arn:fn:1', Version: '1' }, + }); const result = await edgeOptimize.createEdgeOptimizeLambda(creds, '120569600543', { roleWaitMs: 0 }); expect(result).to.include({ - functionArn: 'arn:fn', - versionArn: 'arn:fn:1', - version: '1', - created: true, + functionArn: 'arn:fn', versionArn: 'arn:fn:1', version: '1', created: true, }); expect(result.roleArn).to.include('edgeoptimize-origin-role'); - expect(iamSendStub.secondCall.args[0].commandName).to.equal('CreateRole'); - expect(lambdaSendStub.getCall(1).args[0].commandName).to.equal('CreateFunction'); - expect(lambdaSendStub.getCall(1).args[0].input.Role).to.include('edgeoptimize-origin-role'); - expect(lambdaSendStub.getCall(3).args[0].commandName).to.equal('PublishVersion'); + expect(lastLambda('CreateFunction').input.Role).to.include('edgeoptimize-origin-role'); + expect(lastLambda('PublishVersion')).to.not.equal(undefined); }); - it('updates the function code when it already exists', async () => { - iamSendStub.onFirstCall().resolves({ Role: { Arn: 'arn:role' } }); // GetRole - iamSendStub.onSecondCall().resolves({}); // UpdateAssumeRolePolicy - iamSendStub.onThirdCall().resolves({}); // PutRolePolicy - - lambdaSendStub.onCall(0).resolves({}); // GetFunction (exists) - lambdaSendStub.onCall(1).resolves({ FunctionArn: 'arn:fn' }); // UpdateFunctionCode - lambdaSendStub.onCall(2).resolves({ State: 'Active' }); - lambdaSendStub.onCall(3).resolves({ FunctionArn: 'arn:fn:2', Version: '2' }); + it('is idempotent: reuses the existing version when code is unchanged (no update/publish)', async () => { + wireIam({ GetRole: { Role: { Arn: 'arn:role' } }, UpdateAssumeRolePolicy: {}, PutRolePolicy: {} }); + wireLambda({ + GetFunctionConfiguration: { + FunctionArn: 'arn:fn', State: 'Active', LastUpdateStatus: 'Successful', CodeSha256: 'sha-1', + }, + ListVersionsByFunction: { Versions: [{ Version: '$LATEST' }, { Version: '3', FunctionArn: 'arn:fn:3', CodeSha256: 'sha-1' }] }, + }); const result = await edgeOptimize.createEdgeOptimizeLambda(creds, '120569600543'); expect(result.created).to.equal(false); - expect(result.version).to.equal('2'); - expect(lambdaSendStub.getCall(1).args[0].commandName).to.equal('UpdateFunctionCode'); + expect(result.alreadyExisted).to.equal(true); + expect(result.versionArn).to.equal('arn:fn:3'); + expect(result.version).to.equal('3'); + expect(lastLambda('UpdateFunctionCode')).to.equal(undefined); // not re-updated + expect(lastLambda('PublishVersion')).to.equal(undefined); // not re-published }); - it('retries CreateFunction on role-propagation errors then succeeds', async () => { - iamSendStub.onFirstCall().rejects(Object.assign(new Error('no role'), { name: 'NoSuchEntityException' })); - iamSendStub.onSecondCall().resolves({ Role: { Arn: 'arn:role' } }); - iamSendStub.onThirdCall().resolves({}); + it('updates the code + publishes when the existing version is stale', async () => { + wireIam({ GetRole: { Role: { Arn: 'arn:role' } }, UpdateAssumeRolePolicy: {}, PutRolePolicy: {} }); + wireLambda({ + GetFunctionConfiguration: { + FunctionArn: 'arn:fn', State: 'Active', LastUpdateStatus: 'Successful', CodeSha256: 'sha-current', + }, + ListVersionsByFunction: { Versions: [{ Version: '1', FunctionArn: 'arn:fn:1', CodeSha256: 'sha-OLD' }] }, + UpdateFunctionCode: { FunctionArn: 'arn:fn' }, + PublishVersion: { FunctionArn: 'arn:fn:2', Version: '2' }, + }); - const roleErr = Object.assign( - new Error('The role defined for the function cannot be assumed by Lambda'), - { name: 'InvalidParameterValueException' }, - ); - lambdaSendStub.onCall(0).rejects(Object.assign(new Error('nf'), { name: 'ResourceNotFoundException' })); - lambdaSendStub.onCall(1).rejects(roleErr); // first CreateFunction attempt - lambdaSendStub.onCall(2).resolves({ FunctionArn: 'arn:fn' }); // retry succeeds - lambdaSendStub.onCall(3).resolves({ State: 'Active' }); - lambdaSendStub.onCall(4).resolves({ FunctionArn: 'arn:fn:1', Version: '1' }); + const result = await edgeOptimize.createEdgeOptimizeLambda(creds, '120569600543'); - const result = await edgeOptimize.createEdgeOptimizeLambda( - creds, - '120569600543', - { roleWaitMs: 0, retryDelayMs: 0 }, - ); + expect(result.created).to.equal(false); + expect(result.version).to.equal('2'); + expect(lastLambda('UpdateFunctionCode')).to.not.equal(undefined); + }); + + it('waits for an in-progress update to finish before publishing (no conflict)', async () => { + wireIam({ GetRole: { Role: { Arn: 'arn:role' } }, UpdateAssumeRolePolicy: {}, PutRolePolicy: {} }); + let polls = 0; + wireLambda({ + // existence check, then waitForLambdaIdle polls: InProgress first, then idle + GetFunctionConfiguration: () => { + polls += 1; + if (polls <= 1) { + return { + FunctionArn: 'arn:fn', State: 'Active', LastUpdateStatus: 'InProgress', CodeSha256: 'sha-1', + }; + } + return { + FunctionArn: 'arn:fn', State: 'Active', LastUpdateStatus: 'Successful', CodeSha256: 'sha-1', + }; + }, + ListVersionsByFunction: { Versions: [{ Version: '5', FunctionArn: 'arn:fn:5', CodeSha256: 'sha-1' }] }, + }); - expect(result.version).to.equal('1'); - expect(lambdaSendStub.getCall(1).args[0].commandName).to.equal('CreateFunction'); - expect(lambdaSendStub.getCall(2).args[0].commandName).to.equal('CreateFunction'); + const result = await edgeOptimize.createEdgeOptimizeLambda(creds, '120569600543'); + + expect(result.versionArn).to.equal('arn:fn:5'); + expect(polls).to.be.greaterThan(1); // it polled past the InProgress state }); it('throws for an invalid account id', async () => { @@ -686,6 +729,57 @@ describe('edge-optimize support', () => { }); }); + describe('getEdgeOptimizeLambdaStatus', () => { + it('reports exists:false when the function is absent', async () => { + lambdaSendStub.callsFake((cmd) => { + if (cmd.commandName === 'GetFunctionConfiguration') { + return Promise.reject(Object.assign(new Error('nf'), { name: 'ResourceNotFoundException' })); + } + throw new Error(`unexpected: ${cmd.commandName}`); + }); + + const result = await edgeOptimize.getEdgeOptimizeLambdaStatus({}); + + expect(result).to.deep.equal({ exists: false, versionArn: null }); + }); + + it('reports the latest published version when the function exists', async () => { + lambdaSendStub.callsFake((cmd) => { + if (cmd.commandName === 'GetFunctionConfiguration') { + return Promise.resolve({ FunctionArn: 'arn:fn', State: 'Active', LastUpdateStatus: 'Successful' }); + } + if (cmd.commandName === 'ListVersionsByFunction') { + return Promise.resolve({ Versions: [{ Version: '$LATEST' }, { Version: '2', FunctionArn: 'arn:fn:2' }] }); + } + throw new Error(`unexpected: ${cmd.commandName}`); + }); + + const result = await edgeOptimize.getEdgeOptimizeLambdaStatus({}); + + expect(result.exists).to.equal(true); + expect(result.state).to.equal('Active'); + expect(result.versionArn).to.equal('arn:fn:2'); + expect(result.version).to.equal('2'); + }); + + it('reports versionArn null when only $LATEST exists (not yet published)', async () => { + lambdaSendStub.callsFake((cmd) => { + if (cmd.commandName === 'GetFunctionConfiguration') { + return Promise.resolve({ FunctionArn: 'arn:fn', State: 'Pending', LastUpdateStatus: 'InProgress' }); + } + if (cmd.commandName === 'ListVersionsByFunction') { + return Promise.resolve({ Versions: [{ Version: '$LATEST' }] }); + } + throw new Error(`unexpected: ${cmd.commandName}`); + }); + + const result = await edgeOptimize.getEdgeOptimizeLambdaStatus({}); + + expect(result.exists).to.equal(true); + expect(result.versionArn).to.equal(null); + }); + }); + describe('applyEdgeOptimizeAssociations', () => { const lambdaArn = 'arn:aws:lambda:us-east-1:120569600543:function:edgeoptimize-origin:1'; From e9a32b879314f7f693c1126117f493eb253a898b Mon Sep 17 00:00:00 2001 From: Akash Bhardwaj Date: Sun, 21 Jun 2026 18:13:17 +0530 Subject: [PATCH 14/20] fix(llmo): non-blocking Lambda@Edge create + role status (async-friendly) create-lambda no longer blocks on a fresh function becoming Active (which exceeded the CDN first-byte timeout -> 503). It now ensures the role + kicks off the function create and returns { status: 'provisioning' | 'ready' } immediately; the UI polls until a published version exists. Also: - buildLambdaZip uses a fixed timestamp so CodeSha256 is deterministic (no version churn). - lambda-status now reports roleExists + a ready flag (role is created synchronously by the create ack) so the wizard can show role + function state and check on entry. - Removed the in-request waitForLambdaIdle/UpdateFunctionCode blocking path. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/support/edge-optimize.js | 144 ++++++++++++++--------------- test/support/edge-optimize.test.js | 107 +++++++++++---------- 2 files changed, 125 insertions(+), 126 deletions(-) diff --git a/src/support/edge-optimize.js b/src/support/edge-optimize.js index 6120aaab4b..412010a494 100644 --- a/src/support/edge-optimize.js +++ b/src/support/edge-optimize.js @@ -37,7 +37,6 @@ import { import { LambdaClient, CreateFunctionCommand as LambdaCreateFunctionCommand, - UpdateFunctionCodeCommand, GetFunctionConfigurationCommand, ListVersionsByFunctionCommand, PublishVersionCommand, @@ -682,9 +681,11 @@ export function buildLambdaZip(filename, content) { const compressed = deflateRawSync(data, { level: 9 }); const crcVal = crc32(data); const fn = Buffer.from(filename, 'utf-8'); - const now = new Date(); - const dosDate = ((now.getFullYear() - 1980) << 9) | ((now.getMonth() + 1) << 5) | now.getDate(); - const dosTime = (now.getHours() << 11) | (now.getMinutes() << 5) | (now.getSeconds() >> 1); + // Fixed DOS date/time (1980-01-01 00:00:00) so the zip — and thus the Lambda CodeSha256 — is + // deterministic for identical source. A timestamp here would change the hash on every call, + // causing needless code updates and version churn. + const dosDate = (0 << 9) | (1 << 5) | 1; + const dosTime = 0; const lh = Buffer.alloc(30 + fn.length); lh.writeUInt32LE(0x04034b50, 0); lh.writeUInt16LE(20, 4); lh.writeUInt16LE(0, 6); @@ -712,28 +713,6 @@ export function buildLambdaZip(filename, content) { } /* eslint-enable no-bitwise, max-statements-per-line, max-len */ -// Wait until the function is fully idle: State Active AND no update in progress. Lambda rejects -// UpdateFunctionCode / PublishVersion with ResourceConflictException ("update is in progress") -// while LastUpdateStatus is InProgress, so we must gate on both signals (not just State). -async function waitForLambdaIdle(lambda, functionName, maxWaitMs = 25000) { - const deadline = Date.now() + maxWaitMs; - /* eslint-disable no-await-in-loop */ - while (Date.now() < deadline) { - const cfg = await lambda.send( - new GetFunctionConfigurationCommand({ FunctionName: functionName }), - ); - if (cfg.State === 'Failed') { - throw new Error(`Lambda function entered Failed state: ${cfg.StateReason}`); - } - if (cfg.State === 'Active' && cfg.LastUpdateStatus !== 'InProgress') { - return cfg; - } - await delay(2000); - } - /* eslint-enable no-await-in-loop */ - throw new Error('Lambda function did not become idle in time'); -} - // Latest published numbered version (skips $LATEST). Returns { versionArn, version, codeSha256 } // or null when no numbered version has been published yet. async function getLatestLambdaVersion(lambda, functionName) { @@ -810,58 +789,30 @@ export async function createEdgeOptimizeLambda( PolicyDocument: buildCwLogsPolicy(String(accountId), EDGE_OPTIMIZE_LAMBDA_FUNCTION_NAME), })); - // ── 3. Ensure the function exists with the current code (idempotent + idle-aware). ── - let functionArn; - let fnExists = false; - let currentCodeSha; + // ── 3. Advance the function state machine WITHOUT blocking on provisioning. ── + // This runs behind a CDN/gateway with a ~60s first-byte timeout, so we must never wait for a + // fresh function to become Active (30–60s) inside the request. Each call does at most one fast + // step and returns `status: 'provisioning' | 'ready'`; the UI polls until ready. + let cfg = null; try { - const cfg = await lambda.send( + cfg = await lambda.send( new GetFunctionConfigurationCommand({ FunctionName: EDGE_OPTIMIZE_LAMBDA_FUNCTION_NAME }), ); - fnExists = true; - functionArn = cfg.FunctionArn; - currentCodeSha = cfg.CodeSha256; } catch (err) { if (err.name !== 'ResourceNotFoundException') { throw err; } } - if (fnExists) { - // The function may still be finalizing a prior (possibly timed-out) call — wait for idle - // before touching it so we don't hit "update is in progress" (ResourceConflictException). - const idleCfg = await waitForLambdaIdle(lambda, EDGE_OPTIMIZE_LAMBDA_FUNCTION_NAME); - currentCodeSha = idleCfg.CodeSha256; - - // If a numbered version already exists for the CURRENT code, reuse it — fully idempotent, - // avoids version churn and lets a retry after a CDN timeout return immediately. - const existingVersion = await getLatestLambdaVersion( - lambda, - EDGE_OPTIMIZE_LAMBDA_FUNCTION_NAME, - ); - if (existingVersion && existingVersion.codeSha256 === currentCodeSha) { - return { - functionArn, - versionArn: existingVersion.versionArn, - version: existingVersion.version, - roleArn, - created: false, - alreadyExisted: true, - }; - } - - // Code differs (or no version yet) — update the code, wait for idle, then publish. - await lambda.send(new UpdateFunctionCodeCommand({ - FunctionName: EDGE_OPTIMIZE_LAMBDA_FUNCTION_NAME, - ZipFile: zipBuffer, - })); - } else { + // Function does not exist yet → create it (returns fast in Pending) and report provisioning. + if (!cfg) { if (roleIsNew && roleWaitMs > 0) { await delay(roleWaitMs); } let lastErr; + let createdArn; /* eslint-disable no-await-in-loop */ - for (let attempt = 0; attempt < 5; attempt += 1) { + for (let attempt = 0; attempt < 3; attempt += 1) { try { const created = await lambda.send(new LambdaCreateFunctionCommand({ FunctionName: EDGE_OPTIMIZE_LAMBDA_FUNCTION_NAME, @@ -873,14 +824,21 @@ export async function createEdgeOptimizeLambda( Timeout: 5, MemorySize: 128, })); - functionArn = created.FunctionArn; + createdArn = created.FunctionArn; lastErr = null; break; } catch (createErr) { lastErr = createErr; + // A just-created role may not have propagated yet — short bounded retry, then give up + // (the next poll will succeed once it propagates) so we never block long. const isRolePropagation = createErr.name === 'InvalidParameterValueException' && (createErr.message || '').toLowerCase().includes('role'); - if (!isRolePropagation || attempt >= 4) { + if (createErr.name === 'ResourceConflictException') { + // Created concurrently by a prior (timed-out) call — treat as provisioning. + lastErr = null; + break; + } + if (!isRolePropagation || attempt >= 2) { throw createErr; } await delay(retryDelayMs); @@ -890,21 +848,44 @@ export async function createEdgeOptimizeLambda( if (lastErr) { throw lastErr; } + return { + status: 'provisioning', functionArn: createdArn, roleArn, created: true, versionArn: null, + }; + } + + // Still finalizing a create/update → report provisioning, don't touch it (avoids conflicts). + if (cfg.State === 'Pending' || cfg.LastUpdateStatus === 'InProgress') { + return { + status: 'provisioning', functionArn: cfg.FunctionArn, roleArn, created: false, versionArn: null, + }; + } + + // Active and idle. If a numbered version already exists, reuse it (idempotent). + const existingVersion = await getLatestLambdaVersion(lambda, EDGE_OPTIMIZE_LAMBDA_FUNCTION_NAME); + if (existingVersion) { + return { + status: 'ready', + functionArn: cfg.FunctionArn, + versionArn: existingVersion.versionArn, + version: existingVersion.version, + roleArn, + created: false, + alreadyExisted: true, + }; } - // ── 4. Wait for idle, then publish a version (Lambda@Edge requires a numbered version). ── - await waitForLambdaIdle(lambda, EDGE_OPTIMIZE_LAMBDA_FUNCTION_NAME); + // Active, idle, no version yet → publish one (fast on an idle function). const published = await lambda.send(new PublishVersionCommand({ FunctionName: EDGE_OPTIMIZE_LAMBDA_FUNCTION_NAME, Description: 'Published by LLM Optimizer CloudFront wizard', })); - return { - functionArn, + status: 'ready', + functionArn: cfg.FunctionArn, versionArn: published.FunctionArn, // includes the :N version suffix version: published.Version, roleArn, - created: !fnExists, + created: false, alreadyExisted: false, }; } @@ -920,6 +901,20 @@ export async function createEdgeOptimizeLambda( */ export async function getEdgeOptimizeLambdaStatus(credentials, region = EDGE_OPTIMIZE_REGION) { const lambda = new LambdaClient({ region, credentials }); + const iam = new IAMClient({ region, credentials }); + + // Execution role status (created synchronously by create-lambda's ack call). + let roleExists = false; + try { + await iam.send(new GetRoleCommand({ RoleName: EDGE_OPTIMIZE_LAMBDA_ROLE_NAME })); + roleExists = true; + } catch (err) { + if (err.name !== 'NoSuchEntityException') { + throw err; + } + } + + // Function status. let cfg; try { cfg = await lambda.send( @@ -927,18 +922,23 @@ export async function getEdgeOptimizeLambdaStatus(credentials, region = EDGE_OPT ); } catch (err) { if (err.name === 'ResourceNotFoundException') { - return { exists: false, versionArn: null }; + return { + roleExists, exists: false, versionArn: null, ready: false, + }; } throw err; } const latest = await getLatestLambdaVersion(lambda, EDGE_OPTIMIZE_LAMBDA_FUNCTION_NAME); + const ready = cfg.State === 'Active' && cfg.LastUpdateStatus !== 'InProgress' && !!latest; return { + roleExists, exists: true, state: cfg.State, lastUpdateStatus: cfg.LastUpdateStatus, functionArn: cfg.FunctionArn, versionArn: latest?.versionArn || null, version: latest?.version, + ready, }; } diff --git a/test/support/edge-optimize.test.js b/test/support/edge-optimize.test.js index a975f6ef66..134774b08d 100644 --- a/test/support/edge-optimize.test.js +++ b/test/support/edge-optimize.test.js @@ -624,97 +624,87 @@ describe('edge-optimize support', () => { .filter((c) => c.args[0].commandName === name).pop()?.args[0]; const notFound = () => Promise.reject(Object.assign(new Error('nf'), { name: 'ResourceNotFoundException' })); - it('creates the role, function and publishes a version', async () => { + it('creates the role + function (non-blocking) and returns provisioning', async () => { wireIam({ GetRole: () => Promise.reject(Object.assign(new Error('no role'), { name: 'NoSuchEntityException' })), CreateRole: { Role: { Arn: 'arn:aws:iam::120569600543:role/edgeoptimize-origin-role' } }, PutRolePolicy: {}, }); - let fnCreated = false; wireLambda({ - GetFunctionConfiguration: () => (fnCreated - ? { - FunctionArn: 'arn:fn', State: 'Active', LastUpdateStatus: 'Successful', CodeSha256: 'sha-new', - } - : notFound()), - CreateFunction: () => { - fnCreated = true; - return { FunctionArn: 'arn:fn' }; - }, - ListVersionsByFunction: { Versions: [{ Version: '$LATEST' }] }, - PublishVersion: { FunctionArn: 'arn:fn:1', Version: '1' }, + GetFunctionConfiguration: () => notFound(), + CreateFunction: { FunctionArn: 'arn:fn' }, }); const result = await edgeOptimize.createEdgeOptimizeLambda(creds, '120569600543', { roleWaitMs: 0 }); - expect(result).to.include({ - functionArn: 'arn:fn', versionArn: 'arn:fn:1', version: '1', created: true, - }); + // Does NOT block on the new function becoming Active — returns provisioning immediately. + expect(result.status).to.equal('provisioning'); + expect(result.created).to.equal(true); + expect(result.versionArn).to.equal(null); expect(result.roleArn).to.include('edgeoptimize-origin-role'); expect(lastLambda('CreateFunction').input.Role).to.include('edgeoptimize-origin-role'); - expect(lastLambda('PublishVersion')).to.not.equal(undefined); + expect(lastLambda('PublishVersion')).to.equal(undefined); // never publishes while Pending }); - it('is idempotent: reuses the existing version when code is unchanged (no update/publish)', async () => { + it('returns provisioning (no mutation) while the function is still finalizing', async () => { wireIam({ GetRole: { Role: { Arn: 'arn:role' } }, UpdateAssumeRolePolicy: {}, PutRolePolicy: {} }); wireLambda({ GetFunctionConfiguration: { - FunctionArn: 'arn:fn', State: 'Active', LastUpdateStatus: 'Successful', CodeSha256: 'sha-1', + FunctionArn: 'arn:fn', State: 'Active', LastUpdateStatus: 'InProgress', }, - ListVersionsByFunction: { Versions: [{ Version: '$LATEST' }, { Version: '3', FunctionArn: 'arn:fn:3', CodeSha256: 'sha-1' }] }, }); const result = await edgeOptimize.createEdgeOptimizeLambda(creds, '120569600543'); - expect(result.created).to.equal(false); - expect(result.alreadyExisted).to.equal(true); - expect(result.versionArn).to.equal('arn:fn:3'); - expect(result.version).to.equal('3'); - expect(lastLambda('UpdateFunctionCode')).to.equal(undefined); // not re-updated - expect(lastLambda('PublishVersion')).to.equal(undefined); // not re-published + expect(result.status).to.equal('provisioning'); + expect(result.versionArn).to.equal(null); + expect(lastLambda('PublishVersion')).to.equal(undefined); // never touched while InProgress }); - it('updates the code + publishes when the existing version is stale', async () => { + it('is idempotent: reuses the existing version when the function is idle', async () => { wireIam({ GetRole: { Role: { Arn: 'arn:role' } }, UpdateAssumeRolePolicy: {}, PutRolePolicy: {} }); wireLambda({ GetFunctionConfiguration: { - FunctionArn: 'arn:fn', State: 'Active', LastUpdateStatus: 'Successful', CodeSha256: 'sha-current', + FunctionArn: 'arn:fn', State: 'Active', LastUpdateStatus: 'Successful', }, - ListVersionsByFunction: { Versions: [{ Version: '1', FunctionArn: 'arn:fn:1', CodeSha256: 'sha-OLD' }] }, - UpdateFunctionCode: { FunctionArn: 'arn:fn' }, - PublishVersion: { FunctionArn: 'arn:fn:2', Version: '2' }, + ListVersionsByFunction: { Versions: [{ Version: '$LATEST' }, { Version: '3', FunctionArn: 'arn:fn:3' }] }, }); const result = await edgeOptimize.createEdgeOptimizeLambda(creds, '120569600543'); - expect(result.created).to.equal(false); - expect(result.version).to.equal('2'); - expect(lastLambda('UpdateFunctionCode')).to.not.equal(undefined); + expect(result.status).to.equal('ready'); + expect(result.alreadyExisted).to.equal(true); + expect(result.versionArn).to.equal('arn:fn:3'); + expect(lastLambda('PublishVersion')).to.equal(undefined); // reused, not re-published }); - it('waits for an in-progress update to finish before publishing (no conflict)', async () => { + it('publishes a version when the function is idle but unpublished', async () => { wireIam({ GetRole: { Role: { Arn: 'arn:role' } }, UpdateAssumeRolePolicy: {}, PutRolePolicy: {} }); - let polls = 0; wireLambda({ - // existence check, then waitForLambdaIdle polls: InProgress first, then idle - GetFunctionConfiguration: () => { - polls += 1; - if (polls <= 1) { - return { - FunctionArn: 'arn:fn', State: 'Active', LastUpdateStatus: 'InProgress', CodeSha256: 'sha-1', - }; - } - return { - FunctionArn: 'arn:fn', State: 'Active', LastUpdateStatus: 'Successful', CodeSha256: 'sha-1', - }; + GetFunctionConfiguration: { + FunctionArn: 'arn:fn', State: 'Active', LastUpdateStatus: 'Successful', }, - ListVersionsByFunction: { Versions: [{ Version: '5', FunctionArn: 'arn:fn:5', CodeSha256: 'sha-1' }] }, + ListVersionsByFunction: { Versions: [{ Version: '$LATEST' }] }, + PublishVersion: { FunctionArn: 'arn:fn:1', Version: '1' }, }); const result = await edgeOptimize.createEdgeOptimizeLambda(creds, '120569600543'); - expect(result.versionArn).to.equal('arn:fn:5'); - expect(polls).to.be.greaterThan(1); // it polled past the InProgress state + expect(result.status).to.equal('ready'); + expect(result.versionArn).to.equal('arn:fn:1'); + expect(lastLambda('PublishVersion')).to.not.equal(undefined); + }); + + it('treats a concurrent-create conflict as provisioning', async () => { + wireIam({ GetRole: { Role: { Arn: 'arn:role' } }, UpdateAssumeRolePolicy: {}, PutRolePolicy: {} }); + wireLambda({ + GetFunctionConfiguration: () => notFound(), + CreateFunction: () => Promise.reject(Object.assign(new Error('exists'), { name: 'ResourceConflictException' })), + }); + + const result = await edgeOptimize.createEdgeOptimizeLambda(creds, '120569600543', { roleWaitMs: 0 }); + + expect(result.status).to.equal('provisioning'); }); it('throws for an invalid account id', async () => { @@ -730,7 +720,8 @@ describe('edge-optimize support', () => { }); describe('getEdgeOptimizeLambdaStatus', () => { - it('reports exists:false when the function is absent', async () => { + it('reports roleExists:false + exists:false when nothing is provisioned', async () => { + iamSendStub.callsFake(() => Promise.reject(Object.assign(new Error('no role'), { name: 'NoSuchEntityException' }))); lambdaSendStub.callsFake((cmd) => { if (cmd.commandName === 'GetFunctionConfiguration') { return Promise.reject(Object.assign(new Error('nf'), { name: 'ResourceNotFoundException' })); @@ -740,10 +731,13 @@ describe('edge-optimize support', () => { const result = await edgeOptimize.getEdgeOptimizeLambdaStatus({}); - expect(result).to.deep.equal({ exists: false, versionArn: null }); + expect(result).to.deep.equal({ + roleExists: false, exists: false, versionArn: null, ready: false, + }); }); - it('reports the latest published version when the function exists', async () => { + it('reports the role + published version and ready:true when fully provisioned', async () => { + iamSendStub.callsFake(() => Promise.resolve({ Role: { Arn: 'arn:role' } })); lambdaSendStub.callsFake((cmd) => { if (cmd.commandName === 'GetFunctionConfiguration') { return Promise.resolve({ FunctionArn: 'arn:fn', State: 'Active', LastUpdateStatus: 'Successful' }); @@ -756,13 +750,16 @@ describe('edge-optimize support', () => { const result = await edgeOptimize.getEdgeOptimizeLambdaStatus({}); + expect(result.roleExists).to.equal(true); expect(result.exists).to.equal(true); expect(result.state).to.equal('Active'); expect(result.versionArn).to.equal('arn:fn:2'); expect(result.version).to.equal('2'); + expect(result.ready).to.equal(true); }); - it('reports versionArn null when only $LATEST exists (not yet published)', async () => { + it('reports ready:false (role created, still provisioning) when not yet published', async () => { + iamSendStub.callsFake(() => Promise.resolve({ Role: { Arn: 'arn:role' } })); lambdaSendStub.callsFake((cmd) => { if (cmd.commandName === 'GetFunctionConfiguration') { return Promise.resolve({ FunctionArn: 'arn:fn', State: 'Pending', LastUpdateStatus: 'InProgress' }); @@ -775,8 +772,10 @@ describe('edge-optimize support', () => { const result = await edgeOptimize.getEdgeOptimizeLambdaStatus({}); + expect(result.roleExists).to.equal(true); expect(result.exists).to.equal(true); expect(result.versionArn).to.equal(null); + expect(result.ready).to.equal(false); }); }); From be2feb734ebc62c36cbf4a9fd85e85d598ad9f0c Mon Sep 17 00:00:00 2001 From: Akash Bhardwaj Date: Sun, 21 Jun 2026 19:09:33 +0530 Subject: [PATCH 15/20] test(llmo): esmock edge-optimize once per file (fix CI heap OOM) edge-optimize.test.js re-ran esmock() in beforeEach, re-instantiating the mocked AWS SDK module graph on every test. As this file grew this session it accumulated enough memory to push the 12.5k-test suite past the 4GB V8 heap limit (worker OOM -> '1 failing: Worker terminated' + lost-worker coverage dropping below the 90% gate). Move esmock to a single before() hook and reset only the send stubs per test. Suite run time for this file drops from ~4min to ~7s and the leak is gone. Co-Authored-By: Claude Opus 4.8 (1M context) --- test/support/edge-optimize.test.js | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/test/support/edge-optimize.test.js b/test/support/edge-optimize.test.js index 134774b08d..f947be1b5b 100644 --- a/test/support/edge-optimize.test.js +++ b/test/support/edge-optimize.test.js @@ -21,12 +21,12 @@ describe('edge-optimize support', () => { let lambdaSendStub; let edgeOptimize; - beforeEach(async function setup() { + // esmock ONCE for the whole file (not per-test) — esmock re-instantiates the mocked module + // graph on every call and accumulates memory, which contributes to the suite's heap pressure. + // The mocked clients call the `*SendStub` closures, which read the `let` bindings reassigned + // fresh in beforeEach, so a single esmock works for all tests. + before(async function setupEsmock() { this.timeout(30000); - stsSendStub = sinon.stub(); - cfSendStub = sinon.stub(); - iamSendStub = sinon.stub(); - lambdaSendStub = sinon.stub(); // Each command in a mocked module is a constructor FUNCTION (not a class) — eslint forbids // multiple class declarations in one file, so we capture the command name + input on `this`. const cfCommand = (Name) => function CloudFrontCommand(input) { @@ -92,6 +92,14 @@ describe('edge-optimize support', () => { }); }); + beforeEach(() => { + // Fresh stubs per test; the esmocked clients read these `let` bindings at call time. + stsSendStub = sinon.stub(); + cfSendStub = sinon.stub(); + iamSendStub = sinon.stub(); + lambdaSendStub = sinon.stub(); + }); + afterEach(() => { sinon.restore(); }); From 5dc58a0f8126652c2b66322df7be10fbfa7a8fb9 Mon Sep 17 00:00:00 2001 From: Akash Bhardwaj Date: Mon, 22 Jun 2026 15:00:35 +0530 Subject: [PATCH 16/20] fix(llmo): set EO origin custom headers so Verify can pass The create-origin step created the EdgeOptimize_Origin without its custom headers, so the routing function's request could not authenticate to Edge Optimize or resolve the customer host - Verify never returned an x-edgeoptimize-request-id. - createEdgeOptimizeOrigin now sets x-edgeoptimize-api-key (site EO API key), x-forwarded-host (customer host), and optional x-edgeoptimize-fetcher-key, mirroring the standalone wizard + CloudFormation installer. - Self-heals: an origin created header-less by the earlier version is patched in place on re-run (returns updated: true). - Handler derives both server-side - api key from the tokowaka metaconfig (apiKeys[0]), forwarded host from calculateForwardedHost(site.baseURL) - so no new UI input; gateEdgeOptimizeWizard now returns the site to avoid a second fetch. - Verify: documented the prod TODO (probe the customer's real domain, not the *.cloudfront.net domain) - behavior unchanged for dev testing. Co-Authored-By: Claude Opus 4.8 --- src/controllers/llmo/llmo.js | 39 ++++++++++++-- src/support/edge-optimize.js | 81 ++++++++++++++++++++++++---- test/controllers/llmo/llmo.test.js | 39 ++++++++++++-- test/support/edge-optimize.test.js | 87 +++++++++++++++++++++++++++++- 4 files changed, 225 insertions(+), 21 deletions(-) diff --git a/src/controllers/llmo/llmo.js b/src/controllers/llmo/llmo.js index d3a9d1e003..95b3d81ce8 100644 --- a/src/controllers/llmo/llmo.js +++ b/src/controllers/llmo/llmo.js @@ -2186,7 +2186,7 @@ function LlmoController(ctx) { if (!accessControlUtil.isLLMOAdministrator()) { return { error: forbidden(`Only LLMO administrators can ${action}`) }; } - return {}; + return { site }; }; // Verify the customer's cross-account connector role is assumable. Used by the wizard's @@ -2417,14 +2417,37 @@ function LlmoController(ctx) { } try { - const { error } = await gateEdgeOptimizeWizard(siteId, Site, 'create the edge optimize origin'); + const { error, site } = await gateEdgeOptimizeWizard(siteId, Site, 'create the edge optimize origin'); if (error) { return error; } + // The EO origin needs custom headers so the routing function's request authenticates to Edge + // Optimize (x-edgeoptimize-api-key) and resolves the customer host (x-forwarded-host). Both + // are derived server-side from the site — no UI input. Without them Verify never goes green. + const baseURL = site.getBaseURL(); + const tokowakaClient = TokowakaClient.createFrom(context); + const metaconfig = await tokowakaClient.fetchMetaconfig(baseURL); + const apiKey = metaconfig?.apiKeys?.[0]; + if (!hasText(apiKey)) { + return badRequest('Site has no Edge Optimize API key — enable Edge Optimize for this site first'); + } + const forwardedHost = calculateForwardedHost(baseURL, log); + const { credentials } = await assumeConnectorRole({ accountId, externalId, roleName }); - const result = await createEdgeOptimizeOrigin(credentials, distributionId, originDomain); - log.info(`[edge-optimize-origin] ${result.created ? 'Created' : 'Origin already existed for'} site ${siteId}, distribution ${distributionId}`); + const result = await createEdgeOptimizeOrigin( + credentials, + distributionId, + originDomain, + { apiKey, forwardedHost }, + ); + let action = 'Origin already existed for'; + if (result.created) { + action = 'Created origin for'; + } else if (result.updated) { + action = 'Patched origin headers for'; + } + log.info(`[edge-optimize-origin] ${action} site ${siteId}, distribution ${distributionId}`); return ok(result); } catch (error) { log.error(`Failed to create CloudFront Edge Optimize origin for site ${siteId}:`, error); @@ -2663,6 +2686,14 @@ function LlmoController(ctx) { } // Determine the URL to probe: prefer an explicit domain, else resolve from the distribution. + // + // TODO(prod): probe the customer's REAL onboarded domain, not the *.cloudfront.net domain. + // In prod, bot traffic hits the customer's own domain (their CNAME / distribution alias), so + // that is the true end-to-end test. We have it server-side via the site + // (calculateForwardedHost(site.getBaseURL())) and/or the distribution Aliases. We default to + // the distribution cloudfront.net DomainName today only because the dev test distribution has + // no custom domain. Switch the default to the site/alias domain before prod (keep the + // explicit `domain` override + cloudfront.net fallback for distributions with no alias). let domain = String(context.data?.domain || '').trim(); if (!hasText(domain)) { const { credentials } = await assumeConnectorRole({ accountId, externalId, roleName }); diff --git a/src/support/edge-optimize.js b/src/support/edge-optimize.js index 412010a494..aa4ceace3f 100644 --- a/src/support/edge-optimize.js +++ b/src/support/edge-optimize.js @@ -205,47 +205,104 @@ function getBehaviorFromConfig(config, pathPattern) { } /** - * Add the Edge Optimize origin to a CloudFront distribution (idempotent). + * Build the custom-header items the EO origin must carry. Mirrors the standalone wizard's + * apiCreateOrigin (server.mjs) + the CloudFormation installer: `x-edgeoptimize-api-key` + * authenticates the prerender request to Edge Optimize, `x-forwarded-host` tells EO which site's + * content to serve, and the optional `x-edgeoptimize-fetcher-key` is for WAF-allowlisted customers. + * Without these the origin returns no `x-edgeoptimize-request-id` and Verify never goes green. * - * Mirrors the standalone wizard's create-origin: reads the distribution config, and — only if no - * Edge Optimize origin exists yet — appends a custom HTTPS origin pointing at the EO target domain - * with the EO request headers, then writes it back via UpdateDistribution (deploy propagates in the - * background; we do not block on it). + * @param {object} headers + * @param {string} [headers.apiKey] - the site's Edge Optimize API key. + * @param {string} [headers.forwardedHost] - the customer's canonical site host. + * @param {string} [headers.fetcherKey] - optional fetcher key (WAF allowlist). + * @returns {Array<{HeaderName: string, HeaderValue: string}>} + */ +function buildEdgeOptimizeOriginHeaders({ apiKey, forwardedHost, fetcherKey } = {}) { + const items = []; + if (hasText(apiKey)) { + items.push({ HeaderName: 'x-edgeoptimize-api-key', HeaderValue: apiKey }); + } + if (hasText(forwardedHost)) { + items.push({ HeaderName: 'x-forwarded-host', HeaderValue: forwardedHost }); + } + if (hasText(fetcherKey)) { + items.push({ HeaderName: 'x-edgeoptimize-fetcher-key', HeaderValue: fetcherKey }); + } + return items; +} + +/** + * Add the Edge Optimize origin to a CloudFront distribution (idempotent + self-healing). + * + * Reads the distribution config and, if no Edge Optimize origin exists yet, appends a custom HTTPS + * origin pointing at the EO target domain with the EO request headers. If the origin already exists + * but its custom headers do not match the desired set (e.g. it was created header-less by an + * earlier version), the headers are patched in place. Writes are applied via UpdateDistribution + * (deploy propagates in the background; we do not block on it). * * @param {object} credentials - temporary credentials from {@link assumeConnectorRole}. * @param {string} distributionId - the CloudFront distribution ID. * @param {string} [originDomain] - EO origin domain (env-driven; defaults to the dev EO domain). + * @param {object} [headers] - EO origin headers ({ apiKey, forwardedHost, fetcherKey }). * @param {string} [region] - CloudFront control-plane region. - * @returns {Promise<{created: boolean, alreadyExisted: boolean, originId: string}>} + * @returns {Promise<{created, alreadyExisted, updated, originId}>} origin mutation outcome. */ export async function createEdgeOptimizeOrigin( credentials, distributionId, originDomain = EDGE_OPTIMIZE_DEFAULT_ORIGIN_DOMAIN, + headers = {}, region = EDGE_OPTIMIZE_REGION, ) { if (!hasText(distributionId)) { throw new Error('distributionId is required'); } + const desiredHeaderItems = buildEdgeOptimizeOriginHeaders(headers); + const client = new CloudFrontClient({ region, credentials }); const result = await client.send(new GetDistributionConfigCommand({ Id: distributionId })); const config = result.DistributionConfig; const etag = result.ETag; const origins = config.Origins?.Items || []; - const alreadyExisted = origins.some( + const existing = origins.find( (o) => o.Id === EDGE_OPTIMIZE_ORIGIN_ID || o.DomainName === originDomain, ); - if (alreadyExisted) { - return { created: false, alreadyExisted: true, originId: EDGE_OPTIMIZE_ORIGIN_ID }; + if (existing) { + // Idempotent — but self-heal an origin created without the EO headers (earlier bug): patch its + // CustomHeaders to the desired set when they differ. Never wipe headers if none were supplied. + const toMap = (arr) => (arr || []).reduce((acc, h) => { + acc[h.HeaderName.toLowerCase()] = h.HeaderValue; + return acc; + }, {}); + const current = toMap(existing.CustomHeaders?.Items); + const desired = toMap(desiredHeaderItems); + const headersMatch = Object.keys(desired).length === Object.keys(current).length + && Object.entries(desired).every(([k, v]) => current[k] === v); + + if (desiredHeaderItems.length === 0 || headersMatch) { + return { + created: false, alreadyExisted: true, updated: false, originId: EDGE_OPTIMIZE_ORIGIN_ID, + }; + } + + existing.CustomHeaders = { Quantity: desiredHeaderItems.length, Items: desiredHeaderItems }; + await client.send(new UpdateDistributionCommand({ + Id: distributionId, + IfMatch: etag, + DistributionConfig: config, + })); + return { + created: false, alreadyExisted: true, updated: true, originId: EDGE_OPTIMIZE_ORIGIN_ID, + }; } origins.push({ Id: EDGE_OPTIMIZE_ORIGIN_ID, DomainName: originDomain, OriginPath: '', - CustomHeaders: { Quantity: 0, Items: [] }, + CustomHeaders: { Quantity: desiredHeaderItems.length, Items: desiredHeaderItems }, CustomOriginConfig: { HTTPPort: 80, HTTPSPort: 443, @@ -265,7 +322,9 @@ export async function createEdgeOptimizeOrigin( DistributionConfig: config, })); - return { created: true, alreadyExisted: false, originId: EDGE_OPTIMIZE_ORIGIN_ID }; + return { + created: true, alreadyExisted: false, updated: false, originId: EDGE_OPTIMIZE_ORIGIN_ID, + }; } /** diff --git a/test/controllers/llmo/llmo.test.js b/test/controllers/llmo/llmo.test.js index cab086718f..99575094ef 100644 --- a/test/controllers/llmo/llmo.test.js +++ b/test/controllers/llmo/llmo.test.js @@ -8193,8 +8193,9 @@ describe('LlmoController', () => { credentials: { accessKeyId: 'AKIA', secretAccessKey: 'secret', sessionToken: 'token' }, }); createEdgeOptimizeOriginStub = sinon.stub().resolves({ - created: true, alreadyExisted: false, originId: 'EdgeOptimize_Origin', + created: true, alreadyExisted: false, updated: false, originId: 'EdgeOptimize_Origin', }); + mockTokowakaClient.fetchMetaconfig.resolves({ apiKeys: ['eo-key-123'] }); originContext = { ...mockContext, params: { siteId: TEST_SITE_ID }, @@ -8212,9 +8213,16 @@ describe('LlmoController', () => { expect(result.status).to.equal(200); const body = await result.json(); - expect(body).to.deep.equal({ created: true, alreadyExisted: false, originId: 'EdgeOptimize_Origin' }); + expect(body).to.deep.equal({ + created: true, alreadyExisted: false, updated: false, originId: 'EdgeOptimize_Origin', + }); expect(assumeConnectorRoleStub.calledOnce).to.equal(true); - expect(createEdgeOptimizeOriginStub.calledOnceWith(sinon.match.any, 'E2EXAMPLE123', 'dev.edgeoptimize.net')).to.equal(true); + expect(createEdgeOptimizeOriginStub.calledOnceWith( + sinon.match.any, + 'E2EXAMPLE123', + 'dev.edgeoptimize.net', + sinon.match({ apiKey: 'eo-key-123', forwardedHost: 'www.example.com' }), + )).to.equal(true); }); it('passes the env-driven origin domain when set', async () => { @@ -8226,9 +8234,20 @@ describe('LlmoController', () => { expect(createEdgeOptimizeOriginStub.calledOnceWith(sinon.match.any, 'E2EXAMPLE123', 'live.edgeoptimize.net')).to.equal(true); }); + it('returns 400 when the site has no Edge Optimize API key', async () => { + mockTokowakaClient.fetchMetaconfig.resolves({ apiKeys: [] }); + + const result = await controller.createEdgeOptimizeOrigin(originContext); + + expect(result.status).to.equal(400); + const body = await result.json(); + expect(body.message).to.include('API key'); + expect(createEdgeOptimizeOriginStub.called).to.equal(false); + }); + it('is idempotent when the origin already exists', async () => { createEdgeOptimizeOriginStub = sinon.stub().resolves({ - created: false, alreadyExisted: true, originId: 'EdgeOptimize_Origin', + created: false, alreadyExisted: true, updated: false, originId: 'EdgeOptimize_Origin', }); const result = await controller.createEdgeOptimizeOrigin(originContext); @@ -8236,6 +8255,18 @@ describe('LlmoController', () => { expect(body.alreadyExisted).to.equal(true); }); + it('reports a header patch on an existing header-less origin', async () => { + createEdgeOptimizeOriginStub = sinon.stub().resolves({ + created: false, alreadyExisted: true, updated: true, originId: 'EdgeOptimize_Origin', + }); + + const result = await controller.createEdgeOptimizeOrigin(originContext); + + expect(result.status).to.equal(200); + const body = await result.json(); + expect(body.updated).to.equal(true); + }); + it('returns 400 for an invalid account id', async () => { const result = await controller.createEdgeOptimizeOrigin({ ...originContext, data: { ...originContext.data, accountId: '123' } }); expect(result.status).to.equal(400); diff --git a/test/support/edge-optimize.test.js b/test/support/edge-optimize.test.js index f947be1b5b..f4be403820 100644 --- a/test/support/edge-optimize.test.js +++ b/test/support/edge-optimize.test.js @@ -304,7 +304,9 @@ describe('edge-optimize support', () => { const result = await edgeOptimize.createEdgeOptimizeOrigin({}, 'E2EXAMPLE', 'dev.edgeoptimize.net'); - expect(result).to.deep.equal({ created: true, alreadyExisted: false, originId: 'EdgeOptimize_Origin' }); + expect(result).to.deep.equal({ + created: true, alreadyExisted: false, updated: false, originId: 'EdgeOptimize_Origin', + }); expect(cfSendStub.secondCall.args[0].commandName).to.equal('UpdateDistribution'); const update = cfSendStub.secondCall.args[0].input; expect(update.IfMatch).to.equal('etag-1'); @@ -313,6 +315,31 @@ describe('edge-optimize support', () => { expect(added.CustomOriginConfig.OriginProtocolPolicy).to.equal('https-only'); }); + it('sets the EO custom headers on the new origin', async () => { + cfSendStub.onFirstCall().resolves({ + DistributionConfig: { Origins: { Quantity: 1, Items: [{ Id: 'origin-aem', DomainName: 'origin.example.com' }] } }, + ETag: 'etag-1', + }); + cfSendStub.onSecondCall().resolves({}); + + await edgeOptimize.createEdgeOptimizeOrigin({}, 'E2EXAMPLE', 'dev.edgeoptimize.net', { + apiKey: 'eo-key-123', forwardedHost: 'www.example.com', fetcherKey: 'fk-9', + }); + + const update = cfSendStub.secondCall.args[0].input; + const added = update.DistributionConfig.Origins.Items.find((o) => o.Id === 'EdgeOptimize_Origin'); + expect(added.CustomHeaders.Quantity).to.equal(3); + const headerMap = added.CustomHeaders.Items.reduce((acc, h) => { + acc[h.HeaderName] = h.HeaderValue; + return acc; + }, {}); + expect(headerMap).to.deep.equal({ + 'x-edgeoptimize-api-key': 'eo-key-123', + 'x-forwarded-host': 'www.example.com', + 'x-edgeoptimize-fetcher-key': 'fk-9', + }); + }); + it('is idempotent when the origin already exists by id', async () => { cfSendStub.resolves({ DistributionConfig: { Origins: { Quantity: 1, Items: [{ Id: 'EdgeOptimize_Origin', DomainName: 'x' }] } }, @@ -321,7 +348,9 @@ describe('edge-optimize support', () => { const result = await edgeOptimize.createEdgeOptimizeOrigin({}, 'E2EXAMPLE'); - expect(result).to.deep.equal({ created: false, alreadyExisted: true, originId: 'EdgeOptimize_Origin' }); + expect(result).to.deep.equal({ + created: false, alreadyExisted: true, updated: false, originId: 'EdgeOptimize_Origin', + }); expect(cfSendStub.calledOnce).to.equal(true); // never updated }); @@ -337,6 +366,60 @@ describe('edge-optimize support', () => { expect(cfSendStub.calledOnce).to.equal(true); }); + it('patches the headers when the origin exists without them (self-heal)', async () => { + cfSendStub.onFirstCall().resolves({ + DistributionConfig: { + Origins: { + Quantity: 1, + Items: [{ Id: 'EdgeOptimize_Origin', DomainName: 'dev.edgeoptimize.net', CustomHeaders: { Quantity: 0, Items: [] } }], + }, + }, + ETag: 'etag-1', + }); + cfSendStub.onSecondCall().resolves({}); + + const result = await edgeOptimize.createEdgeOptimizeOrigin({}, 'E2EXAMPLE', 'dev.edgeoptimize.net', { + apiKey: 'eo-key-123', forwardedHost: 'www.example.com', + }); + + expect(result).to.deep.equal({ + created: false, alreadyExisted: true, updated: true, originId: 'EdgeOptimize_Origin', + }); + expect(cfSendStub.secondCall.args[0].commandName).to.equal('UpdateDistribution'); + const patched = cfSendStub.secondCall.args[0].input + .DistributionConfig.Origins.Items.find((o) => o.Id === 'EdgeOptimize_Origin'); + expect(patched.CustomHeaders.Quantity).to.equal(2); + }); + + it('does not patch when the existing headers already match', async () => { + cfSendStub.resolves({ + DistributionConfig: { + Origins: { + Quantity: 1, + Items: [{ + Id: 'EdgeOptimize_Origin', + DomainName: 'dev.edgeoptimize.net', + CustomHeaders: { + Quantity: 2, + Items: [ + { HeaderName: 'x-edgeoptimize-api-key', HeaderValue: 'eo-key-123' }, + { HeaderName: 'x-forwarded-host', HeaderValue: 'www.example.com' }, + ], + }, + }], + }, + }, + ETag: 'etag-1', + }); + + const result = await edgeOptimize.createEdgeOptimizeOrigin({}, 'E2EXAMPLE', 'dev.edgeoptimize.net', { + apiKey: 'eo-key-123', forwardedHost: 'www.example.com', + }); + + expect(result.updated).to.equal(false); + expect(cfSendStub.calledOnce).to.equal(true); // no UpdateDistribution + }); + it('throws when the distribution id is missing', async () => { let error; try { From 6bebfaf31fbe321d979d45620baa62b8548f233f Mon Sep 17 00:00:00 2001 From: Akash Bhardwaj Date: Mon, 22 Jun 2026 15:29:41 +0530 Subject: [PATCH 17/20] fix(llmo): scope EO connector-role trust default to the dev exec role Tighten the dev-only default for EDGE_OPTIMIZE_TRUSTED_PRINCIPAL_ARN from the whole dev account (arn:aws:iam::682033462621:root) to the exact assuming identity - the spacecat-api-service Lambda execution role (arn:aws:iam::682033462621:role/spacecat-role-lambda-generic) - shrinking the blast radius of the connector-role trust. No AWS-side change needed; the assuming identity is already that role. Prod must still set this via env to the prod execution role ARN (no in-code default) - tracked in the punch list. Co-Authored-By: Claude Opus 4.8 --- src/controllers/llmo/llmo.js | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/controllers/llmo/llmo.js b/src/controllers/llmo/llmo.js index 95b3d81ce8..fbdae44efb 100644 --- a/src/controllers/llmo/llmo.js +++ b/src/controllers/llmo/llmo.js @@ -2130,11 +2130,12 @@ function LlmoController(ctx) { const presignTtlSeconds = Number(env.EDGE_OPTIMIZE_PRESIGN_TTL || 900); const externalId = crypto.randomUUID(); const roleArn = `arn:aws:iam::${accountId}:role/${roleName}`; - // TEMPORARY (testing only): default the trust to the dev signer account so the - // cross-account test works (dev signs, stage is the customer where the role is made). - // TODO: REMOVE before merge/prod — set EDGE_OPTIMIZE_TRUSTED_PRINCIPAL_ARN via env. + // TEMPORARY (testing only): default the trust to the dev signer's Lambda execution role + // (the exact identity that calls AssumeRole), not the whole account — smaller blast radius. + // TODO: REMOVE before prod — set EDGE_OPTIMIZE_TRUSTED_PRINCIPAL_ARN to the PROD + // spacecat-api-service Lambda execution role ARN via env (no in-code default). const trustedPrincipalArn = env.EDGE_OPTIMIZE_TRUSTED_PRINCIPAL_ARN - || 'arn:aws:iam::682033462621:root'; + || 'arn:aws:iam::682033462621:role/spacecat-role-lambda-generic'; // Presign the (private) template so the customer's CloudFormation can read it // cross-account via the signature — no public bucket, no customer S3 access. From 0f7b2fdea3a8aaccf364abaa0cb307e0b51e38ed Mon Sep 17 00:00:00 2001 From: Akash Bhardwaj Date: Mon, 22 Jun 2026 16:24:48 +0530 Subject: [PATCH 18/20] feat(llmo): add idempotent step-on-poll Edge Optimize CloudFront deploy orchestrator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add POST /sites/:siteId/llmo/edge-optimize/deploy: a single endpoint the wizard FE calls once then polls (~30s). Each call advances origin → function → cache → lambda → associate → verify as far as it safely can (well under the ~60s gateway timeout) and returns per-step status. Safe to call repeatedly: - function step gated on DescribeFunction(Stage:LIVE) so it never re-publishes - associate step gated on the target behavior already referencing our CF function + Lambda so it never re-issues UpdateDistribution - origin + cache reuse the existing idempotent support fns - lambda kicks off / polls without re-creating; sequence pauses while it provisions and resumes on the next poll - verify is best-effort: in_progress (never error) until CloudFront propagates - a step that throws is marked error on its own row; the response is still 200 so the FE shows the failure and a re-poll retries idempotently Orchestration lives in runEdgeOptimizeDeployStep (support/edge-optimize.js, unit-testable without HTTP); the deployEdgeOptimize handler does validation + gate + metaconfig/forwarded-host + a single assumeConnectorRole, then delegates. Wires the route into routes/index.js + required-capabilities INTERNAL_ROUTES, and the OpenAPI path + request schema. Adds support-fn and handler tests. Co-Authored-By: Claude Opus 4.8 --- docs/openapi/api.yaml | 2 + docs/openapi/llmo-api.yaml | 142 ++++++++++++ src/controllers/llmo/llmo.js | 77 ++++++ src/routes/index.js | 1 + src/routes/required-capabilities.js | 1 + src/support/edge-optimize.js | 218 +++++++++++++++++ test/controllers/llmo/llmo.test.js | 145 ++++++++++++ test/routes/index.test.js | 2 + test/support/edge-optimize.test.js | 348 ++++++++++++++++++++++++++++ 9 files changed, 936 insertions(+) diff --git a/docs/openapi/api.yaml b/docs/openapi/api.yaml index 003d22c4be..ca3d8ffd72 100644 --- a/docs/openapi/api.yaml +++ b/docs/openapi/api.yaml @@ -649,6 +649,8 @@ paths: $ref: './llmo-api.yaml#/site-llmo-edge-optimize-apply-associations' /sites/{siteId}/llmo/edge-optimize/verify: $ref: './llmo-api.yaml#/site-llmo-edge-optimize-verify' + /sites/{siteId}/llmo/edge-optimize/deploy: + $ref: './llmo-api.yaml#/site-llmo-edge-optimize-deploy' /sites/{siteId}/llmo/edge-optimize-status: $ref: './llmo-api.yaml#/llmo-edge-optimize-status' /sites/{siteId}/llmo/probes/edge-optimize: diff --git a/docs/openapi/llmo-api.yaml b/docs/openapi/llmo-api.yaml index ed95da1641..4405fc96d6 100644 --- a/docs/openapi/llmo-api.yaml +++ b/docs/openapi/llmo-api.yaml @@ -3124,6 +3124,115 @@ site-llmo-edge-optimize-verify: security: - api_key: [ ] +site-llmo-edge-optimize-deploy: + post: + tags: + - llmo + summary: Orchestrate the full Edge Optimize CloudFront deploy (idempotent, step-on-poll) + description: | + Single endpoint that drives the CloudFront "Deploy routing" wizard end-to-end. The frontend + calls it once and then polls it every ~30s; each call advances the deploy sequence (origin → + routing function → cache policy → Lambda@Edge → association → verify) as far as it safely can + while staying well under the gateway's ~60s first-byte timeout, and returns the per-step + status. It is safe to call repeatedly: completed steps are gated and never re-mutated (no + CloudFront re-deploy churn, no routing-function re-publish). When the Lambda@Edge is still + provisioning the sequence pauses (associate/verify stay pending) and the next poll re-checks. + The caller passes the customer's selected distribution, failover origin, and behavior + explicitly; the Edge Optimize API key and forwarded host are derived server-side from the site. + Always returns HTTP 200 unless validation or the access gate fails; a step that errors is + surfaced on its own row with `status: error` rather than failing the whole response. + operationId: deployEdgeOptimize + parameters: + - $ref: './parameters.yaml#/siteId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/edge-optimize-deploy-request' + responses: + '200': + description: The current per-step deploy status. + content: + application/json: + schema: + type: object + required: + - routingDeployed + - verified + - steps + properties: + routingDeployed: + type: boolean + description: True once the association step has completed. + verified: + type: boolean + description: True once end-to-end routing verification passes. + steps: + type: array + items: + type: object + required: + - key + - label + - status + properties: + key: + type: string + enum: + - origin + - function + - cache + - lambda + - associate + - verify + label: + type: string + status: + type: string + enum: + - pending + - in_progress + - done + - error + detail: + type: string + example: + routingDeployed: false + verified: false + steps: + - key: origin + label: Edge Optimize origin + status: done + - key: function + label: Routing function + status: done + - key: cache + label: Cache policy + status: done + - key: lambda + label: Lambda@Edge + status: in_progress + detail: Lambda@Edge is still provisioning + - key: associate + label: Association + status: pending + - key: verify + label: Verify routing + status: pending + '400': + $ref: './responses.yaml#/400' + '401': + $ref: './responses.yaml#/401' + '403': + $ref: './responses.yaml#/403' + '404': + $ref: './responses.yaml#/404' + '500': + $ref: './responses.yaml#/500' + security: + - api_key: [ ] + edge-optimize-connector-request: type: object required: @@ -3218,6 +3327,39 @@ edge-optimize-associate-request: description: The published, versioned Lambda@Edge ARN from the create-lambda step. example: arn:aws:lambda:us-east-1:682033462621:function:edgeoptimize-origin:1 +edge-optimize-deploy-request: + type: object + required: + - accountId + - externalId + - distributionId + - originId + - behavior + properties: + accountId: + type: string + description: The 12-digit AWS account ID hosting the customer's CloudFront distribution. + pattern: '^[0-9]{12}$' + example: '682033462621' + externalId: + type: string + description: Per-session external ID baked into the connector role's trust policy. + example: '7ff9518a-cf59-40b4-aa53-68a3cb2e24a5' + distributionId: + type: string + description: The CloudFront distribution ID to configure. + example: E2EXAMPLE123 + originId: + type: string + description: >- + The customer's existing failover origin id (the default behavior's target origin), + baked into the routing function's failover origin group. + example: origin-aem + behavior: + type: string + description: The cache behavior path pattern to target; use `default` for the default behavior. + example: default + llmo-probe-edge-optimize: get: tags: diff --git a/src/controllers/llmo/llmo.js b/src/controllers/llmo/llmo.js index fbdae44efb..2f8db4fce5 100644 --- a/src/controllers/llmo/llmo.js +++ b/src/controllers/llmo/llmo.js @@ -44,6 +44,7 @@ import { getEdgeOptimizeLambdaStatus, applyEdgeOptimizeAssociations, verifyEdgeOptimizeRouting, + runEdgeOptimizeDeployStep, } from '../../support/edge-optimize.js'; import { UnauthorizedProductError } from '../../support/errors.js'; import { cachedOk } from '../../support/cached-response.js'; @@ -2716,6 +2717,81 @@ function LlmoController(ctx) { } }; + // Idempotent, step-on-poll orchestrator for the CloudFront "Deploy routing" wizard. The FE calls + // this once then polls it (~30s); each call advances origin → function → cache → lambda → + // associate → verify as far as it safely can (well under the gateway's ~60s timeout) and returns + // per-step status. Safe to call repeatedly — gated steps never re-mutate completed work. The FE + // passes the customer's selected distribution, failover origin, and behavior explicitly; the EO + // API key + forwarded host are derived server-side from the site (no UI input). + const deployEdgeOptimizeHandler = async (context) => { + const { log, dataAccess, env } = context; + const { siteId } = context.params; + const { Site } = dataAccess; + const accountId = String(context.data?.accountId || '').replace(/\D/g, ''); + const externalId = String(context.data?.externalId || '').trim(); + const distributionId = String(context.data?.distributionId || '').trim(); + const originId = String(context.data?.originId || '').trim(); + const behavior = String(context.data?.behavior || '').trim(); + const roleName = env?.EDGE_OPTIMIZE_ROLE_NAME || undefined; + const originDomain = env?.EDGE_OPTIMIZE_ORIGIN_DOMAIN || 'dev.edgeoptimize.net'; + + if (accountId.length !== 12) { + return badRequest('accountId must be a 12-digit AWS account ID'); + } + if (!hasText(externalId)) { + return badRequest('externalId is required'); + } + if (!hasText(distributionId)) { + return badRequest('distributionId is required'); + } + if (!hasText(originId)) { + return badRequest('originId is required'); + } + if (!hasText(behavior)) { + return badRequest('behavior is required'); + } + + try { + const { error, site } = await gateEdgeOptimizeWizard(siteId, Site, 'deploy edge optimize routing'); + if (error) { + return error; + } + + // The EO origin needs custom headers so the routing function's request authenticates to Edge + // Optimize (x-edgeoptimize-api-key) and resolves the customer host (x-forwarded-host). Both + // are derived server-side from the site — no UI input. Without them Verify never goes green. + const baseURL = site.getBaseURL(); + const tokowakaClient = TokowakaClient.createFrom(context); + const metaconfig = await tokowakaClient.fetchMetaconfig(baseURL); + const apiKey = metaconfig?.apiKeys?.[0]; + if (!hasText(apiKey)) { + return badRequest('Site has no Edge Optimize API key — enable Edge Optimize for this site first'); + } + const forwardedHost = calculateForwardedHost(baseURL, log); + + // Assume the connector role ONCE; all steps run with the same short-lived credentials. + const { credentials, accountId: resolvedAccountId } = await assumeConnectorRole({ + accountId, externalId, roleName, + }); + + const result = await runEdgeOptimizeDeployStep(credentials, { + distributionId, + originId, + behavior, + originDomain, + originHeaders: { apiKey, forwardedHost }, + accountId: resolvedAccountId, + }); + + log.info(`[edge-optimize-deploy] site ${siteId}: routingDeployed=${result.routingDeployed},` + + ` verified=${result.verified}, steps=${result.steps.map((s) => `${s.key}:${s.status}`).join(',')}`); + return ok(result); + } catch (error) { + log.error(`[edge-optimize-deploy] Failed for site ${siteId}:`, error); + return badRequest(cleanupHeaderValue(error.message)); + } + }; + return { getEdgeOptimizeBootstrapUrl, connectEdgeOptimize, @@ -2730,6 +2806,7 @@ function LlmoController(ctx) { getEdgeOptimizeLambdaStatus: getEdgeOptimizeLambdaStatusHandler, applyEdgeOptimizeAssociations: applyEdgeOptimizeAssociationsHandler, verifyEdgeOptimizeRouting: verifyEdgeOptimizeRoutingHandler, + deployEdgeOptimize: deployEdgeOptimizeHandler, getLlmoSheetData, queryLlmoSheetData, getLlmoGlobalSheetData, diff --git a/src/routes/index.js b/src/routes/index.js index 40b4b73818..a9cae2b1bf 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -493,6 +493,7 @@ export default function getRouteHandlers( 'POST /sites/:siteId/llmo/edge-optimize/lambda-status': llmoController.getEdgeOptimizeLambdaStatus, 'POST /sites/:siteId/llmo/edge-optimize/apply-associations': llmoController.applyEdgeOptimizeAssociations, 'POST /sites/:siteId/llmo/edge-optimize/verify': llmoController.verifyEdgeOptimizeRouting, + 'POST /sites/:siteId/llmo/edge-optimize/deploy': llmoController.deployEdgeOptimize, 'GET /sites/:siteId/llmo/strategy': llmoController.getStrategy, 'PUT /sites/:siteId/llmo/strategy': llmoController.saveStrategy, 'GET /sites/:siteId/llmo/edge-optimize-status': llmoController.checkEdgeOptimizeStatus, diff --git a/src/routes/required-capabilities.js b/src/routes/required-capabilities.js index b3d1f4d512..a57400d32d 100644 --- a/src/routes/required-capabilities.js +++ b/src/routes/required-capabilities.js @@ -140,6 +140,7 @@ export const INTERNAL_ROUTES = [ 'POST /sites/:siteId/llmo/edge-optimize/lambda-status', 'POST /sites/:siteId/llmo/edge-optimize/apply-associations', 'POST /sites/:siteId/llmo/edge-optimize/verify', + 'POST /sites/:siteId/llmo/edge-optimize/deploy', 'PUT /sites/:siteId/llmo/opportunities-reviewed', // PLG onboarding - IMS token auth, self-service flow, not S2S diff --git a/src/support/edge-optimize.js b/src/support/edge-optimize.js index aa4ceace3f..257689bbf3 100644 --- a/src/support/edge-optimize.js +++ b/src/support/edge-optimize.js @@ -42,6 +42,7 @@ import { PublishVersionCommand, } from '@aws-sdk/client-lambda'; import { hasText } from '@adobe/spacecat-shared-utils'; +import { cleanupHeaderValue } from '@adobe/helix-shared-utils'; // CloudFront is a global service; its control plane lives in us-east-1. export const EDGE_OPTIMIZE_REGION = 'us-east-1'; @@ -1119,3 +1120,220 @@ export async function verifyEdgeOptimizeRouting(url) { return { passed, requestId, details: { bot, human } }; } + +// The ordered deploy steps + their human labels, in the sequence the orchestrator advances them. +// Exported so the controller/tests can assert the contract without re-declaring it. +export const EDGE_OPTIMIZE_DEPLOY_STEPS = [ + { key: 'origin', label: 'Edge Optimize origin' }, + { key: 'function', label: 'Routing function' }, + { key: 'cache', label: 'Cache policy' }, + { key: 'lambda', label: 'Lambda@Edge' }, + { key: 'associate', label: 'Association' }, + { key: 'verify', label: 'Verify routing' }, +]; + +/** + * True when the `edgeoptimize-routing` CloudFront Function is already published to LIVE. + * Used to gate the function step so we never re-publish (which causes deploy churn). + * + * @param {CloudFrontClient} client - a CloudFront client built with the connector credentials. + * @returns {Promise} + */ +async function isRoutingFunctionLive(client) { + try { + const desc = await client.send(new DescribeFunctionCommand({ + Name: EDGE_OPTIMIZE_FUNCTION_NAME, + Stage: 'LIVE', + })); + return Boolean(desc?.FunctionSummary?.FunctionMetadata?.FunctionARN); + } catch (err) { + if (err.name === 'NoSuchFunctionExists') { + return false; + } + throw err; + } +} + +/** + * True when the target behavior already has BOTH the Edge Optimize routing CloudFront Function + * (viewer-request) AND the Edge Optimize Lambda@Edge (origin-request) associated. Used to gate the + * associate step so we never re-issue UpdateDistribution (needless re-deploy) once wired. + * + * @param {CloudFrontClient} client - a CloudFront client built with the connector credentials. + * @param {string} distributionId - the CloudFront distribution ID. + * @param {string} pathPattern - the behavior to inspect (`default` for the default behavior). + * @returns {Promise} + */ +async function isBehaviorAlreadyAssociated(client, distributionId, pathPattern) { + const result = await client.send(new GetDistributionConfigCommand({ Id: distributionId })); + const config = result.DistributionConfig || {}; + let behavior; + if (pathPattern === 'default' || pathPattern === 'Default (*)') { + behavior = config.DefaultCacheBehavior; + } else { + behavior = (config.CacheBehaviors?.Items || []).find((b) => b.PathPattern === pathPattern); + } + if (!behavior) { + return false; + } + const hasCfFunction = (behavior.FunctionAssociations?.Items || []).some( + (a) => a.EventType === 'viewer-request' && /edgeoptimize-routing/i.test(a.FunctionARN || ''), + ); + const hasLambda = (behavior.LambdaFunctionAssociations?.Items || []).some( + (a) => a.EventType === 'origin-request' && /edgeoptimize-origin/i.test(a.LambdaFunctionARN || ''), + ); + return hasCfFunction && hasLambda; +} + +/** + * Run one poll of the idempotent Edge Optimize "Deploy routing" orchestrator. + * + * Advances the deploy sequence (origin → function → cache → lambda → associate → verify) as far as + * it safely can in a single call, staying well under the CDN/gateway ~60s first-byte timeout. Each + * step is gated so a re-poll never re-mutates already-completed work (no CloudFront re-deploy + * churn, no CF-function re-publish). Designed to be called once and then polled every ~30s by the + * wizard UI: each call returns the per-step status and the FE keeps polling until verify is green. + * + * Stops advancing (returning earlier steps' real status and later steps `pending`) when the + * Lambda@Edge is still provisioning — the next poll re-checks. A step that throws is marked + * `error` on its own row (with later steps `pending`); the caller still returns HTTP 200 so the FE + * shows the failure on that row and a re-poll retries idempotently. + * + * @param {object} credentials - temporary credentials from {@link assumeConnectorRole}. + * @param {object} params + * @param {string} params.distributionId - the CloudFront distribution ID. + * @param {string} params.originId - the default-behavior target origin id (failover origin). + * @param {string} params.behavior - the cache behavior to target (`default` for the default). + * @param {string} [params.originDomain] - the Edge Optimize origin domain. + * @param {object} [params.originHeaders] - EO origin headers ({ apiKey, forwardedHost }). + * @param {string} params.accountId - the 12-digit customer AWS account ID. + * @param {string} [region] - CloudFront control-plane region. + * @returns {Promise<{routingDeployed: boolean, verified: boolean, steps: Array}>} + */ +export async function runEdgeOptimizeDeployStep( + credentials, + { + distributionId, originId, behavior, originDomain, originHeaders, accountId, + }, + region = EDGE_OPTIMIZE_REGION, +) { + // Start every step pending; each handler flips its own row to done/in_progress/error. + const steps = EDGE_OPTIMIZE_DEPLOY_STEPS.map((s) => ({ ...s, status: 'pending' })); + const byKey = (key) => steps.find((s) => s.key === key); + const client = new CloudFrontClient({ region, credentials }); + + let routingDeployed = false; + let verified = false; + let lambdaVersionArn = null; + + // ── 1. origin — already idempotent (no UpdateDistribution when headers match). ── + try { + await createEdgeOptimizeOrigin( + credentials, + distributionId, + originDomain, + originHeaders, + region, + ); + byKey('origin').status = 'done'; + } catch (err) { + byKey('origin').status = 'error'; + byKey('origin').detail = cleanupHeaderValue(err.message); + return { routingDeployed, verified, steps }; + } + + // ── 2. function — GATE: skip the create+publish when already LIVE (avoids re-publish churn). ── + try { + if (await isRoutingFunctionLive(client)) { + byKey('function').status = 'done'; + } else { + await createEdgeOptimizeRoutingFunction(credentials, originId, null, region); + byKey('function').status = 'done'; + } + } catch (err) { + byKey('function').status = 'error'; + byKey('function').detail = cleanupHeaderValue(err.message); + return { routingDeployed, verified, steps }; + } + + // ── 3. cache — idempotent (skips UpdateDistribution/UpdateCachePolicy when already applied). ── + try { + await applyEdgeOptimizeCacheHeaders(credentials, distributionId, behavior, { region }); + byKey('cache').status = 'done'; + } catch (err) { + byKey('cache').status = 'error'; + byKey('cache').detail = cleanupHeaderValue(err.message); + return { routingDeployed, verified, steps }; + } + + // ── 4. lambda — kick off / poll WITHOUT re-creating; stop here while still provisioning. ── + try { + const ls = await getEdgeOptimizeLambdaStatus(credentials, region); + if (ls.ready) { + lambdaVersionArn = ls.versionArn; + byKey('lambda').status = 'done'; + } else if (ls.exists) { + // Provisioning in progress — do NOT re-create. Hold the sequence and re-check next poll. + byKey('lambda').status = 'in_progress'; + byKey('lambda').detail = 'Lambda@Edge is still provisioning'; + return { routingDeployed, verified, steps }; + } else { + // Not created yet — kick off the create (returns fast) and report in_progress. + await createEdgeOptimizeLambda(credentials, accountId, { region }); + byKey('lambda').status = 'in_progress'; + byKey('lambda').detail = 'Lambda@Edge create started'; + return { routingDeployed, verified, steps }; + } + } catch (err) { + byKey('lambda').status = 'error'; + byKey('lambda').detail = cleanupHeaderValue(err.message); + return { routingDeployed, verified, steps }; + } + + // ── 5. associate — GATE: skip UpdateDistribution when the behavior is already wired. ── + try { + if (await isBehaviorAlreadyAssociated(client, distributionId, behavior)) { + byKey('associate').status = 'done'; + } else { + await applyEdgeOptimizeAssociations( + credentials, + distributionId, + behavior, + lambdaVersionArn, + region, + ); + byKey('associate').status = 'done'; + } + routingDeployed = true; + } catch (err) { + byKey('associate').status = 'error'; + byKey('associate').detail = cleanupHeaderValue(err.message); + return { routingDeployed, verified, steps }; + } + + // ── 6. verify — BEST-EFFORT: in_progress (not error) until CloudFront propagation lets pass. ── + try { + const distributions = await listCloudFrontDistributions(credentials, region); + const match = distributions.find((d) => d.id === distributionId); + const domain = match?.domainName; + if (!hasText(domain)) { + byKey('verify').status = 'in_progress'; + byKey('verify').detail = 'waiting for distribution domain'; + return { routingDeployed, verified, steps }; + } + const result = await verifyEdgeOptimizeRouting(`https://${domain}/`); + if (result.passed) { + verified = true; + byKey('verify').status = 'done'; + } else { + byKey('verify').status = 'in_progress'; + byKey('verify').detail = 'waiting for propagation'; + } + } catch (err) { + // Never fail the whole deploy because verify could not run yet — surface as in_progress. + byKey('verify').status = 'in_progress'; + byKey('verify').detail = cleanupHeaderValue(err.message); + } + + return { routingDeployed, verified, steps }; +} diff --git a/test/controllers/llmo/llmo.test.js b/test/controllers/llmo/llmo.test.js index 99575094ef..6cf30a924c 100644 --- a/test/controllers/llmo/llmo.test.js +++ b/test/controllers/llmo/llmo.test.js @@ -96,6 +96,7 @@ describe('LlmoController', () => { let getEdgeOptimizeLambdaStatusStub; let applyEdgeOptimizeAssociationsStub; let verifyEdgeOptimizeRoutingStub; + let runEdgeOptimizeDeployStepStub; let mockTokowakaClient; let readStrategyStub; let writeStrategyStub; @@ -214,6 +215,7 @@ describe('LlmoController', () => { getEdgeOptimizeLambdaStatusStub = sinon.stub(); applyEdgeOptimizeAssociationsStub = sinon.stub(); verifyEdgeOptimizeRoutingStub = sinon.stub(); + runEdgeOptimizeDeployStepStub = sinon.stub(); // Initialize mock TokowakaClient mockTokowakaClient = { @@ -295,6 +297,7 @@ describe('LlmoController', () => { getEdgeOptimizeLambdaStatus: (...args) => getEdgeOptimizeLambdaStatusStub(...args), applyEdgeOptimizeAssociations: (...args) => applyEdgeOptimizeAssociationsStub(...args), verifyEdgeOptimizeRouting: (...args) => verifyEdgeOptimizeRoutingStub(...args), + runEdgeOptimizeDeployStep: (...args) => runEdgeOptimizeDeployStepStub(...args), }, '@adobe/spacecat-shared-ims-client': { ImsClient: function MockImsClient() { @@ -8850,6 +8853,148 @@ describe('LlmoController', () => { }); }); + describe('deployEdgeOptimize', () => { + let deployContext; + + const sampleSteps = [ + { key: 'origin', label: 'Edge Optimize origin', status: 'done' }, + { key: 'function', label: 'Routing function', status: 'done' }, + { key: 'cache', label: 'Cache policy', status: 'done' }, + { key: 'lambda', label: 'Lambda@Edge', status: 'in_progress' }, + { key: 'associate', label: 'Association', status: 'pending' }, + { key: 'verify', label: 'Verify routing', status: 'pending' }, + ]; + + beforeEach(() => { + assumeConnectorRoleStub = sinon.stub().resolves({ + roleArn: 'arn:aws:iam::120569600543:role/AdobeLLMOptimizerCloudFrontConnectorRole', + accountId: '120569600543', + credentials: { accessKeyId: 'AKIA', secretAccessKey: 'secret', sessionToken: 'token' }, + }); + runEdgeOptimizeDeployStepStub = sinon.stub().resolves({ + routingDeployed: false, + verified: false, + steps: sampleSteps, + }); + mockTokowakaClient.fetchMetaconfig.resolves({ apiKeys: ['eo-key-123'] }); + deployContext = { + ...mockContext, + params: { siteId: TEST_SITE_ID }, + data: { + accountId: '120569600543', + externalId: '7ff9518a-cf59-40b4-aa53-68a3cb2e24a5', + distributionId: 'E2EXAMPLE123', + originId: 'origin-aem', + behavior: 'default', + }, + env: {}, + }; + }); + + it('runs the orchestrator and returns the per-step status', async () => { + const result = await controller.deployEdgeOptimize(deployContext); + + expect(result.status).to.equal(200); + const body = await result.json(); + expect(body.routingDeployed).to.equal(false); + expect(body.verified).to.equal(false); + expect(body.steps).to.deep.equal(sampleSteps); + // assumeConnectorRole is called exactly once for the whole sequence. + expect(assumeConnectorRoleStub.calledOnce).to.equal(true); + expect(runEdgeOptimizeDeployStepStub.calledOnce).to.equal(true); + const [, params] = runEdgeOptimizeDeployStepStub.firstCall.args; + expect(params).to.include({ + distributionId: 'E2EXAMPLE123', + originId: 'origin-aem', + behavior: 'default', + originDomain: 'dev.edgeoptimize.net', + accountId: '120569600543', + }); + expect(params.originHeaders).to.deep.equal({ apiKey: 'eo-key-123', forwardedHost: 'www.example.com' }); + }); + + it('passes the env-driven origin domain when set', async () => { + await controller.deployEdgeOptimize({ + ...deployContext, + env: { EDGE_OPTIMIZE_ORIGIN_DOMAIN: 'live.edgeoptimize.net' }, + }); + const [, params] = runEdgeOptimizeDeployStepStub.firstCall.args; + expect(params.originDomain).to.equal('live.edgeoptimize.net'); + }); + + it('returns 400 when the site has no Edge Optimize API key', async () => { + mockTokowakaClient.fetchMetaconfig.resolves({ apiKeys: [] }); + const result = await controller.deployEdgeOptimize(deployContext); + expect(result.status).to.equal(400); + const body = await result.json(); + expect(body.message).to.include('API key'); + expect(runEdgeOptimizeDeployStepStub.called).to.equal(false); + }); + + it('returns 400 for an invalid account id', async () => { + const result = await controller.deployEdgeOptimize({ ...deployContext, data: { ...deployContext.data, accountId: '123' } }); + expect(result.status).to.equal(400); + }); + + it('returns 400 when the external id is missing', async () => { + const result = await controller.deployEdgeOptimize({ ...deployContext, data: { ...deployContext.data, externalId: '' } }); + expect(result.status).to.equal(400); + }); + + it('returns 400 when the distributionId is missing', async () => { + const result = await controller.deployEdgeOptimize({ ...deployContext, data: { ...deployContext.data, distributionId: '' } }); + expect(result.status).to.equal(400); + const body = await result.json(); + expect(body.message).to.include('distributionId'); + }); + + it('returns 400 when the originId is missing', async () => { + const result = await controller.deployEdgeOptimize({ ...deployContext, data: { ...deployContext.data, originId: '' } }); + expect(result.status).to.equal(400); + const body = await result.json(); + expect(body.message).to.include('originId'); + }); + + it('returns 400 when the behavior is missing', async () => { + const result = await controller.deployEdgeOptimize({ ...deployContext, data: { ...deployContext.data, behavior: '' } }); + expect(result.status).to.equal(400); + const body = await result.json(); + expect(body.message).to.include('behavior'); + }); + + it('returns 400 when the orchestrator throws', async () => { + runEdgeOptimizeDeployStepStub = sinon.stub().rejects(new Error('assume failed')); + const result = await controller.deployEdgeOptimize(deployContext); + expect(result.status).to.equal(400); + const body = await result.json(); + expect(body.message).to.include('assume failed'); + }); + + it('returns 404 when the site is not found', async () => { + mockDataAccess.Site.findById.resolves(null); + const result = await controller.deployEdgeOptimize(deployContext); + expect(result.status).to.equal(404); + }); + + it('returns 403 when the user lacks access to the site', async () => { + const deniedController = controllerWithAccessDenied(mockContext); + const result = await deniedController.deployEdgeOptimize(deployContext); + expect(result.status).to.equal(403); + }); + + it('returns 403 when the user is not an LLMO administrator', async () => { + const LlmoControllerNoAdmin = await esmock('../../../src/controllers/llmo/llmo.js', { + '../../../src/support/access-control-util.js': createMockAccessControlUtil(true, true, false), + '@adobe/spacecat-shared-http-utils': mockHttpUtils, + '../../../src/support/cached-response.js': mockCachedResponse, + ...getCommonMocks(), + }); + const result = await LlmoControllerNoAdmin(mockContext) + .deployEdgeOptimize(deployContext); + expect(result.status).to.equal(403); + }); + }); + describe('getStrategy', () => { const mockStrategyData = { opportunities: [ diff --git a/test/routes/index.test.js b/test/routes/index.test.js index 5f7389a2f7..571ef2268a 100755 --- a/test/routes/index.test.js +++ b/test/routes/index.test.js @@ -339,6 +339,7 @@ describe('getRouteHandlers', () => { getEdgeOptimizeLambdaStatus: () => null, applyEdgeOptimizeAssociations: () => null, verifyEdgeOptimizeRouting: () => null, + deployEdgeOptimize: () => null, getEdgeConfig: () => null, createOrUpdateStageEdgeConfig: () => null, checkEdgeOptimizeStatus: () => null, @@ -1098,6 +1099,7 @@ describe('getRouteHandlers', () => { 'POST /sites/:siteId/llmo/edge-optimize/lambda-status', 'POST /sites/:siteId/llmo/edge-optimize/apply-associations', 'POST /sites/:siteId/llmo/edge-optimize/verify', + 'POST /sites/:siteId/llmo/edge-optimize/deploy', 'GET /sites/:siteId/llmo/edge-optimize-status', 'GET /sites/:siteId/llmo/probes/edge-optimize', 'GET /sites/:siteId/llmo/strategy', diff --git a/test/support/edge-optimize.test.js b/test/support/edge-optimize.test.js index f4be403820..d2ac211161 100644 --- a/test/support/edge-optimize.test.js +++ b/test/support/edge-optimize.test.js @@ -997,4 +997,352 @@ describe('edge-optimize support', () => { expect(error.message).to.include('url'); }); }); + + describe('runEdgeOptimizeDeployStep', () => { + let fetchStub; + const deployParams = { + distributionId: 'E2EXAMPLE123', + originId: 'origin-aem', + behavior: 'default', + originDomain: 'dev.edgeoptimize.net', + originHeaders: { apiKey: 'eo-key', forwardedHost: 'www.example.com' }, + accountId: '120569600543', + }; + + // Dispatch each client's send() by command name; per-test overrides via the `r` map. + const wire = (cf = {}, lambda = {}, iam = {}) => { + cfSendStub.callsFake((cmd) => { + const fn = cf[cmd.commandName]; + if (fn === undefined) { + throw new Error(`unexpected cf command: ${cmd.commandName}`); + } + return Promise.resolve(typeof fn === 'function' ? fn(cmd) : fn); + }); + lambdaSendStub.callsFake((cmd) => { + const fn = lambda[cmd.commandName]; + if (fn === undefined) { + throw new Error(`unexpected lambda command: ${cmd.commandName}`); + } + return Promise.resolve(typeof fn === 'function' ? fn(cmd) : fn); + }); + iamSendStub.callsFake((cmd) => { + const fn = iam[cmd.commandName]; + if (fn === undefined) { + throw new Error(`unexpected iam command: ${cmd.commandName}`); + } + return Promise.resolve(typeof fn === 'function' ? fn(cmd) : fn); + }); + }; + + const statusOf = (steps, key) => steps.find((s) => s.key === key).status; + const cfCalls = (name) => cfSendStub.getCalls().filter((c) => c.args[0].commandName === name); + + // Returns a responder that throws an AWS-style named error (so the SDK error path triggers). + const throwNamed = (name, message) => () => { + const e = new Error(message); + e.name = name; + throw e; + }; + + const makeFetchResponse = (status, headerMap) => ({ + status, + headers: { forEach: (cb) => Object.entries(headerMap).forEach(([k, v]) => cb(v, k)) }, + arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)), + }); + + afterEach(() => { + if (fetchStub) { + fetchStub.restore(); + } + fetchStub = undefined; + }); + + it('first call advances origin+function+cache and returns lambda in_progress (others pending)', async () => { + wire( + { + // origin: existing with matching headers → idempotent no-op (no UpdateDistribution). + GetDistributionConfig: { + DistributionConfig: { + Origins: { + Items: [{ + Id: 'EdgeOptimize_Origin', + DomainName: 'dev.edgeoptimize.net', + CustomHeaders: { + Items: [ + { HeaderName: 'x-edgeoptimize-api-key', HeaderValue: 'eo-key' }, + { HeaderName: 'x-forwarded-host', HeaderValue: 'www.example.com' }, + ], + }, + }], + }, + DefaultCacheBehavior: { CachePolicyId: 'cp-1' }, + }, + ETag: 'etag', + }, + // function gate: already published to LIVE → skip create+publish. + DescribeFunction: { FunctionSummary: { FunctionMetadata: { FunctionARN: 'arn:cf-fn' } } }, + // cache: custom policy already forwards EO headers + MinTTL 0 → no-op. + ListCachePolicies: { CachePolicyList: { Items: [] } }, + GetCachePolicyConfig: { + CachePolicyConfig: { + Name: 'p', + MinTTL: 0, + ParametersInCacheKeyAndForwardedToOrigin: { + HeadersConfig: { + HeaderBehavior: 'whitelist', + Headers: { Quantity: 2, Items: ['x-edgeoptimize-config', 'x-edgeoptimize-url'] }, + }, + }, + }, + ETag: 'cp-etag', + }, + }, + { + // lambda: does not exist yet → kick off create → in_progress. + GetFunctionConfiguration: throwNamed('ResourceNotFoundException', 'nope'), + ListVersionsByFunction: { Versions: [] }, + CreateFunction: { FunctionArn: 'arn:lambda', Version: '$LATEST' }, + }, + { + // Role already exists → no role-propagation wait (the slow create path is avoided). + GetRole: { Role: { Arn: 'arn:role' } }, + UpdateAssumeRolePolicy: {}, + PutRolePolicy: {}, + }, + ); + + const out = await edgeOptimize.runEdgeOptimizeDeployStep({}, deployParams); + + expect(statusOf(out.steps, 'origin')).to.equal('done'); + expect(statusOf(out.steps, 'function')).to.equal('done'); + expect(statusOf(out.steps, 'cache')).to.equal('done'); + expect(statusOf(out.steps, 'lambda')).to.equal('in_progress'); + expect(statusOf(out.steps, 'associate')).to.equal('pending'); + expect(statusOf(out.steps, 'verify')).to.equal('pending'); + expect(out.routingDeployed).to.equal(false); + expect(out.verified).to.equal(false); + // function already LIVE → never created/published. + expect(cfCalls('CreateFunction')).to.have.length(0); + expect(cfCalls('PublishFunction')).to.have.length(0); + }); + + it('with lambda ready proceeds to associate then verify (in_progress until propagation)', async () => { + const lambdaVersionArn = 'arn:aws:lambda:us-east-1:120569600543:function:edgeoptimize-origin:3'; + wire( + { + GetDistributionConfig: () => ({ + // origin exists (idempotent), default behavior NOT yet associated (associate must run). + DistributionConfig: { + Origins: { + Items: [{ + Id: 'EdgeOptimize_Origin', + DomainName: 'dev.edgeoptimize.net', + CustomHeaders: { + Items: [ + { HeaderName: 'x-edgeoptimize-api-key', HeaderValue: 'eo-key' }, + { HeaderName: 'x-forwarded-host', HeaderValue: 'www.example.com' }, + ], + }, + }], + }, + DefaultCacheBehavior: { CachePolicyId: 'cp-1' }, + }, + ETag: 'etag', + }), + DescribeFunction: { FunctionSummary: { FunctionMetadata: { FunctionARN: 'arn:cf-fn' } } }, + ListCachePolicies: { CachePolicyList: { Items: [] } }, + GetCachePolicyConfig: { + CachePolicyConfig: { + Name: 'p', + MinTTL: 0, + ParametersInCacheKeyAndForwardedToOrigin: { + HeadersConfig: { + HeaderBehavior: 'whitelist', + Headers: { Quantity: 2, Items: ['x-edgeoptimize-config', 'x-edgeoptimize-url'] }, + }, + }, + }, + ETag: 'cp-etag', + }, + UpdateDistribution: {}, + ListDistributions: { DistributionList: { Items: [{ Id: 'E2EXAMPLE123', DomainName: 'd123.cloudfront.net' }] } }, + }, + { + GetFunctionConfiguration: { State: 'Active', LastUpdateStatus: 'Successful', FunctionArn: 'arn:lambda' }, + ListVersionsByFunction: { Versions: [{ Version: '3', FunctionArn: lambdaVersionArn, CodeSha256: 'sha' }] }, + }, + { + GetRole: { Role: { Arn: 'arn:role' } }, + }, + ); + // verify probe: bot lacks request-id → not passed yet (propagation). + fetchStub = sinon.stub(global, 'fetch'); + fetchStub.onFirstCall().resolves(makeFetchResponse(200, {})); + fetchStub.onSecondCall().resolves(makeFetchResponse(200, {})); + + const out = await edgeOptimize.runEdgeOptimizeDeployStep({}, deployParams); + + expect(statusOf(out.steps, 'lambda')).to.equal('done'); + expect(statusOf(out.steps, 'associate')).to.equal('done'); + expect(statusOf(out.steps, 'verify')).to.equal('in_progress'); + expect(out.routingDeployed).to.equal(true); + expect(out.verified).to.equal(false); + // associate ran exactly one UpdateDistribution (behavior was not associated). + expect(cfCalls('UpdateDistribution')).to.have.length(1); + }); + + it('verify passes → verified true and verify done', async () => { + const lambdaVersionArn = 'arn:aws:lambda:us-east-1:120569600543:function:edgeoptimize-origin:3'; + wire( + { + GetDistributionConfig: () => ({ + DistributionConfig: { + Origins: { + Items: [{ + Id: 'EdgeOptimize_Origin', + DomainName: 'dev.edgeoptimize.net', + CustomHeaders: { + Items: [ + { HeaderName: 'x-edgeoptimize-api-key', HeaderValue: 'eo-key' }, + { HeaderName: 'x-forwarded-host', HeaderValue: 'www.example.com' }, + ], + }, + }], + }, + // already associated → associate gate skips UpdateDistribution. + DefaultCacheBehavior: { + CachePolicyId: 'cp-1', + FunctionAssociations: { Items: [{ EventType: 'viewer-request', FunctionARN: 'arn:fn/edgeoptimize-routing' }] }, + LambdaFunctionAssociations: { Items: [{ EventType: 'origin-request', LambdaFunctionARN: 'arn:edgeoptimize-origin:3' }] }, + }, + }, + ETag: 'etag', + }), + DescribeFunction: { FunctionSummary: { FunctionMetadata: { FunctionARN: 'arn:cf-fn' } } }, + ListCachePolicies: { CachePolicyList: { Items: [] } }, + GetCachePolicyConfig: { + CachePolicyConfig: { + Name: 'p', + MinTTL: 0, + ParametersInCacheKeyAndForwardedToOrigin: { + HeadersConfig: { + HeaderBehavior: 'whitelist', + Headers: { Quantity: 2, Items: ['x-edgeoptimize-config', 'x-edgeoptimize-url'] }, + }, + }, + }, + ETag: 'cp-etag', + }, + ListDistributions: { DistributionList: { Items: [{ Id: 'E2EXAMPLE123', DomainName: 'd123.cloudfront.net' }] } }, + }, + { + GetFunctionConfiguration: { State: 'Active', LastUpdateStatus: 'Successful', FunctionArn: 'arn:lambda' }, + ListVersionsByFunction: { Versions: [{ Version: '3', FunctionArn: lambdaVersionArn, CodeSha256: 'sha' }] }, + }, + { GetRole: { Role: { Arn: 'arn:role' } } }, + ); + fetchStub = sinon.stub(global, 'fetch'); + fetchStub.onFirstCall().resolves(makeFetchResponse(200, { 'x-edgeoptimize-request-id': 'req-1' })); + fetchStub.onSecondCall().resolves(makeFetchResponse(200, {})); + + const out = await edgeOptimize.runEdgeOptimizeDeployStep({}, deployParams); + + expect(statusOf(out.steps, 'associate')).to.equal('done'); + expect(statusOf(out.steps, 'verify')).to.equal('done'); + expect(out.routingDeployed).to.equal(true); + expect(out.verified).to.equal(true); + // idempotent gate: behavior already associated → no UpdateDistribution at all. + expect(cfCalls('UpdateDistribution')).to.have.length(0); + }); + + it('marks the step error (earlier done, later pending) and does not throw when a step fails', async () => { + wire( + { + GetDistributionConfig: { + DistributionConfig: { + Origins: { + Items: [{ + Id: 'EdgeOptimize_Origin', + DomainName: 'dev.edgeoptimize.net', + CustomHeaders: { + Items: [ + { HeaderName: 'x-edgeoptimize-api-key', HeaderValue: 'eo-key' }, + { HeaderName: 'x-forwarded-host', HeaderValue: 'www.example.com' }, + ], + }, + }], + }, + DefaultCacheBehavior: { CachePolicyId: 'cp-1' }, + }, + ETag: 'etag', + }, + // function gate DescribeFunction throws a non-NoSuchFunction error → step error. + DescribeFunction: () => { throw new Error('AccessDenied on DescribeFunction'); }, + }, + ); + + const out = await edgeOptimize.runEdgeOptimizeDeployStep({}, deployParams); + + expect(statusOf(out.steps, 'origin')).to.equal('done'); + expect(statusOf(out.steps, 'function')).to.equal('error'); + expect(out.steps.find((s) => s.key === 'function').detail).to.include('AccessDenied'); + // later steps remain pending. + expect(statusOf(out.steps, 'cache')).to.equal('pending'); + expect(statusOf(out.steps, 'lambda')).to.equal('pending'); + expect(out.routingDeployed).to.equal(false); + }); + + it('holds the sequence when lambda exists but is not yet ready (no re-create)', async () => { + wire( + { + GetDistributionConfig: { + DistributionConfig: { + Origins: { + Items: [{ + Id: 'EdgeOptimize_Origin', + DomainName: 'dev.edgeoptimize.net', + CustomHeaders: { + Items: [ + { HeaderName: 'x-edgeoptimize-api-key', HeaderValue: 'eo-key' }, + { HeaderName: 'x-forwarded-host', HeaderValue: 'www.example.com' }, + ], + }, + }], + }, + DefaultCacheBehavior: { CachePolicyId: 'cp-1' }, + }, + ETag: 'etag', + }, + DescribeFunction: { FunctionSummary: { FunctionMetadata: { FunctionARN: 'arn:cf-fn' } } }, + ListCachePolicies: { CachePolicyList: { Items: [] } }, + GetCachePolicyConfig: { + CachePolicyConfig: { + Name: 'p', + MinTTL: 0, + ParametersInCacheKeyAndForwardedToOrigin: { + HeadersConfig: { + HeaderBehavior: 'whitelist', + Headers: { Quantity: 2, Items: ['x-edgeoptimize-config', 'x-edgeoptimize-url'] }, + }, + }, + }, + ETag: 'cp-etag', + }, + }, + { + // exists but still finalizing → not ready, must NOT re-create. + GetFunctionConfiguration: { State: 'Pending', LastUpdateStatus: 'InProgress', FunctionArn: 'arn:lambda' }, + ListVersionsByFunction: { Versions: [] }, + }, + { GetRole: { Role: { Arn: 'arn:role' } } }, + ); + + const out = await edgeOptimize.runEdgeOptimizeDeployStep({}, deployParams); + + expect(statusOf(out.steps, 'lambda')).to.equal('in_progress'); + expect(statusOf(out.steps, 'associate')).to.equal('pending'); + // never called CreateFunction on the Lambda client (no re-create of an in-flight function). + expect(lambdaSendStub.getCalls().filter((c) => c.args[0].commandName === 'CreateFunction')).to.have.length(0); + }); + }); }); From d76198a4df0ebcfcc38c3b53dd15069ce41df6f6 Mon Sep 17 00:00:00 2001 From: Akash Bhardwaj Date: Mon, 22 Jun 2026 18:10:44 +0530 Subject: [PATCH 19/20] test(llmo): raise edge-optimize esmock before-hook timeout to 120s The one-time setupEsmock before() hook in edge-optimize.test.js exceeded the 30s hook timeout under the full CI suite (12k+ tests + nyc coverage + heap pressure), failing the build even though it completes in ~1s locally. The orchestrator's added code + tests (and a main merge) pushed cumulative suite memory over the edge. Give the one-time hook generous headroom so suite growth can't flake the whole build. Co-Authored-By: Claude Opus 4.8 --- test/support/edge-optimize.test.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/test/support/edge-optimize.test.js b/test/support/edge-optimize.test.js index d2ac211161..218d0e1e30 100644 --- a/test/support/edge-optimize.test.js +++ b/test/support/edge-optimize.test.js @@ -26,7 +26,11 @@ describe('edge-optimize support', () => { // The mocked clients call the `*SendStub` closures, which read the `let` bindings reassigned // fresh in beforeEach, so a single esmock works for all tests. before(async function setupEsmock() { - this.timeout(30000); + // One-time esmock of the AWS SDK module graph. This is memory-heavy, so under the full CI + // suite (12k+ tests + nyc coverage + heap pressure) it can take well over the default/30s + // even though it runs in ~1s locally. Give the hook generous headroom so it can't flake the + // whole build on suite growth (it still completes in seconds in practice). + this.timeout(120000); // Each command in a mocked module is a constructor FUNCTION (not a class) — eslint forbids // multiple class declarations in one file, so we capture the command name + input on `this`. const cfCommand = (Name) => function CloudFrontCommand(input) { From 834564059b082a631faabd1a626a6617b9a03ddc Mon Sep 17 00:00:00 2001 From: Akash Bhardwaj Date: Mon, 22 Jun 2026 21:33:33 +0530 Subject: [PATCH 20/20] fix(llmo): publish Lambda@Edge version in deploy orchestrator The orchestrator only called createEdgeOptimizeLambda when the function did NOT exist; once it existed (Pending -> Active) it merely status-checked. But createEdgeOptimizeLambda is the only thing that PUBLISHES the numbered version (Active + no version -> PublishVersion), and readiness requires a published version -> the Deploy step hung at 'Lambda@Edge is still provisioning' forever. Fix: call createEdgeOptimizeLambda on EVERY not-ready poll (it is idempotent + non-blocking: creates when missing, no-ops while Pending, publishes once Active). Added a unit test for the publish-on-Active path; updated the 'holds while Pending' test (createEdgeOptimizeLambda now runs but still does not CreateFunction/PublishVersion while Pending). Co-Authored-By: Claude Opus 4.8 --- src/support/edge-optimize.js | 29 +++++++----- test/support/edge-optimize.test.js | 75 ++++++++++++++++++++++++++++-- 2 files changed, 90 insertions(+), 14 deletions(-) diff --git a/src/support/edge-optimize.js b/src/support/edge-optimize.js index 257689bbf3..8537127454 100644 --- a/src/support/edge-optimize.js +++ b/src/support/edge-optimize.js @@ -1266,23 +1266,30 @@ export async function runEdgeOptimizeDeployStep( return { routingDeployed, verified, steps }; } - // ── 4. lambda — kick off / poll WITHOUT re-creating; stop here while still provisioning. ── + // ── 4. lambda — drive the create/publish state machine each poll (idempotent + non-blocking). ── + // createEdgeOptimizeLambda creates the function when missing, no-ops while it is Pending, and — + // crucially — PUBLISHES a numbered version once the function is Active (which is what flips it to + // ready). We must call it on EVERY not-ready poll, not only when the function is missing: the + // version is published on a later poll (after the function reaches Active), so if we merely + // status-check while it "exists", the version never gets published and the step hangs at + // "provisioning" forever. try { const ls = await getEdgeOptimizeLambdaStatus(credentials, region); if (ls.ready) { lambdaVersionArn = ls.versionArn; byKey('lambda').status = 'done'; - } else if (ls.exists) { - // Provisioning in progress — do NOT re-create. Hold the sequence and re-check next poll. - byKey('lambda').status = 'in_progress'; - byKey('lambda').detail = 'Lambda@Edge is still provisioning'; - return { routingDeployed, verified, steps }; } else { - // Not created yet — kick off the create (returns fast) and report in_progress. - await createEdgeOptimizeLambda(credentials, accountId, { region }); - byKey('lambda').status = 'in_progress'; - byKey('lambda').detail = 'Lambda@Edge create started'; - return { routingDeployed, verified, steps }; + const created = await createEdgeOptimizeLambda(credentials, accountId, { region }); + if (created.status === 'ready') { + lambdaVersionArn = created.versionArn; + byKey('lambda').status = 'done'; + } else { + byKey('lambda').status = 'in_progress'; + byKey('lambda').detail = ls.exists + ? 'Lambda@Edge is still provisioning' + : 'Lambda@Edge create started'; + return { routingDeployed, verified, steps }; + } } } catch (err) { byKey('lambda').status = 'error'; diff --git a/test/support/edge-optimize.test.js b/test/support/edge-optimize.test.js index 218d0e1e30..6717c92f34 100644 --- a/test/support/edge-optimize.test.js +++ b/test/support/edge-optimize.test.js @@ -1334,19 +1334,88 @@ describe('edge-optimize support', () => { }, }, { - // exists but still finalizing → not ready, must NOT re-create. + // exists but still finalizing (Pending) → createEdgeOptimizeLambda is called to drive the + // state machine, but it must NOT CreateFunction or PublishVersion while still Pending. GetFunctionConfiguration: { State: 'Pending', LastUpdateStatus: 'InProgress', FunctionArn: 'arn:lambda' }, ListVersionsByFunction: { Versions: [] }, }, - { GetRole: { Role: { Arn: 'arn:role' } } }, + { GetRole: { Role: { Arn: 'arn:role' } }, UpdateAssumeRolePolicy: {}, PutRolePolicy: {} }, ); const out = await edgeOptimize.runEdgeOptimizeDeployStep({}, deployParams); expect(statusOf(out.steps, 'lambda')).to.equal('in_progress'); expect(statusOf(out.steps, 'associate')).to.equal('pending'); - // never called CreateFunction on the Lambda client (no re-create of an in-flight function). + // Pending → neither CreateFunction nor PublishVersion (no re-create, no premature publish). expect(lambdaSendStub.getCalls().filter((c) => c.args[0].commandName === 'CreateFunction')).to.have.length(0); + expect(lambdaSendStub.getCalls().filter((c) => c.args[0].commandName === 'PublishVersion')).to.have.length(0); + }); + + it('publishes the version once the Lambda is Active, then proceeds to associate + verify', async () => { + const lambdaVersionArn = 'arn:aws:lambda:us-east-1:120569600543:function:edgeoptimize-origin:1'; + wire( + { + GetDistributionConfig: () => ({ + DistributionConfig: { + Origins: { + Items: [{ + Id: 'EdgeOptimize_Origin', + DomainName: 'dev.edgeoptimize.net', + CustomHeaders: { + Items: [ + { HeaderName: 'x-edgeoptimize-api-key', HeaderValue: 'eo-key' }, + { HeaderName: 'x-forwarded-host', HeaderValue: 'www.example.com' }, + ], + }, + }], + }, + // already associated → associate gate skips; the focus is the lambda publish path. + DefaultCacheBehavior: { + CachePolicyId: 'cp-1', + FunctionAssociations: { Items: [{ EventType: 'viewer-request', FunctionARN: 'arn:fn/edgeoptimize-routing' }] }, + LambdaFunctionAssociations: { Items: [{ EventType: 'origin-request', LambdaFunctionARN: 'arn:edgeoptimize-origin:1' }] }, + }, + }, + ETag: 'etag', + }), + DescribeFunction: { FunctionSummary: { FunctionMetadata: { FunctionARN: 'arn:cf-fn' } } }, + ListCachePolicies: { CachePolicyList: { Items: [] } }, + GetCachePolicyConfig: { + CachePolicyConfig: { + Name: 'p', + MinTTL: 0, + ParametersInCacheKeyAndForwardedToOrigin: { + HeadersConfig: { + HeaderBehavior: 'whitelist', + Headers: { Quantity: 2, Items: ['x-edgeoptimize-config', 'x-edgeoptimize-url'] }, + }, + }, + }, + ETag: 'cp-etag', + }, + ListDistributions: { DistributionList: { Items: [{ Id: 'E2EXAMPLE123', DomainName: 'd123.cloudfront.net' }] } }, + }, + { + // Active + idle, NO published version yet → createEdgeOptimizeLambda must publish one. + GetFunctionConfiguration: { State: 'Active', LastUpdateStatus: 'Successful', FunctionArn: 'arn:lambda' }, + ListVersionsByFunction: { Versions: [] }, + PublishVersion: { Version: '1', FunctionArn: lambdaVersionArn }, + }, + { GetRole: { Role: { Arn: 'arn:role' } }, UpdateAssumeRolePolicy: {}, PutRolePolicy: {} }, + ); + fetchStub = sinon.stub(global, 'fetch'); + fetchStub.onFirstCall().resolves(makeFetchResponse(200, { 'x-edgeoptimize-request-id': 'req-1' })); + fetchStub.onSecondCall().resolves(makeFetchResponse(200, {})); + + const out = await edgeOptimize.runEdgeOptimizeDeployStep({}, deployParams); + + // the fix: Active-without-version gets published → lambda flips to done (not stuck). + expect(statusOf(out.steps, 'lambda')).to.equal('done'); + expect(lambdaSendStub.getCalls().filter((c) => c.args[0].commandName === 'PublishVersion')).to.have.length(1); + expect(statusOf(out.steps, 'associate')).to.equal('done'); + expect(statusOf(out.steps, 'verify')).to.equal('done'); + expect(out.routingDeployed).to.equal(true); + expect(out.verified).to.equal(true); }); }); });