Skip to content
Merged
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
30,576 changes: 15,328 additions & 15,248 deletions docs/index.html

Large diffs are not rendered by default.

33 changes: 30 additions & 3 deletions docs/openapi/aso-overlay-api.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,16 @@ get-aso-redirects:
- name: service
in: path
required: true
description: Cloud Manager service identifier.
description: |
Cloud Manager service identifier. Accepts the optional `-prev` suffix
AEM Cloud Service injects into the `AEM_SERVICE` env var on preview
publish pods (e.g. `cm-p154709-e1629980-prev`); the suffix is stripped
before all downstream lookups since preview and publish tiers share
the same overlay (Mystique writes exactly one overlay per program +
environment).
schema:
type: string
pattern: '^cm-p\d{1,10}-e\d{1,10}$'
pattern: '^cm-p\d{1,10}-e\d{1,10}(-prev)?$'
example: cm-p154709-e1629980
- name: If-None-Match
in: header
Expand Down Expand Up @@ -67,6 +73,19 @@ get-aso-redirects:
schema:
type: string
example: max-age=10
Surrogate-Key:
description: |
Fastly surrogate-key tag for targeted purging. Set to
`aso-overlay-<canonical-service>`, where `<canonical-service>`
is `cm-p<program>-e<env>` without any `-prev` suffix so publish
and preview cache entries share the tag and are invalidated
together. Mystique invokes Fastly's key-purge endpoint
(`POST /service/<sid>/purge/<key>`) with this value after
writing a new overlay so dispatchers see the change on their
next poll rather than waiting for TTL expiry.
schema:
type: string
example: aso-overlay-cm-p154709-e1629980
content:
text/plain:
schema:
Expand Down Expand Up @@ -102,13 +121,21 @@ get-aso-redirects:
schema:
type: string
example: max-age=10
Surrogate-Key:
description: |
Same value as the 200 response so any 304 stragglers still on
the old TTL are invalidated by the same targeted purge. See the
200 response Surrogate-Key description for the full contract.
schema:
type: string
example: aso-overlay-cm-p154709-e1629980
Content-Length:
description: Always `0` — RFC 7230 §3.3.3 forbids a body on 304.
schema:
type: integer
example: 0
'400':
description: Bad request. The service identifier does not match the Cloud Manager `cm-pXXX-eYYY` pattern.
description: Bad request. The service identifier does not match the Cloud Manager `cm-pXXX-eYYY` (or `cm-pXXX-eYYY-prev`) pattern.
headers:
X-Error:
$ref: './headers.yaml#/xError'
Expand Down
70 changes: 57 additions & 13 deletions src/controllers/redirects.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,32 @@ import {
// Cloud Manager service identifier, e.g. cm-p154709-e1629980. Digit runs are
// capped to a realistic length to avoid arbitrarily long lookups; the capture
// groups yield the program id (pXXXX) and environment id (eYYYY).
const SERVICE_RE = /^cm-p(\d{1,10})-e(\d{1,10})$/;
//
// The optional `-prev` suffix accepts the AEM Cloud Service preview publish
// tier's AEM_SERVICE env var shape (e.g. `cm-p154709-e1629980-prev`). AEM CS
// injects this suffix on preview pods for its own internal tier routing, but
// it must not affect overlay resolution: Mystique writes exactly one overlay
// per (program, environment) — publish and preview tiers share it. So we
// accept the suffix at the API boundary, strip it before all downstream
// lookups (Site lookup, S3 key, Surrogate-Key), and the preview pod gets the
// same content as the publish pod for the same tenant. Without this the
// preview tier polls a URL Fastly currently rejects with 401 (edge auth)
// and would 400 at origin (SERVICE_RE mismatch) — verified from the
// spacecat_oncall_aso_overlay Splunk board 2026-07-16 (~273/hr 401s at edge).
const SERVICE_RE = /^cm-p(\d{1,10})-e(\d{1,10})(-prev)?$/;

// Fastly caches on raw URL, so preview (`.../cm-p<N>-e<N>-prev/redirects.txt`)
// and publish (`.../cm-p<N>-e<N>/redirects.txt`) create two separate cache
// entries per tenant. We accept the small doubled-footprint cost because:
// 1. Both entries share `Surrogate-Key: aso-overlay-<canonical>`, so
// mystique's purge-on-Deploy invalidates both in a single call.
// 2. Both entries fetch the same S3 object (canonical), so upstream is not
// doubled beyond initial per-URL fills — subsequent polls are cache hits.
// 3. At 10k tenants and ~20% preview coverage the extra entries are cheap
// relative to the doubled origin fetches we'd take without caching.
// A future normalization at Fastly VCL (regsub in vcl_recv before cache key
// derivation) would collapse to one entry per tenant, but that requires
// Fastly-admin territory. Origin-side is the pragmatic bridge.
// Aligns with the Fastly edge TTL for this path (fetch/100-aso-overlay-ttl.vcl).
// Single source of truth so the 200 and 304 responses can't drift under future edits.
const OVERLAY_TTL_SECONDS = 10;
Expand Down Expand Up @@ -179,7 +204,11 @@ function RedirectsController(ctx) {
return internalServerError('Overlay endpoint not configured', NO_STORE_HEADERS);
}

// `match[3]` captures the optional `-prev` suffix; when preview pod hit us,
// strip it so every downstream lookup uses the canonical service ID. The
// preview tier reads the same overlay as publish (see SERVICE_RE comment).
const [, programId, environmentId] = match;
const canonicalService = `cm-p${programId}-e${environmentId}`;

// Resolve (program, env) -> Site via the indexed external-id accessor. The
// p<programId>/e<environmentId> encoding matches Site.computeExternalIds for
Expand Down Expand Up @@ -214,20 +243,35 @@ function RedirectsController(ctx) {
}

// Read the overlay with the Lambda's own execution role (no SigV4 from caller).
const key = `config/${service}/redirects.txt`;
const key = `config/${canonicalService}/redirects.txt`;
const s3StartedAt = Date.now();
// Emits AsoOverlayS3ReadDurationMs with the S3-scoped result. Kept separate
// from `emitFinal`'s request-level Outcome so dashboards don't ambiguously
// slice S3 latency by request-level codes (e.g. "S3 success on a 304").
const emitS3Duration = (s3Result) => emitMetric(
{
name: 'AsoOverlayS3ReadDurationMs',
value: Date.now() - s3StartedAt,
unit: 'Milliseconds',
dimensions: { S3Result: s3Result },
},
emitOpts,
);
// Guard against double-emission: on the 200 path we emit SUCCESS right
// after `s3Client.send()` resolves, but the body stream (`transformToString`
// below) can still throw with an SDK / network error that lands in the
// outer catch and would fire another `emitS3Duration(UNEXPECTED)` for the
// same request. Emitting two duration samples with contradictory S3Result
// dimensions skews the histogram — the caller-observable outcome (200 vs
// 500) is still correctly recorded by `emitFinal`, but the S3-tier chart
// would double-count.
let s3DurationEmitted = false;
const emitS3Duration = (s3Result) => {
if (s3DurationEmitted) {
return;
}
s3DurationEmitted = true;
emitMetric(
{
name: 'AsoOverlayS3ReadDurationMs',
value: Date.now() - s3StartedAt,
unit: 'Milliseconds',
dimensions: { S3Result: s3Result },
},
emitOpts,
);
};
try {
const command = new GetObjectCommand({ Bucket: bucketName, Key: key });
const response = await s3Client.send(command);
Expand Down Expand Up @@ -276,7 +320,7 @@ function RedirectsController(ctx) {
// old TTL can be purged by the same call. Fastly stores the header
// for edge state; RFC 7232 requires we carry cache-control + etag,
// and Surrogate-Key is an operationally-linked companion.
'surrogate-key': `aso-overlay-${service}`,
'surrogate-key': `aso-overlay-${canonicalService}`,
});
}

