mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 17:41:09 +02:00
chore(infra): pre-commit validator for cloudflared-config.yml
Adds scripts/validate-cloudflared-config.mjs — a node-only validator
that lint-staged runs whenever cloudflared-config.yml is staged. The
goal is to catch the same failure modes that
`cloudflared tunnel ingress validate` would catch on the server, but
without requiring cloudflared to be installed on every dev box.
Checks:
- YAML parses
- tunnel: is a uuid
- credentials-file: ends with .json and contains the tunnel id
(warning when it doesn't — likely an out-of-sync remnant from a
previous rebuild, exactly the failure mode that bit us in the
first locally-managed switch)
- ingress: is a non-empty array
- every rule except the last has both hostname AND service
- the LAST rule is the catch-all `service: http_status:NNN`
- no duplicate hostnames (the most common copy-paste mistake)
- service URLs look like http(s):// / ssh:// / http_status:NNN
/ unix:/ / hello_world
- hostnames are lowercase dot-separated DNS labels (no spaces, no
weird characters)
Wired into lint-staged.config.js with a single glob entry; the
existing eslint + prettier flow is unchanged.
Tested against the live cloudflared-config.yml (passes, 51 hostnames)
and a synthetic broken file (catches all 6 categories of error +
the credentials-file/tunnel id drift warning).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
77b2d1eb32
commit
6c0f88f5a2
2 changed files with 177 additions and 0 deletions
|
|
@ -4,4 +4,11 @@ export default {
|
|||
'prettier --config .prettierrc.json --write',
|
||||
],
|
||||
'*.{json,md,svelte,astro}': ['prettier --config .prettierrc.json --write'],
|
||||
// Validate the tunnel config locally so a malformed ingress map can
|
||||
// never reach main. The validator runs entirely in node (no
|
||||
// cloudflared CLI dependency on the dev box) and catches the same
|
||||
// failure modes that `cloudflared tunnel ingress validate` would
|
||||
// catch on the server: bad YAML, missing tunnel id, duplicate
|
||||
// hostnames, missing catch-all, malformed service URLs.
|
||||
'cloudflared-config.yml': ['node scripts/validate-cloudflared-config.mjs'],
|
||||
};
|
||||
|
|
|
|||
170
scripts/validate-cloudflared-config.mjs
Executable file
170
scripts/validate-cloudflared-config.mjs
Executable file
|
|
@ -0,0 +1,170 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* Validate cloudflared-config.yml without requiring cloudflared to be
|
||||
* installed on the dev box. Run by lint-staged whenever the file is
|
||||
* staged so a malformed ingress map can never reach main.
|
||||
*
|
||||
* Checks:
|
||||
* - YAML parses
|
||||
* - tunnel: is a UUID
|
||||
* - credentials-file: is set and points at a .json under .cloudflared
|
||||
* - ingress: is a non-empty array
|
||||
* - every rule except the last has both `hostname` and `service`
|
||||
* - the LAST rule is the catch-all `service: http_status:<code>`
|
||||
* - no duplicate hostnames
|
||||
* - every `service:` looks like http(s)://, ssh://, http_status:NNN, or unix:
|
||||
* - no obvious typos in hostnames (must be lowercase, dot-separated, no spaces)
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/validate-cloudflared-config.mjs cloudflared-config.yml
|
||||
*
|
||||
* Wired into lint-staged.config.js so a `git commit` that touches
|
||||
* cloudflared-config.yml automatically runs the validator first.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { parse } from 'yaml';
|
||||
|
||||
const RED = '\x1b[31m';
|
||||
const GREEN = '\x1b[32m';
|
||||
const YELLOW = '\x1b[33m';
|
||||
const NC = '\x1b[0m';
|
||||
|
||||
const errors = [];
|
||||
const warnings = [];
|
||||
|
||||
function err(msg) {
|
||||
errors.push(msg);
|
||||
}
|
||||
function warn(msg) {
|
||||
warnings.push(msg);
|
||||
}
|
||||
|
||||
const file = process.argv[2];
|
||||
if (!file) {
|
||||
console.error(`${RED}usage: validate-cloudflared-config.mjs <file>${NC}`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
let raw;
|
||||
try {
|
||||
raw = readFileSync(file, 'utf8');
|
||||
} catch (e) {
|
||||
console.error(`${RED}cannot read ${file}: ${e.message}${NC}`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
let doc;
|
||||
try {
|
||||
doc = parse(raw);
|
||||
} catch (e) {
|
||||
console.error(`${RED}YAML parse error: ${e.message}${NC}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!doc || typeof doc !== 'object') {
|
||||
err('top-level document must be an object');
|
||||
}
|
||||
|
||||
// ─── tunnel: ───────────────────────────────────────────────
|
||||
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
if (!doc.tunnel) {
|
||||
err('missing required field `tunnel:`');
|
||||
} else if (typeof doc.tunnel !== 'string' || !UUID.test(doc.tunnel)) {
|
||||
err(`tunnel: must be a uuid, got "${doc.tunnel}"`);
|
||||
}
|
||||
|
||||
// ─── credentials-file: ─────────────────────────────────────
|
||||
if (!doc['credentials-file']) {
|
||||
err('missing required field `credentials-file:`');
|
||||
} else if (typeof doc['credentials-file'] !== 'string') {
|
||||
err('credentials-file: must be a string path');
|
||||
} else {
|
||||
const cred = doc['credentials-file'];
|
||||
if (!cred.endsWith('.json')) {
|
||||
err(`credentials-file: should end with .json, got "${cred}"`);
|
||||
}
|
||||
if (doc.tunnel && !cred.includes(doc.tunnel)) {
|
||||
warn(
|
||||
`credentials-file does not contain the tunnel id (${doc.tunnel}) — likely an out-of-sync remnant from a previous tunnel rebuild`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── ingress: ──────────────────────────────────────────────
|
||||
const ingress = doc.ingress;
|
||||
if (!Array.isArray(ingress)) {
|
||||
err('`ingress:` must be an array');
|
||||
} else if (ingress.length === 0) {
|
||||
err('`ingress:` is empty — at least the catch-all rule must be present');
|
||||
} else {
|
||||
// Last rule must be the catch-all
|
||||
const last = ingress[ingress.length - 1];
|
||||
if (last.hostname) {
|
||||
err(`last ingress rule must be the catch-all (no hostname), got hostname="${last.hostname}"`);
|
||||
}
|
||||
if (typeof last.service !== 'string' || !last.service.startsWith('http_status:')) {
|
||||
err(`last ingress rule must be a catch-all "service: http_status:NNN", got "${last.service}"`);
|
||||
}
|
||||
|
||||
const HOSTNAME = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/;
|
||||
const SERVICE =
|
||||
/^(https?:\/\/[^\s]+|ssh:\/\/[^\s]+|http_status:\d{3}|unix:\/[^\s]+|hello_world)$/;
|
||||
|
||||
const seen = new Map();
|
||||
for (let i = 0; i < ingress.length; i++) {
|
||||
const r = ingress[i];
|
||||
const isLast = i === ingress.length - 1;
|
||||
const where = `ingress[${i}]`;
|
||||
|
||||
if (!r || typeof r !== 'object') {
|
||||
err(`${where}: must be an object`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isLast) {
|
||||
if (!r.hostname) {
|
||||
err(`${where}: missing hostname (only the catch-all rule may omit it)`);
|
||||
} else if (typeof r.hostname !== 'string') {
|
||||
err(`${where}: hostname must be a string`);
|
||||
} else if (!HOSTNAME.test(r.hostname)) {
|
||||
err(
|
||||
`${where}: hostname "${r.hostname}" looks invalid (lowercase, dot-separated, no spaces)`
|
||||
);
|
||||
} else if (seen.has(r.hostname)) {
|
||||
err(
|
||||
`${where}: duplicate hostname "${r.hostname}" (also at ingress[${seen.get(r.hostname)}])`
|
||||
);
|
||||
} else {
|
||||
seen.set(r.hostname, i);
|
||||
}
|
||||
}
|
||||
|
||||
if (r.service == null) {
|
||||
err(`${where}: missing service`);
|
||||
} else if (typeof r.service !== 'string') {
|
||||
err(`${where}: service must be a string`);
|
||||
} else if (!SERVICE.test(r.service)) {
|
||||
err(
|
||||
`${where}: service "${r.service}" doesn't look like http(s)://, ssh://, http_status:NNN, or unix:`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Report ────────────────────────────────────────────────
|
||||
if (warnings.length > 0) {
|
||||
for (const w of warnings) console.error(`${YELLOW}warning${NC}: ${w}`);
|
||||
}
|
||||
if (errors.length > 0) {
|
||||
for (const e of errors) console.error(`${RED}error${NC}: ${e}`);
|
||||
console.error(
|
||||
`${RED}✗${NC} ${file}: ${errors.length} error${errors.length === 1 ? '' : 's'}, ${warnings.length} warning${warnings.length === 1 ? '' : 's'}`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const hostCount = (doc.ingress ?? []).filter((r) => r.hostname).length;
|
||||
console.log(
|
||||
`${GREEN}✓${NC} ${file}: ${hostCount} hostnames, ${warnings.length} warning${warnings.length === 1 ? '' : 's'}`
|
||||
);
|
||||
Loading…
Add table
Add a link
Reference in a new issue