fix: pre-flight the CloudFront payload limits the dry run is the only guard for
Build and deploy / build-and-deploy (push) Failing after 4s
Build and deploy / build-and-deploy (push) Failing after 4s
The second `--apply` of 2026-09-04 created adr-sml-pdf-noindex and then failed at create-origin-request-policy: InvalidArgument, "The parameter Comment is too big" — 182 characters against a 128 cap. update-distribution never ran, so the distribution is unchanged, but the account was left holding an orphaned policy. Nothing local could have caught it, and that is now measured rather than assumed: botocore/validate.py checks neither `max` nor `pattern` (range_check reads only `min`; the word `pattern` does not appear in the file), and the 128 is not modelled as a constraint at all — `Comment` is a bare `string` and the cap lives in the shape's documentation prose. So the dry run really is the only pre-flight, and it now enforces PAYLOAD_LIMITS: 13 entries across both policy payloads and the function ARN, each with the source it came from. The entries that matter guard CLONED values rather than literals this file authors — a literal is reviewed when it is written, while a value copied out of the default behaviour's policy changes with no diff here. The API declares TooLongCSPInResponseHeadersPolicy for exactly that case and docs/05 already specifies a CSP that would land there. RemoveHeadersConfig is a recorded gap: its cap is real but unpublished, and inventing a number would be worse. Both comments are now 76 and 74 characters. `--function-arn` is validated before any AWS call, and an unrecognised `--flag` is a usage error — the `=` form was invisible to the parser and to the presence check, for a clean exit 0 with no router attached. A skipped section now exits 3, because docs/09 uses exit 0 as its own success stamp and a partial run read as a complete one. Confirmed by measurement, as asked: the next run REUSES the orphan by name, matches every reconciled field, and stages it — create line gone, 4 changes down to 3, no duplicate and no collision. Reviewed twice. Round 2 found that the §7 record broke the table it lives in, and that two comments asserted behaviour the code did not have. 17 findings across both rounds, all fixed. Nothing was applied to the distribution and nothing was deployed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Md3GndFqWPzK78xAoebsg5
This commit is contained in:
co-authored by
Claude Opus 5
parent
bbe535d158
commit
a07193d561
+302
-147
@@ -48,6 +48,7 @@ import {
|
||||
withoutEmptyMembers,
|
||||
isEmptyObject,
|
||||
emptyObjectPaths,
|
||||
limitViolations,
|
||||
} from './policy-shapes.mjs';
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
@@ -57,6 +58,30 @@ const flag = (name) => {
|
||||
};
|
||||
const APPLY = args.includes('--apply');
|
||||
|
||||
/* ⚠️ AN UNRECOGNISED `--flag` IS A USAGE ERROR. `flag()` reads only the
|
||||
`--name <value>` form, so `--function-arn=<arn>` is invisible to it and to
|
||||
the presence check below: the run prints `no --function-arn given`, exits 0,
|
||||
and attaches no router. `--dist=` and `--api-domain=` already failed safe on
|
||||
the required-argument guard; `--function-arn=` was the one that degraded
|
||||
quietly. This closes the `=` form and typos together. */
|
||||
const KNOWN_FLAGS = new Set([
|
||||
'--dist',
|
||||
'--api-domain',
|
||||
'--function-arn',
|
||||
'--apply',
|
||||
]);
|
||||
const unknownFlags = args.filter(
|
||||
(a) => a.startsWith('--') && !KNOWN_FLAGS.has(a),
|
||||
);
|
||||
if (unknownFlags.length) {
|
||||
console.error(
|
||||
`unrecognised argument(s): ${unknownFlags.join(', ')}\n` +
|
||||
`Known flags: ${[...KNOWN_FLAGS].join(', ')}. A value is a separate ` +
|
||||
`argument — write \`--function-arn <arn>\`, not \`--function-arn=<arn>\`.`,
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const DIST = flag('dist');
|
||||
const API_DOMAIN = flag('api-domain');
|
||||
const FUNCTION_ARN = flag('function-arn');
|
||||
@@ -76,6 +101,35 @@ if (/^https?:/.test(API_DOMAIN) || API_DOMAIN.includes('/')) {
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
/* ⚠️ BEFORE ANY AWS CALL, because by the time the distribution payload exists
|
||||
sections 4 and 5 may already have created policies. `--api-domain` was
|
||||
guarded here and `--function-arn` was not, and the value that motivates this
|
||||
is not hypothetical: see `policy-shapes.mjs`, PAYLOAD_LIMITS. */
|
||||
/* ⚠️ PRESENCE, NOT TRUTHINESS. `docs/09` Part 2 says `$ROUTER_ARN` "is empty if
|
||||
`publish-function` failed", so `''` is the documented failure of the step
|
||||
that produces this argument — and falsy, so a truthiness test skips it here
|
||||
and again in section 1, for a clean exit 0 that attaches no router. */
|
||||
if (args.includes('--function-arn')) {
|
||||
const bad = FUNCTION_ARN
|
||||
? limitViolations('function-association', { FunctionARN: FUNCTION_ARN })
|
||||
: [
|
||||
{
|
||||
message:
|
||||
'--function-arn was given with an empty value. docs/09 Part 2: $ROUTER_ARN is empty if publish-function failed',
|
||||
},
|
||||
];
|
||||
if (bad.length) {
|
||||
console.error(
|
||||
`--function-arn is not a CloudFront function ARN:\n` +
|
||||
bad.map((b) => ` - ${b.message}`).join('\n') +
|
||||
`\n got: ${JSON.stringify(FUNCTION_ARN)}\n` +
|
||||
`Derive it with: aws cloudfront describe-function --name <name> --stage LIVE ` +
|
||||
`--query 'FunctionSummary.FunctionMetadata.FunctionARN' --output text\n` +
|
||||
`NOT with list-functions, which returns one row per stage and joins them.`,
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
}
|
||||
|
||||
/* stderr is NEVER suppressed and the exit status is always read — the AWS CLI
|
||||
reports an expired session, a missing permission and a typo'd id all on
|
||||
@@ -324,6 +378,14 @@ if (catchAll !== -1 && catchAll < apiIndex) {
|
||||
2026-09-03: all five security headers arrive on the live PDF today. */
|
||||
const PDF_PATTERN = '*.pdf';
|
||||
const PDF_POLICY_NAME = 'adr-sml-pdf-noindex';
|
||||
/* ⚠️ 128 CHARACTERS, SERVER-SIDE — nothing local checks it. Keep it short and
|
||||
put the explanation in section 4.
|
||||
|
||||
⚠️ `Comment` IS DELIBERATELY NOT IN THE DRIFT CHECK. The live policy carries
|
||||
the original, longer text (§7); reconciling it would throw on that policy and
|
||||
block the run that attaches it. */
|
||||
const PDF_POLICY_COMMENT =
|
||||
'X-Robots-Tag: noindex on *.pdf, cloned headers. See configure.mjs section 4.';
|
||||
const XRT = { Header: 'X-Robots-Tag', Value: 'noindex', Override: true };
|
||||
|
||||
const defaultRhpId = cfg.DefaultCacheBehavior.ResponseHeadersPolicyId;
|
||||
@@ -398,12 +460,17 @@ if (!defaultRhpId) {
|
||||
/* ⚠️ SKIP, NOT THROW — same rule as the missing-id case above, and it was
|
||||
inconsistent for one round. A policy carrying only `CorsConfig` is legal;
|
||||
an ABSENT source is section 4's problem alone and must not stop sections
|
||||
1-3 from re-applying `router.js`. TWO THROWS BELOW ARE DELIBERATE
|
||||
EXCEPTIONS, and they are exceptions for the same reason: DRIFT is a
|
||||
divergence rather than an absence (`docs/09` Part 3 argues for it), and the
|
||||
EMPTY-OBJECT throw means this script generated an invalid config — a bug in
|
||||
the script, not a state of the world. Neither is something a later section
|
||||
should be allowed to paper over. */
|
||||
1-3 from re-applying `router.js`.
|
||||
|
||||
⚠️ TWO CONDITIONS BELOW STILL ABORT THE WHOLE RUN, AND THEY ARE THE ONLY
|
||||
TWO: the DRIFT throw, and the throw on a `*.pdf` behaviour this script
|
||||
cannot account for. Both are divergences rather than absences — someone
|
||||
else has configured this distribution — and `docs/09` Part 3 argues for
|
||||
stopping on them. Everything else section 4 can hit, including an empty
|
||||
object in the generated config and a breached CloudFront limit, is a SKIP.
|
||||
(This comment said "the EMPTY-OBJECT throw" after that throw had been
|
||||
converted to a skip. Which conditions abort is this file's most
|
||||
safety-critical property; count them in the code, not here.) */
|
||||
if (!srcCfg?.SecurityHeadersConfig) {
|
||||
skipped.push(
|
||||
`${PDF_PATTERN} / ${PDF_POLICY_NAME} — response-headers policy ${defaultRhpId} has no SecurityHeadersConfig to clone`,
|
||||
@@ -457,15 +524,40 @@ if (!defaultRhpId) {
|
||||
section 4 never blocks sections 1-3 from re-applying `router.js`, without
|
||||
which 22 of 23 pages return S3's AccessDenied. A skip is already loud: it
|
||||
prints under its own heading and the NOTHING TO CHANGE guard names it. */
|
||||
/* Built once and checked, then sent — not rebuilt at the call. That is
|
||||
what makes the dry run a pre-flight. Checked only when a create would
|
||||
happen; a breach on a payload nobody sends is a false alarm. */
|
||||
const pdfPolicyConfig = {
|
||||
Name: PDF_POLICY_NAME,
|
||||
Comment: PDF_POLICY_COMMENT,
|
||||
...wanted,
|
||||
};
|
||||
const pdfBreaches = existingPdfPolicy
|
||||
? []
|
||||
: limitViolations('response-headers-policy', pdfPolicyConfig);
|
||||
const empties = emptyObjectPaths(wanted);
|
||||
if (empties.length) {
|
||||
skipped.push(
|
||||
`${PDF_PATTERN} / ${PDF_POLICY_NAME} — the generated policy config still ` +
|
||||
`contains ${empties.length} empty object(s) (${empties.join(', ')}), and ` +
|
||||
`AWS rejects those on ParamValidation before the call leaves the machine. ` +
|
||||
`withoutEmptyMembers should have removed them, so either it is broken or ` +
|
||||
`this script built one itself — see policy-shapes.mjs and its test.`,
|
||||
);
|
||||
/* ⚠️ ONE GUARD FOR THE WHOLE SECTION, NOT A BRANCH ROUND THE CREATE. No
|
||||
policy means `pdfPolicyId` stays null, and the behaviour below would then
|
||||
be staged carrying the placeholder string as its
|
||||
ResponseHeadersPolicyId. */
|
||||
const sectionFourBlockers = [
|
||||
...(empties.length
|
||||
? [
|
||||
`the generated policy config still contains ${empties.length} empty object(s) ` +
|
||||
`(${empties.join(', ')}), which AWS rejects on ParamValidation before the call ` +
|
||||
`leaves the machine. withoutEmptyMembers should have removed them, so either it ` +
|
||||
`is broken or this script built one itself — see policy-shapes.mjs and its test`,
|
||||
]
|
||||
: []),
|
||||
...pdfBreaches.map(
|
||||
(b) =>
|
||||
`the policy config would breach a CloudFront limit: ${b.message}`,
|
||||
),
|
||||
];
|
||||
if (sectionFourBlockers.length) {
|
||||
for (const blocker of sectionFourBlockers) {
|
||||
skipped.push(`${PDF_PATTERN} / ${PDF_POLICY_NAME} — ${blocker}`);
|
||||
}
|
||||
} else {
|
||||
if (existingPdfPolicy) {
|
||||
const have = existingPdfPolicy.ResponseHeadersPolicyConfig;
|
||||
@@ -504,7 +596,7 @@ if (!defaultRhpId) {
|
||||
k === 'CustomHeadersConfig'
|
||||
? have.CustomHeadersConfig?.Items
|
||||
: have[k],
|
||||
)}\n default : ${norm(
|
||||
)}\n wanted : ${norm(
|
||||
k === 'CustomHeadersConfig'
|
||||
? wanted.CustomHeadersConfig.Items
|
||||
: wanted[k],
|
||||
@@ -525,7 +617,8 @@ if (!defaultRhpId) {
|
||||
);
|
||||
} else if (!APPLY) {
|
||||
console.log(
|
||||
`· would CREATE response-headers policy ${PDF_POLICY_NAME}`,
|
||||
`· would CREATE response-headers policy ${PDF_POLICY_NAME}` +
|
||||
` (payload within every limit in PAYLOAD_LIMITS)`,
|
||||
);
|
||||
changes.push(
|
||||
`create response-headers policy ${PDF_POLICY_NAME} (SecurityHeadersConfig cloned from ${defaultRhpId} + X-Robots-Tag: noindex)`,
|
||||
@@ -535,12 +628,10 @@ if (!defaultRhpId) {
|
||||
'cloudfront',
|
||||
'create-response-headers-policy',
|
||||
'--response-headers-policy-config',
|
||||
JSON.stringify({
|
||||
Name: PDF_POLICY_NAME,
|
||||
Comment:
|
||||
'Cloned from the default behaviour, plus X-Robots-Tag: noindex for *.pdf. See infra/cloudfront/configure.mjs section 4.',
|
||||
...wanted,
|
||||
}),
|
||||
/* The object `limitViolations` inspected, not a second literal built
|
||||
here. Section 5 already did this; two literals that agree today are
|
||||
how a pre-flight stops covering the payload. */
|
||||
JSON.stringify(pdfPolicyConfig),
|
||||
'--output',
|
||||
'json',
|
||||
]);
|
||||
@@ -634,7 +725,10 @@ if (!defaultRhpId) {
|
||||
? ` (${pdfPolicyId})`
|
||||
: ' (policy id created in the same --apply pass)'),
|
||||
);
|
||||
if (!APPLY && !pdfPolicyId) {
|
||||
/* ⚠️ `!APPLY` ALONE, never gated on the policy id: knowing the id is a
|
||||
reason to show the payload, not to hide it. This is the only live
|
||||
change section 4 makes. */
|
||||
if (!APPLY) {
|
||||
console.log(
|
||||
`· would ADD cache behaviour ${PDF_PATTERN}:\n` +
|
||||
JSON.stringify(behaviour, null, 2)
|
||||
@@ -729,6 +823,13 @@ if (!defaultRhpId) {
|
||||
procedure for that. Forwarding a header is infrastructure. Storing it is a
|
||||
privacy-policy edit. */
|
||||
const ORP_NAME = 'adr-sml-api-viewer-address';
|
||||
/* ⚠️ THIS WAS 182 CHARACTERS AND IT FAILED THE SECOND `--apply`, 2026-09-04:
|
||||
InvalidArgument, "The parameter Comment is too big". The cap is 128 and it is
|
||||
server-side — the model types `Comment` as a bare `string`, so nothing local
|
||||
saw it. Section 4 had already created its policy by then, so the run left an
|
||||
orphan. `docs/09` Part 3 carries both attempts. */
|
||||
const ORP_COMMENT =
|
||||
'Forwards CloudFront-Viewer-Address on /api/*. See configure.mjs section 5.';
|
||||
/* Sorted, because the drift check below compares this list to what CloudFront
|
||||
returns and an ordering difference would read as a drift. */
|
||||
const ORP_HEADERS = [
|
||||
@@ -801,10 +902,25 @@ if (!apiBehaviour) {
|
||||
);
|
||||
} else {
|
||||
const reads = handlerHeaderReads();
|
||||
if (reads === null) {
|
||||
skipped.push(
|
||||
`${PATH_PATTERN} / ${ORP_NAME} — backend/intake/handler.mjs is not in this checkout, so the whitelist could not be checked against the handler's own reads`,
|
||||
);
|
||||
/* ⚠️ THE PROBE CAN BREAK, AND A BROKEN PROBE READS AS A CLEAN PASS. `reads`
|
||||
is `null` when the file is absent and `[]` when the regex stops matching —
|
||||
rename `headerOf`, switch to double quotes, pass the name as a constant,
|
||||
and `missing` is empty, nothing throws, and the run prints
|
||||
`· whitelist covers all 0 headers the handler reads ()` before replacing the
|
||||
policy on the intake form's path. CLAUDE.md: re-check "uniformly GOOD" too.
|
||||
The handler has four reads today.
|
||||
|
||||
⚠️ EITHER CASE SKIPS THE WHOLE SECTION — recording a skip and continuing
|
||||
would replace the policy on the live intake path with a whitelist nothing
|
||||
verified, under a heading saying nothing changed. */
|
||||
const readsBlocker =
|
||||
reads === null
|
||||
? "backend/intake/handler.mjs is not in this checkout, so the whitelist could not be checked against the handler's own reads"
|
||||
: reads.length === 0
|
||||
? "the handler-reads probe matched nothing. It greps for headerOf(event, '<name>') in backend/intake/handler.mjs, which has four reads today, so zero means the probe is broken rather than that the handler reads nothing"
|
||||
: null;
|
||||
if (readsBlocker) {
|
||||
skipped.push(`${PATH_PATTERN} / ${ORP_NAME} — ${readsBlocker}`);
|
||||
} else {
|
||||
const lower = ORP_HEADERS.map((h) => h.toLowerCase());
|
||||
const missing = reads.filter((h) => !lower.includes(h));
|
||||
@@ -823,131 +939,160 @@ if (!apiBehaviour) {
|
||||
console.log(
|
||||
`· whitelist covers all ${reads.length} headers the handler reads (${reads.join(', ')})`,
|
||||
);
|
||||
}
|
||||
|
||||
const existingOrp = findApiOriginRequestPolicy();
|
||||
let orpId = existingOrp?.Id ?? null;
|
||||
const existingOrp = findApiOriginRequestPolicy();
|
||||
let orpId = existingOrp?.Id ?? null;
|
||||
|
||||
const wantedOrp = {
|
||||
HeadersConfig: {
|
||||
HeaderBehavior: 'whitelist',
|
||||
Headers: { Quantity: ORP_HEADERS.length, Items: ORP_HEADERS },
|
||||
},
|
||||
CookiesConfig: { CookieBehavior: 'all' },
|
||||
QueryStringsConfig: { QueryStringBehavior: 'all' },
|
||||
};
|
||||
const wantedOrp = {
|
||||
HeadersConfig: {
|
||||
HeaderBehavior: 'whitelist',
|
||||
Headers: { Quantity: ORP_HEADERS.length, Items: ORP_HEADERS },
|
||||
},
|
||||
CookiesConfig: { CookieBehavior: 'all' },
|
||||
QueryStringsConfig: { QueryStringBehavior: 'all' },
|
||||
};
|
||||
|
||||
if (existingOrp) {
|
||||
const have = existingOrp.OriginRequestPolicyConfig;
|
||||
const norm = (o) => JSON.stringify(o ?? null);
|
||||
/* Compare the header ITEMS as a sorted set rather than the whole
|
||||
HeadersConfig object: CloudFront echoes `Quantity` back and a list that
|
||||
differs only in order is the same forwarding rule. A drift report that
|
||||
fires on ordering is a drift report nobody reads twice. */
|
||||
const haveHeaders = [...(have.HeadersConfig?.Headers?.Items ?? [])].sort();
|
||||
const drift = [];
|
||||
if (have.HeadersConfig?.HeaderBehavior !== 'whitelist')
|
||||
drift.push([
|
||||
'HeaderBehavior',
|
||||
have.HeadersConfig?.HeaderBehavior,
|
||||
'whitelist',
|
||||
/* Built once and checked, for the reason section 4 gives — and this is the
|
||||
payload that actually failed. */
|
||||
const orpPolicyConfig = {
|
||||
Name: ORP_NAME,
|
||||
Comment: ORP_COMMENT,
|
||||
...wantedOrp,
|
||||
};
|
||||
const orpBreaches = existingOrp
|
||||
? []
|
||||
: limitViolations('origin-request-policy', orpPolicyConfig);
|
||||
|
||||
if (existingOrp) {
|
||||
const have = existingOrp.OriginRequestPolicyConfig;
|
||||
const norm = (o) => JSON.stringify(o ?? null);
|
||||
/* Compare the header ITEMS as a sorted set rather than the whole
|
||||
HeadersConfig object: CloudFront echoes `Quantity` back and a list that
|
||||
differs only in order is the same forwarding rule. A drift report that
|
||||
fires on ordering is a drift report nobody reads twice. */
|
||||
const haveHeaders = [
|
||||
...(have.HeadersConfig?.Headers?.Items ?? []),
|
||||
].sort();
|
||||
const drift = [];
|
||||
if (have.HeadersConfig?.HeaderBehavior !== 'whitelist')
|
||||
drift.push([
|
||||
'HeaderBehavior',
|
||||
have.HeadersConfig?.HeaderBehavior,
|
||||
'whitelist',
|
||||
]);
|
||||
if (norm(haveHeaders) !== norm([...ORP_HEADERS].sort()))
|
||||
drift.push(['Headers', norm(haveHeaders), norm(ORP_HEADERS)]);
|
||||
for (const k of ['CookiesConfig', 'QueryStringsConfig']) {
|
||||
if (norm(have[k]) !== norm(wantedOrp[k]))
|
||||
drift.push([k, norm(have[k]), norm(wantedOrp[k])]);
|
||||
}
|
||||
if (drift.length) {
|
||||
/* Both sides, same rule as section 4: naming the field does not say which
|
||||
direction to repair in, and here the two directions are "the handler
|
||||
reads a header nobody forwards" and "CloudFront forwards a header
|
||||
nobody reads". Only one of those loses inquiries. */
|
||||
throw new Error(
|
||||
`${ORP_NAME} has DRIFTED from what this script expects on ` +
|
||||
`${drift.length} field(s). ${PATH_PATTERN} is the intake form's path, ` +
|
||||
`so read which way before repairing:\n` +
|
||||
drift
|
||||
.map(
|
||||
([k, a, b]) => ` ${k}\n live : ${a}\n wanted : ${b}`,
|
||||
)
|
||||
.join('\n') +
|
||||
`\nReconcile with update-origin-request-policy (it needs the policy's ` +
|
||||
`own ETag), then re-run.` +
|
||||
`\n\nNOTE: in an --apply run this throws AFTER section 4 may already have ` +
|
||||
`created ${PDF_POLICY_NAME}, and BEFORE update-distribution is called — ` +
|
||||
`so a policy can exist that no behaviour references. That is harmless ` +
|
||||
`and self-healing: the next run finds it by name, matches it, and ` +
|
||||
`attaches it. Do not delete it by hand.`,
|
||||
);
|
||||
}
|
||||
console.log(`· origin request policy ${ORP_NAME} exists and matches`);
|
||||
} else if (orpBreaches.length) {
|
||||
skipped.push(
|
||||
`${PATH_PATTERN} / ${ORP_NAME} — the policy config this script would send breaches ` +
|
||||
`${orpBreaches.length} CloudFront limit(s): ${orpBreaches.map((b) => b.message).join('; ')}`,
|
||||
);
|
||||
} else if (!APPLY) {
|
||||
console.log(
|
||||
`· would CREATE origin request policy ${ORP_NAME}` +
|
||||
` (payload within every limit in PAYLOAD_LIMITS)`,
|
||||
);
|
||||
changes.push(
|
||||
`create origin request policy ${ORP_NAME} (whitelist: ${ORP_HEADERS.join(', ')}; cookies all; query strings all)`,
|
||||
);
|
||||
} else {
|
||||
const created = aws([
|
||||
'cloudfront',
|
||||
'create-origin-request-policy',
|
||||
'--origin-request-policy-config',
|
||||
JSON.stringify(orpPolicyConfig),
|
||||
'--output',
|
||||
'json',
|
||||
]);
|
||||
if (norm(haveHeaders) !== norm([...ORP_HEADERS].sort()))
|
||||
drift.push(['Headers', norm(haveHeaders), norm(ORP_HEADERS)]);
|
||||
for (const k of ['CookiesConfig', 'QueryStringsConfig']) {
|
||||
if (norm(have[k]) !== norm(wantedOrp[k]))
|
||||
drift.push([k, norm(have[k]), norm(wantedOrp[k])]);
|
||||
orpId = created?.OriginRequestPolicy?.Id;
|
||||
if (!orpId) {
|
||||
throw new Error(
|
||||
`create-origin-request-policy returned no Id for ${ORP_NAME}`,
|
||||
);
|
||||
}
|
||||
changes.push(`created origin request policy ${ORP_NAME} (${orpId})`);
|
||||
}
|
||||
if (drift.length) {
|
||||
/* Both sides, same rule as section 4: naming the field does not say which
|
||||
direction to repair in, and here the two directions are "the handler
|
||||
reads a header nobody forwards" and "CloudFront forwards a header
|
||||
nobody reads". Only one of those loses inquiries. */
|
||||
throw new Error(
|
||||
`${ORP_NAME} has DRIFTED from what this script expects on ` +
|
||||
`${drift.length} field(s). ${PATH_PATTERN} is the intake form's path, ` +
|
||||
`so read which way before repairing:\n` +
|
||||
drift
|
||||
.map(([k, a, b]) => ` ${k}\n live : ${a}\n wanted : ${b}`)
|
||||
.join('\n') +
|
||||
`\nReconcile with update-origin-request-policy (it needs the policy's ` +
|
||||
`own ETag), then re-run.` +
|
||||
`\n\nNOTE: in an --apply run this throws AFTER section 4 may already have ` +
|
||||
`created ${PDF_POLICY_NAME}, and BEFORE update-distribution is called — ` +
|
||||
`so a policy can exist that no behaviour references. That is harmless ` +
|
||||
`and self-healing: the next run finds it by name, matches it, and ` +
|
||||
`attaches it. Do not delete it by hand.`,
|
||||
|
||||
/* 🛑 NO POLICY, NO ATTACHMENT. If the create above was skipped, `orpId` is
|
||||
null — and the `--apply` branch below assigns it unconditionally, so this
|
||||
would set the intake form's own behaviour to a null OriginRequestPolicyId.
|
||||
That REMOVES header forwarding from `/api/*`: `Origin`, `Referer` and
|
||||
`Content-Type` stop reaching the handler and every submission fails while
|
||||
looking like the visitor's browser. The dry run staged the line too, which
|
||||
is how this was found. */
|
||||
if (orpBreaches.length || (APPLY && !orpId)) {
|
||||
console.log(
|
||||
`· NOT touching ${PATH_PATTERN}'s OriginRequestPolicyId — ${ORP_NAME} was not created`,
|
||||
);
|
||||
} else if (orpId && apiBehaviour.OriginRequestPolicyId === orpId) {
|
||||
console.log(`· ${PATH_PATTERN} already uses ${ORP_NAME}`);
|
||||
} else if (!APPLY) {
|
||||
console.log(
|
||||
`· would SET ${PATH_PATTERN} OriginRequestPolicyId -> ${ORP_NAME}` +
|
||||
` (from ${apiBehaviour.OriginRequestPolicyId})`,
|
||||
);
|
||||
changes.push(
|
||||
`${PATH_PATTERN} OriginRequestPolicyId ${apiBehaviour.OriginRequestPolicyId} -> ${ORP_NAME}`,
|
||||
);
|
||||
} else {
|
||||
const from = apiBehaviour.OriginRequestPolicyId;
|
||||
apiBehaviour.OriginRequestPolicyId = orpId;
|
||||
changes.push(
|
||||
`${PATH_PATTERN} OriginRequestPolicyId ${from} -> ${orpId} (${ORP_NAME})`,
|
||||
);
|
||||
/* Printed at the moment of the change, not only in the runbook, because the
|
||||
operator who needs it most is the one who did not read Part 3 first.
|
||||
|
||||
⚠️ IT NAMED THE OLD ID AS `Managed-AllViewerExceptHostHeader` WITHOUT
|
||||
CHECKING, and printed an empty string when the behaviour carried no
|
||||
policy at all — an "id" an operator would paste into a rollback. It now
|
||||
says only what it read, and says so when it read nothing.
|
||||
|
||||
⚠️ AND IT SAID "and re-apply", WHICH NAMES THIS SCRIPT. Re-running with
|
||||
--apply RE-ATTACHES the whitelist: section 5 converges forward and does
|
||||
not know a revert from a first run (they are byte-identical in the
|
||||
config). The rollback is a direct `update-distribution`, and the runbook
|
||||
says so in the sentence under its code block; this line no longer
|
||||
contradicts it. */
|
||||
console.log(
|
||||
from
|
||||
? ` ↩ ROLLBACK for ${PATH_PATTERN}: PUT OriginRequestPolicyId back to ${from}` +
|
||||
`${from === allViewerExceptHost ? ' (Managed-AllViewerExceptHostHeader)' : ''}` +
|
||||
' with update-distribution --if-match. Do NOT re-run this script to' +
|
||||
' roll back — it would re-attach the whitelist.'
|
||||
: ` ↩ ROLLBACK for ${PATH_PATTERN}: the behaviour carried NO origin request` +
|
||||
' policy before this change. Remove the field with' +
|
||||
' update-distribution --if-match; do NOT re-run this script.',
|
||||
);
|
||||
}
|
||||
console.log(`· origin request policy ${ORP_NAME} exists and matches`);
|
||||
} else if (!APPLY) {
|
||||
console.log(`· would CREATE origin request policy ${ORP_NAME}`);
|
||||
changes.push(
|
||||
`create origin request policy ${ORP_NAME} (whitelist: ${ORP_HEADERS.join(', ')}; cookies all; query strings all)`,
|
||||
);
|
||||
} else {
|
||||
const created = aws([
|
||||
'cloudfront',
|
||||
'create-origin-request-policy',
|
||||
'--origin-request-policy-config',
|
||||
JSON.stringify({
|
||||
Name: ORP_NAME,
|
||||
Comment:
|
||||
'Forwards CloudFront-Viewer-Address plus the four headers the intake handler reads. Replaces Managed-AllViewerExceptHostHeader on /api/*. See infra/cloudfront/configure.mjs section 5.',
|
||||
...wantedOrp,
|
||||
}),
|
||||
'--output',
|
||||
'json',
|
||||
]);
|
||||
orpId = created?.OriginRequestPolicy?.Id;
|
||||
if (!orpId) {
|
||||
throw new Error(
|
||||
`create-origin-request-policy returned no Id for ${ORP_NAME}`,
|
||||
);
|
||||
}
|
||||
changes.push(`created origin request policy ${ORP_NAME} (${orpId})`);
|
||||
}
|
||||
|
||||
if (orpId && apiBehaviour.OriginRequestPolicyId === orpId) {
|
||||
console.log(`· ${PATH_PATTERN} already uses ${ORP_NAME}`);
|
||||
} else if (!APPLY) {
|
||||
console.log(
|
||||
`· would SET ${PATH_PATTERN} OriginRequestPolicyId -> ${ORP_NAME}` +
|
||||
` (from ${apiBehaviour.OriginRequestPolicyId})`,
|
||||
);
|
||||
changes.push(
|
||||
`${PATH_PATTERN} OriginRequestPolicyId ${apiBehaviour.OriginRequestPolicyId} -> ${ORP_NAME}`,
|
||||
);
|
||||
} else {
|
||||
const from = apiBehaviour.OriginRequestPolicyId;
|
||||
apiBehaviour.OriginRequestPolicyId = orpId;
|
||||
changes.push(
|
||||
`${PATH_PATTERN} OriginRequestPolicyId ${from} -> ${orpId} (${ORP_NAME})`,
|
||||
);
|
||||
/* Printed at the moment of the change, not only in the runbook, because the
|
||||
operator who needs it most is the one who did not read Part 3 first.
|
||||
|
||||
⚠️ IT NAMED THE OLD ID AS `Managed-AllViewerExceptHostHeader` WITHOUT
|
||||
CHECKING, and printed an empty string when the behaviour carried no
|
||||
policy at all — an "id" an operator would paste into a rollback. It now
|
||||
says only what it read, and says so when it read nothing.
|
||||
|
||||
⚠️ AND IT SAID "and re-apply", WHICH NAMES THIS SCRIPT. Re-running with
|
||||
--apply RE-ATTACHES the whitelist: section 5 converges forward and does
|
||||
not know a revert from a first run (they are byte-identical in the
|
||||
config). The rollback is a direct `update-distribution`, and the runbook
|
||||
says so in the sentence under its code block; this line no longer
|
||||
contradicts it. */
|
||||
console.log(
|
||||
from
|
||||
? ` ↩ ROLLBACK for ${PATH_PATTERN}: PUT OriginRequestPolicyId back to ${from}` +
|
||||
`${from === allViewerExceptHost ? ' (Managed-AllViewerExceptHostHeader)' : ''}` +
|
||||
' with update-distribution --if-match. Do NOT re-run this script to' +
|
||||
' roll back — it would re-attach the whitelist.'
|
||||
: ` ↩ ROLLBACK for ${PATH_PATTERN}: the behaviour carried NO origin request` +
|
||||
' policy before this change. Remove the field with' +
|
||||
' update-distribution --if-match; do NOT re-run this script.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -966,13 +1111,22 @@ if (skipped.length) {
|
||||
console.log(' CloudFront-Viewer-Address — the skip above says which.');
|
||||
console.log('');
|
||||
}
|
||||
/* ⚠️ A SKIPPED SECTION MUST NOT EXIT 0. `docs/09` uses `exit 0` as its own
|
||||
success stamp throughout, so a partial run that returned 0 read as a complete
|
||||
one — and two of the skip paths here are recent (a breached limit, a broken
|
||||
handler probe), where the same conditions previously surfaced as a failed AWS
|
||||
call, which is loud. 3 means: sections that could run did, something was
|
||||
skipped, read the ⚠ block. */
|
||||
const EXIT_SKIPPED = 3;
|
||||
const exitCode = skipped.length ? EXIT_SKIPPED : 0;
|
||||
|
||||
if (changes.length === 0) {
|
||||
console.log(
|
||||
skipped.length
|
||||
? 'NOTHING TO CHANGE — but see the skips above; the distribution does NOT carry all five.'
|
||||
: 'NOTHING TO CHANGE — the distribution already carries all five.',
|
||||
);
|
||||
process.exit(0);
|
||||
process.exit(exitCode);
|
||||
}
|
||||
console.log(
|
||||
`${changes.length} change(s) to distribution ${DIST} (ETag ${etag}):`,
|
||||
@@ -982,7 +1136,7 @@ console.log('');
|
||||
|
||||
if (!APPLY) {
|
||||
console.log('DRY RUN — nothing was sent. Re-run with --apply to write it.');
|
||||
process.exit(0);
|
||||
process.exit(exitCode);
|
||||
}
|
||||
|
||||
const res = aws([
|
||||
@@ -1003,3 +1157,4 @@ console.log(
|
||||
"runbook's verification block:\n" +
|
||||
` aws cloudfront wait distribution-deployed --id ${DIST}`,
|
||||
);
|
||||
process.exit(exitCode);
|
||||
|
||||
Reference in New Issue
Block a user