Expand All @@ -296,7 +340,7 @@ function RedirectsController(ctx) {
// prefix so future overlays under different routes don't collide with
// this key space. Fastly VCL strips the /config/<tier>/ prefix before
// reaching origin, so we only need per-service uniqueness (not per-tier).
'surrogate-key': `aso-overlay-${service}`,
'surrogate-key': `aso-overlay-${canonicalService}`,
});
} catch (err) {
const code = err.$metadata?.httpStatusCode;
Expand Down
8 changes: 7 additions & 1 deletion src/support/aso-overlay-key-handler.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,13 @@ import {
// the controller's own validation (RedirectsController SERVICE_RE), so only
// well-formed overlay requests are even considered for this credential.
// Tolerates the suffix with or without a leading slash (prod sets it with one).
const OVERLAY_ROUTE = /^\/?config\/cm-p\d{1,10}-e\d{1,10}\/redirects\.txt$/;
// The optional `-prev` accepts the AEM CS preview-tier AEM_SERVICE shape
// (see RedirectsController SERVICE_RE for the full rationale) — must be kept
// in lockstep with the controller, otherwise preview requests would fall
// through the auth chain and 401 before the controller could strip the
// suffix. The controller does the canonical lookup; this handler only
// authenticates.
const OVERLAY_ROUTE = /^\/?config\/cm-p\d{1,10}-e\d{1,10}(?:-prev)?\/redirects\.txt$/;

// Constant-time compare that does not leak input length: both inputs are HMAC'd
// to a fixed 32-byte digest before timingSafeEqual. The HMAC key is not a secret
Expand Down
101 changes: 101 additions & 0 deletions test/controllers/redirects.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,38 @@ describe('RedirectsController', () => {
expect(mockS3.s3Client.send.called).to.be.false;
});

it('does not double-emit AsoOverlayS3ReadDurationMs when the body stream throws', async () => {
// Regression guard: `s3Client.send()` resolves (S3 GET succeeded, headers
// received) then `transformToString()` throws (stream/decode failure).
// Without the emitS3Duration guard, we'd get two duration samples with
// contradictory S3Result dimensions (SUCCESS + UNEXPECTED) for one request,
// skewing the S3-tier chart. The guard makes emitS3Duration idempotent
// per-request so only the first result sticks.
const streamErr = new Error('stream aborted');
mockS3.s3Client.send.resolves({
ETag: ETAG,
Body: { transformToString: sandbox.stub().rejects(streamErr) },
});

// Spy on the EMF sink (metrics-emf.js writes envelopes to stdout).
const stdoutWrite = sandbox.spy(process.stdout, 'write');

const response = await controller.getRedirects(requestContext);

expect(response.status).to.equal(500);
// Count only the AsoOverlayS3ReadDurationMs envelopes — other metrics
// (RequestTotal, RequestDurationMs) legitimately emit on every request.
const durationEmissions = stdoutWrite.getCalls()
.map((c) => c.args[0])
.filter((s) => typeof s === 'string' && s.includes('AsoOverlayS3ReadDurationMs'));
expect(durationEmissions).to.have.lengthOf(
1,
`expected exactly one AsoOverlayS3ReadDurationMs emission, got ${durationEmissions.length}`,
);
// First-writer-wins: the SUCCESS from the initial send() resolution.
expect(durationEmissions[0]).to.include('"S3Result":"success"');
});

it('returns 400 for a malformed service identifier', async () => {
requestContext.params.service = 'not-a-cm-service';
const response = await controller.getRedirects(requestContext);
Expand All @@ -386,6 +418,75 @@ describe('RedirectsController', () => {
expect(mockS3.s3Client.send.called).to.be.false;
});

// AEM CS injects a `-prev` suffix in `AEM_SERVICE` for preview-tier pods
// (e.g. `cm-p154709-e1629980-prev`). Mystique writes exactly one overlay per
// (program, environment) — publish and preview share it. So we accept the
// suffix at the API boundary and strip it before all downstream lookups.
describe('preview-tier suffix (-prev)', () => {
const PREVIEW_SERVICE = `${SERVICE}-prev`;

it('accepts -prev and reads the canonical (non-prev) S3 key', async () => {
const overlay = 'example.com/old https://www.example.com/new\n';
mockS3.s3Client.send.resolves({
ETag: ETAG,
Body: { transformToString: sandbox.stub().resolves(overlay) },
});
requestContext.params.service = PREVIEW_SERVICE;

const response = await controller.getRedirects(requestContext);

expect(response.status).to.equal(200);
expect(await response.text()).to.equal(overlay);
// Site lookup uses the canonical program/environment ids — the `-prev`
// suffix is not part of the site identity.
expect(mockDataAccess.Site.findByExternalOwnerIdAndExternalSiteId
.calledWith('p154709', 'e1629980')).to.be.true;
// S3 read hits the canonical object; preview never has its own key.
expect(mockS3.GetObjectCommand.calledWithMatch({
Bucket: BUCKET,
Key: `config/${SERVICE}/redirects.txt`,
})).to.be.true;
// Surrogate-Key uses the canonical service so a single mystique
// purge-on-Deploy call invalidates both the publish and preview Fastly
// cache entries (they differ by URL only, they share the tag).
expect(response.headers.get('surrogate-key')).to.equal(`aso-overlay-${SERVICE}`);
});

it('returns 304 on -prev when If-None-Match matches (canonical ETag)', async () => {
mockS3.s3Client.send.resolves({
ETag: ETAG,
Body: { transformToString: sandbox.stub().resolves('irrelevant') },
});
requestContext.params.service = PREVIEW_SERVICE;
withIfNoneMatch(ETAG);

const response = await controller.getRedirects(requestContext);

expect(response.status).to.equal(304);
expect(response.headers.get('etag')).to.equal(ETAG);
expect(response.headers.get('surrogate-key')).to.equal(`aso-overlay-${SERVICE}`);
});

it('rejects malformed -prev variants at the boundary', async () => {
// Trailing hyphen without `prev`, embedded `-prev-`, capitalized suffix
// must all still 400 — the regex only allows a single literal `-prev`
// at the very end. Otherwise a caller could exercise arbitrary shapes.
for (const bad of [
'cm-p154709-e1629980-',
'cm-p154709-e1629980-preview',
'cm-p154709-e1629980-PREV',
'cm-p154709-e1629980-prev-2',
]) {
// eslint-disable-next-line no-await-in-loop
requestContext = { params: { service: bad }, pathInfo: { headers: {} } };
// eslint-disable-next-line no-await-in-loop
const response = await controller.getRedirects(requestContext);
expect(response.status, `expected 400 for ${bad}`).to.equal(400);
}
expect(mockS3.s3Client.send.called).to.be.false;
});
});

it('returns 404 (not 403) when no site resolves — no enumeration signal', async () => {
mockDataAccess.Site.findByExternalOwnerIdAndExternalSiteId.resolves(null);
const response = await controller.getRedirects(requestContext);
Expand Down
14 changes: 14 additions & 0 deletions test/it/shared/tests/redirects.js
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,20 @@ export default function redirectsTests(getBaseUrl) {
expect(await res.text()).to.equal(OVERLAY_BODY);
});

// AEM CS preview pods send AEM_SERVICE=cm-pN-eN-prev; both the auth handler
// and the controller must accept the suffix and resolve to the canonical
// overlay. Runs the same fixture end-to-end via `-prev` to prove no leg of
// the chain 401/400s the preview traffic before the strip happens.
it('valid key + -prev suffix → 200 (resolves to canonical overlay)', async () => {
const res = await get(
`/config/${ENTITLED_SERVICE}-prev/redirects.txt`,
{ 'x-aso-api-key': API_KEY },
);
expect(res.status).to.equal(200);
expect(res.headers.get('content-type')).to.include('text/plain');
expect(await res.text()).to.equal(OVERLAY_BODY);
});

it('missing X-ASO-API-Key → 401', async () => {
const res = await get(`/config/${ENTITLED_SERVICE}/redirects.txt`, {});
expect(res.status).to.equal(401);
Expand Down
15 changes: 15 additions & 0 deletions test/support/aso-overlay-key-handler.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,21 @@ describe('AsoOverlayKeyHandler', () => {
expect(authInfo.getType()).to.equal('aso_overlay_key');
});

it('accepts the AEM CS preview-tier -prev suffix', async () => {
// AEM CS preview pods send AEM_SERVICE=cm-pN-eN-prev; this regex must
// stay in lockstep with RedirectsController's SERVICE_RE so the auth
// layer doesn't silently 401 preview traffic before the controller
// can strip and resolve to the canonical overlay.
const authInfo = await handler.checkAuth(
makeRequest({ 'x-aso-api-key': API_KEY }),
makeContext({
pathInfo: { method: 'GET', suffix: '/config/cm-p154709-e1629980-prev/redirects.txt' },
}),
);
expect(authInfo).to.not.be.null;
expect(authInfo.getType()).to.equal('aso_overlay_key');
});

describe('dual-key rotation overlap', () => {
const PREVIOUS_KEY = 'old-aso-key-being-rotated';

Expand Down
Loading