diff --git a/src/support/cloud-manager.js b/src/support/cloud-manager.js new file mode 100644 index 000000000..93947f999 --- /dev/null +++ b/src/support/cloud-manager.js @@ -0,0 +1,137 @@ +/* + * 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 { InvokeCommand, LambdaClient } from '@aws-sdk/client-lambda'; + +/** + * Client for the Cloud Manager (CM) connector Lambda. + * + * api-service cannot reach Cloud Manager's private API directly from its own + * network; a dedicated connector service reaches it (SITES-47815) and this + * client synchronously invokes that connector Lambda. + * + * It is invoked from the post-ack onboarding path only (queueDeliveryConfigWriter), + * never before the Slack 3s ack — the connector retries a lossy network path + * and can take a few seconds. + * + * All methods degrade gracefully (never throw to the caller) so onboarding is + * never blocked by CM: when the connector isn't configured/deployed/authorized, + * they return `{ verified: false, degraded: true }` and log a warning. + * + * @class + */ +export class CloudManagerClient { + /** + * @param {object} opts + * @param {string} [opts.region] - AWS region (from context.runtime.region). + * @param {string} [opts.functionName] - CM connector Lambda name + * (env.CM_CONNECTOR_FUNCTION_NAME). + * @param {object} [opts.log] - logger. + */ + constructor({ region, functionName, log } = {}) { + this.functionName = functionName; + this.log = log; + this.client = functionName ? new LambdaClient(region ? { region } : {}) : null; + } + + /** @returns {boolean} whether the connector is configured and can be invoked. */ + get enabled() { + return Boolean(this.functionName && this.client); + } + + /** + * Invoke the connector with an action payload and return its parsed JSON result. + * @param {object} payload - e.g. { action: 'get_program', programId }. + * @returns {Promise} the connector's response body. + * @throws if the connector is not configured or the invocation errors. + */ + async invokeAction(payload) { + if (!this.enabled) { + throw new Error('CloudManager connector is not configured (CM_CONNECTOR_FUNCTION_NAME)'); + } + const res = await this.client.send(new InvokeCommand({ + FunctionName: this.functionName, + InvocationType: 'RequestResponse', + Payload: Buffer.from(JSON.stringify(payload)), + })); + const text = res.Payload ? Buffer.from(res.Payload).toString('utf8') : ''; + if (res.FunctionError) { + throw new Error(`CloudManager connector error (${res.FunctionError}): ${text.slice(0, 300)}`); + } + try { + return text ? JSON.parse(text) : {}; + } catch { + throw new Error(`CloudManager connector returned non-JSON: ${text.slice(0, 300)}`); + } + } + + /** + * GET /api/program/{programId}. + * @param {string} programId + * @returns {Promise} connector response { ok, statusCode, data }. + */ + async getProgram(programId) { + return this.invokeAction({ action: 'get_program', programId }); + } + + /** + * GET /api/program/{programId}/environments. + * @param {string} programId + * @returns {Promise} connector response { ok, statusCode, data }. + */ + async listEnvironments(programId) { + return this.invokeAction({ action: 'list_environments', programId }); + } + + /** + * Best-effort verification that a (host-regex-derived) programId actually + * exists in Cloud Manager. Never throws — degrades so onboarding continues. + * @param {string} programId + * @returns {Promise<{verified: boolean, degraded?: boolean, statusCode?: number, + * program?: object, error?: string}>} + */ + async verifyProgram(programId) { + if (!this.enabled) { + this.log?.info('[cloud-manager] connector not configured; skipping program verification'); + return { verified: false, degraded: true }; + } + if (!programId) { + return { verified: false, degraded: true }; + } + try { + const r = await this.getProgram(programId); + if (!r?.ok) { + this.log?.warn(`[cloud-manager] program ${programId} not verified (status ${r?.statusCode})`); + return { verified: false, statusCode: r?.statusCode, program: r?.data }; + } + return { + verified: true, statusCode: r.statusCode, program: r.data, + }; + } catch (e) { + this.log?.warn(`[cloud-manager] verifyProgram(${programId}) failed, degrading: ${e.message}`); + return { verified: false, degraded: true, error: e.message }; + } + } +} + +/** + * Build a CloudManagerClient from a Lambda context (env + runtime + log). + * @param {object} context + * @returns {CloudManagerClient} + */ +export function createCloudManagerClient(context = {}) { + const { env = {}, runtime = {}, log } = context; + return new CloudManagerClient({ + region: runtime.region || env.AWS_REGION, + functionName: env.CM_CONNECTOR_FUNCTION_NAME, + log, + }); +} diff --git a/src/support/utils.js b/src/support/utils.js index bb7b86dfb..f64d24a15 100644 --- a/src/support/utils.js +++ b/src/support/utils.js @@ -40,6 +40,7 @@ import { PROMISE_TOKEN_REQUIRED_ERROR_CODE, } from '../utils/constants.js'; import { updateRumConfig } from './rum-config-service.js'; +import { createCloudManagerClient } from './cloud-manager.js'; // Two signals indicate a previous paid onboarding: // 1. ahref-paid-pages import — unique to the paid profile's import set. // 2. onboardConfig.lastProfile === 'paid' — set for sites backfilled via script or onboarded @@ -1507,6 +1508,23 @@ export async function queueDeliveryConfigWriter( minutes, updateRedirects, }; + + // SITES-47815: the programId/environmentId above are derived by regex on + // the AEM CS hostname (extractDeliveryConfigFromPreviewUrl), never checked + // against Cloud Manager. Best-effort verify the program actually exists in + // CM via the connector Lambda (the service that can reach CM's private + // API). This runs post-ack, so its latency is off the Slack 3s window, and + // it degrades gracefully: when the connector is not configured/deployed/ + // authorized it is a no-op and onboarding proceeds on the regex-derived + // values. `programVerified` is additive telemetry for the audit-worker. + const cloudManager = createCloudManagerClient(context); + const { verified, degraded } = await cloudManager.verifyProgram(String(programId)); + redirectParams.programVerified = verified; + if (!verified && !degraded) { + log.warn( + `[delivery-config-writer] Cloud Manager did not verify program ${programId} for ${resolvedBaseURL}; proceeding on regex-derived delivery config.`, + ); + } } else { log.info( `[delivery-config-writer] ${skipMessage}; CDN detection only.`, diff --git a/test/support/cloud-manager.test.js b/test/support/cloud-manager.test.js new file mode 100644 index 000000000..45e17bf7c --- /dev/null +++ b/test/support/cloud-manager.test.js @@ -0,0 +1,122 @@ +/* + * 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. + */ +/* eslint-env mocha */ +import { expect } from 'chai'; +import sinon from 'sinon'; +import { CloudManagerClient, createCloudManagerClient } from '../../src/support/cloud-manager.js'; + +const payload = (obj) => ({ Payload: Buffer.from(JSON.stringify(obj)) }); +const log = { info: () => {}, warn: () => {}, error: () => {} }; + +describe('CloudManagerClient', () => { + let sandbox; + beforeEach(() => { + sandbox = sinon.createSandbox(); + }); + afterEach(() => { + sandbox.restore(); + }); + + describe('enabled', () => { + it('is false without a function name', () => { + expect(new CloudManagerClient({ log }).enabled).to.equal(false); + }); + it('is true with a function name', () => { + expect(new CloudManagerClient({ functionName: 'fn', log }).enabled).to.equal(true); + }); + }); + + describe('invokeAction', () => { + it('sends an InvokeCommand and parses the JSON payload', async () => { + const c = new CloudManagerClient({ functionName: 'fn', log }); + const send = sandbox.stub(c.client, 'send').resolves(payload({ ok: true, statusCode: 200 })); + const res = await c.invokeAction({ action: 'get_program', programId: 'p1' }); + expect(res).to.deep.equal({ ok: true, statusCode: 200 }); + const sent = send.firstCall.args[0].input; + expect(sent.FunctionName).to.equal('fn'); + expect(sent.InvocationType).to.equal('RequestResponse'); + expect(JSON.parse(Buffer.from(sent.Payload).toString())).to.deep.equal({ action: 'get_program', programId: 'p1' }); + }); + + it('throws when not configured', async () => { + const c = new CloudManagerClient({ log }); + await expect(c.invokeAction({ action: 'x' })).to.be.rejectedWith(/not configured/); + }); + + it('throws on a Lambda FunctionError', async () => { + const c = new CloudManagerClient({ functionName: 'fn', log }); + sandbox.stub(c.client, 'send').resolves({ FunctionError: 'Unhandled', Payload: Buffer.from('{"errorMessage":"boom"}') }); + await expect(c.invokeAction({ action: 'x' })).to.be.rejectedWith(/connector error \(Unhandled\)/); + }); + + it('throws on a non-JSON payload', async () => { + const c = new CloudManagerClient({ functionName: 'fn', log }); + sandbox.stub(c.client, 'send').resolves({ Payload: Buffer.from('not json') }); + await expect(c.invokeAction({ action: 'x' })).to.be.rejectedWith(/non-JSON/); + }); + }); + + describe('verifyProgram (graceful degradation)', () => { + it('degrades (no throw) when the connector is not configured', async () => { + const res = await new CloudManagerClient({ log }).verifyProgram('p1'); + expect(res).to.deep.equal({ verified: false, degraded: true }); + }); + + it('degrades when no programId is supplied', async () => { + const c = new CloudManagerClient({ functionName: 'fn', log }); + const send = sandbox.stub(c.client, 'send'); + const res = await c.verifyProgram(undefined); + expect(res.degraded).to.equal(true); + expect(send.called).to.equal(false); + }); + + it('returns verified:true when CM confirms the program', async () => { + const c = new CloudManagerClient({ functionName: 'fn', log }); + sandbox.stub(c.client, 'send').resolves(payload({ ok: true, statusCode: 200, data: { id: 'p1' } })); + const res = await c.verifyProgram('p1'); + expect(res.verified).to.equal(true); + expect(res.program).to.deep.equal({ id: 'p1' }); + }); + + it('returns verified:false (not degraded) when CM responds not-ok', async () => { + const c = new CloudManagerClient({ functionName: 'fn', log }); + sandbox.stub(c.client, 'send').resolves(payload({ ok: false, statusCode: 404 })); + const res = await c.verifyProgram('p1'); + expect(res).to.include({ verified: false, statusCode: 404 }); + expect(res.degraded).to.equal(undefined); + }); + + it('degrades when the invocation throws (e.g. AccessDenied before IAM grant)', async () => { + const c = new CloudManagerClient({ functionName: 'fn', log }); + sandbox.stub(c.client, 'send').rejects(new Error('AccessDeniedException')); + const res = await c.verifyProgram('p1'); + expect(res).to.include({ verified: false, degraded: true }); + expect(res.error).to.match(/AccessDenied/); + }); + }); + + describe('createCloudManagerClient', () => { + it('reads function name from env and region from runtime', () => { + const c = createCloudManagerClient({ + env: { CM_CONNECTOR_FUNCTION_NAME: 'spacecat-services--cm-connector' }, + runtime: { region: 'us-east-1' }, + log, + }); + expect(c.enabled).to.equal(true); + expect(c.functionName).to.equal('spacecat-services--cm-connector'); + }); + + it('is disabled when the env var is absent', () => { + expect(createCloudManagerClient({ env: {}, log }).enabled).to.equal(false); + }); + }); +});