Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 137 additions & 0 deletions src/support/cloud-manager.js
Original file line number Diff line number Diff line change
@@ -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<object>} 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<object>} 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<object>} 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,
});
}
18 changes: 18 additions & 0 deletions src/support/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.`,
Expand Down
122 changes: 122 additions & 0 deletions test/support/cloud-manager.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
Loading